Browse Source

Document every test with description + AAA pattern

Adds a leading paragraph describing what each test verifies and Arrange /
Act / Assert markers (split into numbered phases for multi-step tests) so
the intent of each fixture is readable from comments alone.

Assisted-by: Claude:claude-opus-4-7:Claude Code
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
84a0307196
  1. 266
      ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs
  2. 118
      ILSpy.Tests/Editor/DecompilerViewTests.cs
  3. 7
      ILSpy.Tests/MainWindow/MainMenuTests.cs
  4. 31
      ILSpy.Tests/MainWindow/MainWindowTests.cs
  5. 39
      ILSpy.Tests/Navigation/NavigationTests.cs
  6. 50
      ILSpy.Tests/Resources/IconCompositionTests.cs
  7. 56
      ILSpy.Tests/Resources/ResourceFactoryTests.cs

266
ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs

@ -24,24 +24,20 @@ using System.Xml.Linq;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.DataGridHierarchical;
using Avalonia.Headless.NUnit; using Avalonia.Headless.NUnit;
using Avalonia.Threading; using Avalonia.Threading;
using Avalonia.VisualTree; using Avalonia.VisualTree;
using AwesomeAssertions; using AwesomeAssertions;
using ICSharpCode.ILSpy.Properties;
using ICSharpCode.Decompiler.TypeSystem; using ICSharpCode.Decompiler.TypeSystem;
using ICSharpCode.ILSpy.Properties;
using ICSharpCode.ILSpyX; using ICSharpCode.ILSpyX;
using ICSharpCode.ILSpyX.TreeView;
using ILSpy; using ILSpy;
using ILSpy.AppEnv; using ILSpy.AppEnv;
using ILSpy.AssemblyTree; using ILSpy.AssemblyTree;
using ILSpy.Commands; using ILSpy.Commands;
using ILSpy.Images;
using ILSpy.TreeNodes; using ILSpy.TreeNodes;
using ILSpy.ViewModels; using ILSpy.ViewModels;
using ILSpy.Views; using ILSpy.Views;
@ -56,37 +52,49 @@ public class AssemblyTreeTests
[AvaloniaTest] [AvaloniaTest]
public async Task Assembly_Node_Has_Resources_Folder_When_Module_Has_Resources() public async Task Assembly_Node_Has_Resources_Folder_When_Module_Has_Resources()
{ {
// Assemblies with embedded resources expose a "Resources" folder under the assembly
// node. Verifies the folder is present, contains entries, and every entry is an
// ILSpyTreeNode (typed factory routes specialised handlers, generic falls back to
// ResourceTreeNode — both are ILSpyTreeNodes).
// Arrange — boot, wait for assemblies.
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!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3);
// Pick a loaded assembly that ships embedded resources. CoreLib always has at least // Act — pick CoreLib (always ships localised error-message tables), expand it.
// the localised error-message tables.
var coreLibName = typeof(object).Assembly.GetName().Name!; var coreLibName = typeof(object).Assembly.GetName().Name!;
var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>(coreLibName); var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>(coreLibName);
assemblyNode.EnsureLazyChildren(); assemblyNode.EnsureLazyChildren();
assemblyNode.IsExpanded = true;
// Assert — exactly one Resources folder, named, non-empty, every child an ILSpyTreeNode.
var resources = assemblyNode.Children.OfType<ResourceListTreeNode>().Single(); var resources = assemblyNode.Children.OfType<ResourceListTreeNode>().Single();
resources.Text.Should().Be(Resources._Resources); resources.Text.Should().Be(Resources._Resources);
resources.EnsureLazyChildren(); resources.EnsureLazyChildren();
resources.IsExpanded = true;
resources.Children.Should().NotBeEmpty(); resources.Children.Should().NotBeEmpty();
// The factory dispatcher routes typed resources (.xml/.png/.ico/.cur/.xaml) to
// specialised ResourceEntryNode subclasses; everything else falls back to
// ResourceTreeNode. Both branches are ILSpyTreeNodes.
resources.Children.Should().AllBeAssignableTo<ILSpyTreeNode>(); resources.Children.Should().AllBeAssignableTo<ILSpyTreeNode>();
} }
[AvaloniaTest] [AvaloniaTest]
public async Task DataGrid_Has_Extended_Selection_Mode() public async Task DataGrid_Has_Extended_Selection_Mode()
{ {
// The assembly tree DataGrid must run in Extended selection mode so users can
// Ctrl-click to multi-select rows.
// Arrange — boot, wait for assemblies + AssemblyListPane to materialise.
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!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3);
// Act — locate the realised DataGrid inside the AssemblyListPane.
var pane = await window.WaitForComponent<AssemblyListPane>(); var pane = await window.WaitForComponent<AssemblyListPane>();
var treeGrid = await pane.WaitForComponent<DataGrid>(); var treeGrid = await pane.WaitForComponent<DataGrid>();
// Assert — SelectionMode is Extended.
treeGrid.SelectionMode.Should().Be(DataGridSelectionMode.Extended, treeGrid.SelectionMode.Should().Be(DataGridSelectionMode.Extended,
"the assembly tree must let users Ctrl-click multiple rows"); "the assembly tree must let users Ctrl-click multiple rows");
} }
@ -94,6 +102,11 @@ public class AssemblyTreeTests
[AvaloniaTest] [AvaloniaTest]
public async Task Multi_Selection_Tracks_Multiple_Nodes_And_Marks_Them_IsSelected() public async Task Multi_Selection_Tracks_Multiple_Nodes_And_Marks_Them_IsSelected()
{ {
// Adding multiple nodes to AssemblyTreeModel.SelectedItems must reflect on each node's
// IsSelected flag (used by the tree view template binding) and removing one node must
// flip its IsSelected back without affecting siblings.
// Arrange — boot, wait for assemblies, expand Enumerable, grab two methods.
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!;
@ -102,20 +115,26 @@ public class AssemblyTreeTests
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");
typeNode.EnsureLazyChildren(); typeNode.EnsureLazyChildren();
typeNode.IsExpanded = true;
var first = typeNode.Children.OfType<MethodTreeNode>() var first = typeNode.Children.OfType<MethodTreeNode>()
.Single(m => m.MethodDefinition.Name == "AsEnumerable"); .Single(m => m.MethodDefinition.Name == "AsEnumerable");
var second = typeNode.Children.OfType<MethodTreeNode>() var second = typeNode.Children.OfType<MethodTreeNode>()
.First(m => m.MethodDefinition.Name == "Empty"); .First(m => m.MethodDefinition.Name == "Empty");
// Act 1 — add both nodes to the multi-selection.
vm.AssemblyTreeModel.SelectedItems.Add(first); vm.AssemblyTreeModel.SelectedItems.Add(first);
vm.AssemblyTreeModel.SelectedItems.Add(second); vm.AssemblyTreeModel.SelectedItems.Add(second);
// Assert 1 — both are tracked and both report IsSelected.
vm.AssemblyTreeModel.SelectedItems.Should().Contain(first); vm.AssemblyTreeModel.SelectedItems.Should().Contain(first);
vm.AssemblyTreeModel.SelectedItems.Should().Contain(second); vm.AssemblyTreeModel.SelectedItems.Should().Contain(second);
first.IsSelected.Should().BeTrue(); first.IsSelected.Should().BeTrue();
second.IsSelected.Should().BeTrue(); second.IsSelected.Should().BeTrue();
// Act 2 — remove one.
vm.AssemblyTreeModel.SelectedItems.Remove(first); vm.AssemblyTreeModel.SelectedItems.Remove(first);
// Assert 2 — only the removed one flips back.
first.IsSelected.Should().BeFalse(); first.IsSelected.Should().BeFalse();
second.IsSelected.Should().BeTrue(); second.IsSelected.Should().BeTrue();
} }
@ -123,6 +142,11 @@ public class AssemblyTreeTests
[AvaloniaTest] [AvaloniaTest]
public async Task Loading_Zip_Package_Surfaces_Folders_And_Entries() public async Task Loading_Zip_Package_Surfaces_Folders_And_Entries()
{ {
// Loading a .zip should run the bundled-archive file loader: nested directories become
// PackageFolderTreeNodes, top-level files become ResourceTreeNodes.
// Arrange — boot, wait for assemblies, build a fixture zip with a flat file + two
// folders containing one file each.
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!;
@ -141,6 +165,7 @@ public class AssemblyTreeTests
w.Write("<html />"); w.Write("<html />");
} }
// Act — open the zip via the OpenCommand and wait for it to load.
var registry = AppComposition.Current.GetExport<MainMenuCommandRegistry>(); var registry = AppComposition.Current.GetExport<MainMenuCommandRegistry>();
var openCommand = registry.Commands var openCommand = registry.Commands
.Single(c => c.Metadata.Header == nameof(Resources._Open)) .Single(c => c.Metadata.Header == nameof(Resources._Open))
@ -156,7 +181,10 @@ public class AssemblyTreeTests
var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>(loaded.ShortName); var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>(loaded.ShortName);
assemblyNode.EnsureLazyChildren(); assemblyNode.EnsureLazyChildren();
assemblyNode.IsExpanded = true;
// Assert — both nested folders show as PackageFolderTreeNodes; the flat file shows as
// a ResourceTreeNode with its original entry name.
var folderNames = assemblyNode.Children.OfType<PackageFolderTreeNode>() var folderNames = assemblyNode.Children.OfType<PackageFolderTreeNode>()
.Select(f => (string)f.Text) .Select(f => (string)f.Text)
.ToList(); .ToList();
@ -172,6 +200,10 @@ public class AssemblyTreeTests
[AvaloniaTest] [AvaloniaTest]
public async Task Dot_Resources_File_Resolves_To_ResourcesFileTreeNode() public async Task Dot_Resources_File_Resolves_To_ResourcesFileTreeNode()
{ {
// .resources entries get the dedicated ResourcesFileTreeNode handler, which exposes a
// string-table or inner ResourceEntryNode children (depending on payload type).
// Arrange — boot, wait for assemblies, expand CoreLib's Resources folder.
var 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!;
@ -180,15 +212,21 @@ public class AssemblyTreeTests
var coreLibName = typeof(object).Assembly.GetName().Name!; var coreLibName = typeof(object).Assembly.GetName().Name!;
var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>(coreLibName); var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>(coreLibName);
assemblyNode.EnsureLazyChildren(); assemblyNode.EnsureLazyChildren();
assemblyNode.IsExpanded = true;
var resources = assemblyNode.Children.OfType<ResourceListTreeNode>().Single(); var resources = assemblyNode.Children.OfType<ResourceListTreeNode>().Single();
resources.EnsureLazyChildren(); resources.EnsureLazyChildren();
resources.IsExpanded = true;
// Act — look up the first .resources file and select it.
var resourceFileNode = resources.Children.OfType<ResourcesFileTreeNode>().FirstOrDefault(); var resourceFileNode = resources.Children.OfType<ResourcesFileTreeNode>().FirstOrDefault();
((object?)resourceFileNode).Should().NotBeNull( ((object?)resourceFileNode).Should().NotBeNull(
"CoreLib must ship at least one embedded .resources file"); "CoreLib must ship at least one embedded .resources file");
resourceFileNode!.Resource.Name.Should().EndWith(".resources"); resourceFileNode!.Resource.Name.Should().EndWith(".resources");
resourceFileNode.EnsureLazyChildren(); resourceFileNode.EnsureLazyChildren();
// Assert — either inner children or string-table entries — at least one branch must
// surface non-empty content.
(resourceFileNode.Children.Count > 0 || resourceFileNode.StringTableEntries.Count > 0) (resourceFileNode.Children.Count > 0 || resourceFileNode.StringTableEntries.Count > 0)
.Should().BeTrue("a .resources file must surface either inner ResourceEntryNode children or string-table entries"); .Should().BeTrue("a .resources file must surface either inner ResourceEntryNode children or string-table entries");
} }
@ -196,6 +234,10 @@ public class AssemblyTreeTests
[AvaloniaTest] [AvaloniaTest]
public async Task Assembly_Node_Has_References_Folder_Child() public async Task Assembly_Node_Has_References_Folder_Child()
{ {
// Each assembly node exposes a "References" folder child whose children are
// AssemblyReferenceTreeNodes — one per AssemblyRef row.
// Arrange — boot, wait for assemblies, expand System.Linq.
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!;
@ -203,11 +245,16 @@ public class AssemblyTreeTests
var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq"); var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq");
assemblyNode.EnsureLazyChildren(); assemblyNode.EnsureLazyChildren();
assemblyNode.IsExpanded = true;
// Act — find the References folder.
var refFolder = assemblyNode.Children.OfType<ReferenceFolderTreeNode>().Single(); var refFolder = assemblyNode.Children.OfType<ReferenceFolderTreeNode>().Single();
// Assert — folder is named, expands to non-empty AssemblyReferenceTreeNode children.
refFolder.Text.Should().Be(Resources.References); refFolder.Text.Should().Be(Resources.References);
refFolder.EnsureLazyChildren(); refFolder.EnsureLazyChildren();
refFolder.IsExpanded = true;
refFolder.Children.Should().NotBeEmpty(); refFolder.Children.Should().NotBeEmpty();
refFolder.Children.Should().AllBeAssignableTo<AssemblyReferenceTreeNode>(); refFolder.Children.Should().AllBeAssignableTo<AssemblyReferenceTreeNode>();
} }
@ -215,6 +262,13 @@ public class AssemblyTreeTests
[AvaloniaTest] [AvaloniaTest]
public async Task Assembly_Reference_Icon_Settles_To_Assembly_Once_Resolved() public async Task Assembly_Reference_Icon_Settles_To_Assembly_Once_Resolved()
{ {
// AssemblyReferenceTreeNode.Icon starts as the AssemblyLoading glyph and asynchronously
// flips to the resolved Assembly glyph once the resolver finishes — verifies the
// async-icon two-stage flow.
// Arrange — boot, wait for assemblies, force lazy children on assembly + References.
// Stay model-only (no IsExpanded = true) — visually expanding rows would render the
// icons, triggering the resolver and losing the lazy "loading" state we're asserting.
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!;
@ -228,18 +282,25 @@ public class AssemblyTreeTests
var refNode = refFolder.Children.OfType<AssemblyReferenceTreeNode>() var refNode = refFolder.Children.OfType<AssemblyReferenceTreeNode>()
.First(n => n.AssemblyReference.Name == "System.Runtime"); .First(n => n.AssemblyReference.Name == "System.Runtime");
// First access is "loading" — the resolver runs on the background dispatcher. // Act 1 + Assert 1 — first access triggers the loading state.
var initialIcon = refNode.Icon; var initialIcon = refNode.Icon;
initialIcon.Should().BeSameAs(global::ILSpy.Images.Images.AssemblyLoading); initialIcon.Should().BeSameAs(global::ILSpy.Images.Images.AssemblyLoading);
// Act 2 — wait for resolver to finish.
await Waiters.WaitForAsync(() => await Waiters.WaitForAsync(() =>
!ReferenceEquals(refNode.Icon, global::ILSpy.Images.Images.AssemblyLoading)); !ReferenceEquals(refNode.Icon, global::ILSpy.Images.Images.AssemblyLoading));
// Assert 2 — icon settles on the resolved Assembly glyph.
refNode.Icon.Should().BeSameAs(global::ILSpy.Images.Images.Assembly); refNode.Icon.Should().BeSameAs(global::ILSpy.Images.Images.Assembly);
} }
[AvaloniaTest] [AvaloniaTest]
public async Task Assembly_Reference_Has_Referenced_Types_Subnode() public async Task Assembly_Reference_Has_Referenced_Types_Subnode()
{ {
// Each AssemblyReferenceTreeNode contains a "Referenced Types" subnode that lists the
// TypeRef rows the host assembly imports from that reference.
// Arrange — boot, wait for assemblies, expand System.Linq → References → System.Runtime.
var 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!;
@ -247,16 +308,24 @@ public class AssemblyTreeTests
var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq"); var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq");
assemblyNode.EnsureLazyChildren(); assemblyNode.EnsureLazyChildren();
assemblyNode.IsExpanded = true;
var refFolder = assemblyNode.Children.OfType<ReferenceFolderTreeNode>().Single(); var refFolder = assemblyNode.Children.OfType<ReferenceFolderTreeNode>().Single();
refFolder.EnsureLazyChildren(); refFolder.EnsureLazyChildren();
refFolder.IsExpanded = true;
var refNode = refFolder.Children.OfType<AssemblyReferenceTreeNode>() var refNode = refFolder.Children.OfType<AssemblyReferenceTreeNode>()
.First(n => n.AssemblyReference.Name == "System.Runtime"); .First(n => n.AssemblyReference.Name == "System.Runtime");
refNode.EnsureLazyChildren(); refNode.EnsureLazyChildren();
refNode.IsExpanded = true;
// Act — find the ReferencedTypes subnode and expand it.
var typesNode = refNode.Children.OfType<AssemblyReferenceReferencedTypesTreeNode>().Single(); var typesNode = refNode.Children.OfType<AssemblyReferenceReferencedTypesTreeNode>().Single();
typesNode.Text.ToString().Should().Contain(Resources.ReferencedTypes);
typesNode.EnsureLazyChildren(); typesNode.EnsureLazyChildren();
typesNode.IsExpanded = true;
// Assert — header text contains the expected label, and the children include at least
// one TypeReferenceTreeNode.
typesNode.Text.ToString().Should().Contain(Resources.ReferencedTypes);
typesNode.Children.Should().NotBeEmpty(); typesNode.Children.Should().NotBeEmpty();
typesNode.Children.Should().Contain(c => c is TypeReferenceTreeNode); typesNode.Children.Should().Contain(c => c is TypeReferenceTreeNode);
} }
@ -264,6 +333,10 @@ public class AssemblyTreeTests
[AvaloniaTest] [AvaloniaTest]
public async Task FindNamespaceNode_Returns_The_Tree_Node_For_A_Loaded_Namespace() public async Task FindNamespaceNode_Returns_The_Tree_Node_For_A_Loaded_Namespace()
{ {
// AssemblyTreeNode.FindNamespaceNode is the lookup primitive used by navigation and
// search to land on a specific namespace.
// Arrange — boot, wait for assemblies, expand System.Linq.
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!;
@ -271,8 +344,12 @@ public class AssemblyTreeTests
var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq"); var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq");
assemblyNode.EnsureLazyChildren(); assemblyNode.EnsureLazyChildren();
assemblyNode.IsExpanded = true;
// Act — look up the System.Linq namespace.
var ns = assemblyNode.FindNamespaceNode("System.Linq"); var ns = assemblyNode.FindNamespaceNode("System.Linq");
// Assert — returns the actual NamespaceTreeNode child, not a clone.
((object?)ns).Should().NotBeNull(); ((object?)ns).Should().NotBeNull();
ns!.Name.Should().Be("System.Linq"); ns!.Name.Should().Be("System.Linq");
assemblyNode.Children.OfType<NamespaceTreeNode>().Should().Contain(ns); assemblyNode.Children.OfType<NamespaceTreeNode>().Should().Contain(ns);
@ -281,6 +358,10 @@ public class AssemblyTreeTests
[AvaloniaTest] [AvaloniaTest]
public async Task FindTypeNode_Returns_The_Tree_Node_For_A_Loaded_Type() public async Task FindTypeNode_Returns_The_Tree_Node_For_A_Loaded_Type()
{ {
// AssemblyTreeNode.FindTypeNode is the lookup primitive used by navigation and search
// to land on a specific type by ITypeDefinition.
// Arrange — boot, wait for assemblies, expand System.Linq.
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!;
@ -288,13 +369,17 @@ public class AssemblyTreeTests
var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq"); var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq");
assemblyNode.EnsureLazyChildren(); assemblyNode.EnsureLazyChildren();
assemblyNode.IsExpanded = true;
// Act — resolve System.Linq.Enumerable's ITypeDefinition, then look up its tree node.
var module = assemblyNode.LoadedAssembly.GetMetadataFileOrNull()!; var module = assemblyNode.LoadedAssembly.GetMetadataFileOrNull()!;
var typeSystem = (MetadataModule)module.GetTypeSystemOrNull()!.MainModule; var typeSystem = (MetadataModule)module.GetTypeSystemOrNull()!.MainModule;
var enumerable = typeSystem.TopLevelTypeDefinitions var enumerable = typeSystem.TopLevelTypeDefinitions
.First(t => t.ReflectionName == "System.Linq.Enumerable"); .First(t => t.ReflectionName == "System.Linq.Enumerable");
var node = assemblyNode.FindTypeNode(enumerable); var node = assemblyNode.FindTypeNode(enumerable);
// Assert — non-null, with the matching metadata token.
((object?)node).Should().NotBeNull(); ((object?)node).Should().NotBeNull();
node!.Handle.Should().Be((System.Reflection.Metadata.TypeDefinitionHandle)enumerable.MetadataToken); node!.Handle.Should().Be((System.Reflection.Metadata.TypeDefinitionHandle)enumerable.MetadataToken);
} }
@ -302,6 +387,10 @@ public class AssemblyTreeTests
[AvaloniaTest] [AvaloniaTest]
public async Task Member_Reference_Node_Uses_Method_Or_Field_Overlay_Icon() public async Task Member_Reference_Node_Uses_Method_Or_Field_Overlay_Icon()
{ {
// MemberReferenceTreeNode renders with an icon that overlays a "reference arrow" on
// top of the underlying Method/Field glyph — verifies that overlay routing works.
// Arrange — boot, wait for assemblies, expand System.Linq → References.
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!;
@ -309,23 +398,28 @@ public class AssemblyTreeTests
var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq"); var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq");
assemblyNode.EnsureLazyChildren(); assemblyNode.EnsureLazyChildren();
assemblyNode.IsExpanded = true;
var refFolder = assemblyNode.Children.OfType<ReferenceFolderTreeNode>().Single(); var refFolder = assemblyNode.Children.OfType<ReferenceFolderTreeNode>().Single();
refFolder.EnsureLazyChildren(); refFolder.EnsureLazyChildren();
refFolder.IsExpanded = true;
// Walk every reference's ReferencedTypes subtree until we find a TypeRef that // Act — walk every reference's ReferencedTypes subtree until we find a TypeRef with
// has at least one MemberReference. System.Linq imports plenty (e.g. delegates, // at least one MemberReference. System.Linq imports plenty (delegates, Func ctors)
// Func ctors) so this should always succeed. // so this should always succeed.
MemberReferenceTreeNode? memberRefNode = null; MemberReferenceTreeNode? memberRefNode = null;
foreach (var refNode in refFolder.Children.OfType<AssemblyReferenceTreeNode>()) foreach (var refNode in refFolder.Children.OfType<AssemblyReferenceTreeNode>())
{ {
refNode.EnsureLazyChildren(); refNode.EnsureLazyChildren();
refNode.IsExpanded = true;
var typesNode = refNode.Children.OfType<AssemblyReferenceReferencedTypesTreeNode>().FirstOrDefault(); var typesNode = refNode.Children.OfType<AssemblyReferenceReferencedTypesTreeNode>().FirstOrDefault();
if (typesNode == null) if (typesNode == null)
continue; continue;
typesNode.EnsureLazyChildren(); typesNode.EnsureLazyChildren();
typesNode.IsExpanded = true;
foreach (var typeRef in typesNode.Children.OfType<TypeReferenceTreeNode>()) foreach (var typeRef in typesNode.Children.OfType<TypeReferenceTreeNode>())
{ {
typeRef.EnsureLazyChildren(); typeRef.EnsureLazyChildren();
typeRef.IsExpanded = true;
memberRefNode = typeRef.Children.OfType<MemberReferenceTreeNode>().FirstOrDefault(); memberRefNode = typeRef.Children.OfType<MemberReferenceTreeNode>().FirstOrDefault();
if (memberRefNode != null) if (memberRefNode != null)
break; break;
@ -334,6 +428,7 @@ public class AssemblyTreeTests
break; break;
} }
// Assert — non-null, icon is one of the two ref glyphs, label is non-empty.
((object?)memberRefNode).Should().NotBeNull( ((object?)memberRefNode).Should().NotBeNull(
"at least one TypeRef under System.Linq's references must have member references"); "at least one TypeRef under System.Linq's references must have member references");
memberRefNode!.Icon.Should().BeOneOf(global::ILSpy.Images.Images.MethodReference, memberRefNode!.Icon.Should().BeOneOf(global::ILSpy.Images.Images.MethodReference,
@ -344,14 +439,16 @@ public class AssemblyTreeTests
[AvaloniaTest] [AvaloniaTest]
public async Task Exported_Type_Node_Uses_Export_Overlay_Icon() public async Task Exported_Type_Node_Uses_Export_Overlay_Icon()
{ {
// ExportedTypeTreeNode (type forwarders, e.g. mscorlib forwarding to
// System.Private.CoreLib) renders with the dedicated ExportedType glyph.
// Arrange — boot, wait for assemblies. Open mscorlib explicitly: it's the canonical
// type-forwarder facade and we need a known forwarder regardless of default startup.
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!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3);
// `mscorlib` is the canonical type-forwarder facade: it reports ExportedTypes that
// resolve to System.Private.CoreLib. Open it explicitly so the test fixture has a
// loaded forwarder regardless of what the default startup list includes.
var coreLibDir = System.IO.Path.GetDirectoryName(typeof(object).Assembly.Location)!; var coreLibDir = System.IO.Path.GetDirectoryName(typeof(object).Assembly.Location)!;
var mscorlibPath = System.IO.Path.Combine(coreLibDir, "mscorlib.dll"); var mscorlibPath = System.IO.Path.Combine(coreLibDir, "mscorlib.dll");
if (!System.IO.File.Exists(mscorlibPath)) if (!System.IO.File.Exists(mscorlibPath))
@ -374,22 +471,28 @@ public class AssemblyTreeTests
var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>(loaded.ShortName); var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>(loaded.ShortName);
assemblyNode.EnsureLazyChildren(); assemblyNode.EnsureLazyChildren();
assemblyNode.IsExpanded = true;
var refFolder = assemblyNode.Children.OfType<ReferenceFolderTreeNode>().Single(); var refFolder = assemblyNode.Children.OfType<ReferenceFolderTreeNode>().Single();
refFolder.EnsureLazyChildren(); refFolder.EnsureLazyChildren();
refFolder.IsExpanded = true;
// Act — walk every reference's ReferencedTypes for an ExportedTypeTreeNode.
ExportedTypeTreeNode? exportedNode = null; ExportedTypeTreeNode? exportedNode = null;
foreach (var refNode in refFolder.Children.OfType<AssemblyReferenceTreeNode>()) foreach (var refNode in refFolder.Children.OfType<AssemblyReferenceTreeNode>())
{ {
refNode.EnsureLazyChildren(); refNode.EnsureLazyChildren();
refNode.IsExpanded = true;
var typesNode = refNode.Children.OfType<AssemblyReferenceReferencedTypesTreeNode>().FirstOrDefault(); var typesNode = refNode.Children.OfType<AssemblyReferenceReferencedTypesTreeNode>().FirstOrDefault();
if (typesNode == null) if (typesNode == null)
continue; continue;
typesNode.EnsureLazyChildren(); typesNode.EnsureLazyChildren();
typesNode.IsExpanded = true;
exportedNode = typesNode.Children.OfType<ExportedTypeTreeNode>().FirstOrDefault(); exportedNode = typesNode.Children.OfType<ExportedTypeTreeNode>().FirstOrDefault();
if (exportedNode != null) if (exportedNode != null)
break; break;
} }
// Assert — non-null, uses the ExportedType glyph, label is non-empty.
((object?)exportedNode).Should().NotBeNull( ((object?)exportedNode).Should().NotBeNull(
"mscorlib must forward types to System.Private.CoreLib via ExportedType rows"); "mscorlib must forward types to System.Private.CoreLib via ExportedType rows");
exportedNode!.Icon.Should().BeSameAs(global::ILSpy.Images.Images.ExportedType); exportedNode!.Icon.Should().BeSameAs(global::ILSpy.Images.Images.ExportedType);
@ -399,6 +502,10 @@ public class AssemblyTreeTests
[AvaloniaTest] [AvaloniaTest]
public async Task Expanding_Assembly_Reference_Reveals_Transitive_References() public async Task Expanding_Assembly_Reference_Reveals_Transitive_References()
{ {
// AssemblyReferenceTreeNode shows the reference's *own* AssemblyRef rows as children
// — building a transitive reference tree the user can drill into.
// Arrange — boot, wait for assemblies, expand System.Linq → References.
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!;
@ -406,27 +513,36 @@ public class AssemblyTreeTests
var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq"); var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq");
assemblyNode.EnsureLazyChildren(); assemblyNode.EnsureLazyChildren();
assemblyNode.IsExpanded = true;
var refFolder = assemblyNode.Children.OfType<ReferenceFolderTreeNode>().Single(); var refFolder = assemblyNode.Children.OfType<ReferenceFolderTreeNode>().Single();
refFolder.EnsureLazyChildren(); refFolder.EnsureLazyChildren();
refFolder.IsExpanded = true;
// Many BCL refs resolve to facade assemblies with zero transitive references; iterate // Act — many BCL refs resolve to facade assemblies with zero transitive references;
// until we find one that has its own AssemblyRefs to verify the transitive walk works. // iterate until we find one that has its own AssemblyRefs.
bool foundTransitive = false; bool foundTransitive = false;
foreach (var refNode in refFolder.Children.OfType<AssemblyReferenceTreeNode>()) foreach (var refNode in refFolder.Children.OfType<AssemblyReferenceTreeNode>())
{ {
refNode.EnsureLazyChildren(); refNode.EnsureLazyChildren();
refNode.IsExpanded = true;
if (refNode.Children.OfType<AssemblyReferenceTreeNode>().Any()) if (refNode.Children.OfType<AssemblyReferenceTreeNode>().Any())
{ {
foundTransitive = true; foundTransitive = true;
break; break;
} }
} }
// Assert — at least one reference exposes its own AssemblyRefs.
foundTransitive.Should().BeTrue("at least one reference must expose its own AssemblyRefs as child nodes"); foundTransitive.Should().BeTrue("at least one reference must expose its own AssemblyRefs as child nodes");
} }
[AvaloniaTest] [AvaloniaTest]
public async Task Activating_Assembly_Reference_Selects_Resolved_Assembly_Node() public async Task Activating_Assembly_Reference_Selects_Resolved_Assembly_Node()
{ {
// Double-clicking (Activate) an AssemblyReferenceTreeNode should jump to the resolved
// assembly's AssemblyTreeNode in the tree — auto-loading it if necessary.
// Arrange — boot, wait for assemblies, expand System.Linq → References.
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!;
@ -434,15 +550,19 @@ public class AssemblyTreeTests
var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq"); var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq");
assemblyNode.EnsureLazyChildren(); assemblyNode.EnsureLazyChildren();
assemblyNode.IsExpanded = true;
var refFolder = assemblyNode.Children.OfType<ReferenceFolderTreeNode>().Single(); var refFolder = assemblyNode.Children.OfType<ReferenceFolderTreeNode>().Single();
refFolder.EnsureLazyChildren(); refFolder.EnsureLazyChildren();
refFolder.IsExpanded = true;
var refNode = refFolder.Children.OfType<AssemblyReferenceTreeNode>() var refNode = refFolder.Children.OfType<AssemblyReferenceTreeNode>()
.Single(n => n.AssemblyReference.Name == "System.Runtime"); .Single(n => n.AssemblyReference.Name == "System.Runtime");
// Act — fire ActivateItem with a stub event-args so we can assert handled.
var args = new StubRoutedEventArgs(); var args = new StubRoutedEventArgs();
refNode.ActivateItem(args); refNode.ActivateItem(args);
// Assert — args.Handled flips and the selection lands on the resolved assembly node.
args.Handled.Should().BeTrue(); args.Handled.Should().BeTrue();
await Waiters.WaitForAsync(() => await Waiters.WaitForAsync(() =>
vm.AssemblyTreeModel.SelectedItem is AssemblyTreeNode selected vm.AssemblyTreeModel.SelectedItem is AssemblyTreeNode selected
@ -457,6 +577,12 @@ public class AssemblyTreeTests
[AvaloniaTest] [AvaloniaTest]
public async Task Selecting_Method_Auto_Loads_Referenced_Assemblies() public async Task Selecting_Method_Auto_Loads_Referenced_Assemblies()
{ {
// Decompiling a method whose body references types from currently-unloaded assemblies
// must auto-load those assemblies (so the decompiler can resolve them) — and the
// auto-loaded entries must be flagged IsAutoLoaded.
// Arrange — boot, wait for assemblies, open System.Net.Http (its CancelPendingRequests
// pulls in plenty of currently-unreferenced types).
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!;
@ -481,9 +607,12 @@ public class AssemblyTreeTests
.Select(a => a.FileName) .Select(a => a.FileName)
.ToHashSet(System.StringComparer.OrdinalIgnoreCase); .ToHashSet(System.StringComparer.OrdinalIgnoreCase);
// Act — select CancelPendingRequests, wait for decompile, then for new (auto-loaded)
// assemblies to appear.
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>( var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
loaded.ShortName, "System.Net.Http", "System.Net.Http.HttpClient"); loaded.ShortName, "System.Net.Http", "System.Net.Http.HttpClient");
typeNode.EnsureLazyChildren(); typeNode.EnsureLazyChildren();
typeNode.IsExpanded = true;
var methodNode = typeNode.Children.OfType<MethodTreeNode>() var methodNode = typeNode.Children.OfType<MethodTreeNode>()
.First(m => m.MethodDefinition.Name == "CancelPendingRequests"); .First(m => m.MethodDefinition.Name == "CancelPendingRequests");
@ -493,6 +622,8 @@ public class AssemblyTreeTests
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)));
// Assert — non-empty set of newly-loaded entries, all flagged IsAutoLoaded both on
// LoadedAssembly and on the matching tree node.
var addedAssemblies = vm.AssemblyTreeModel.AssemblyList!.GetAssemblies() var addedAssemblies = vm.AssemblyTreeModel.AssemblyList!.GetAssemblies()
.Where(a => !initialFiles.Contains(a.FileName)) .Where(a => !initialFiles.Contains(a.FileName))
.ToList(); .ToList();
@ -509,6 +640,11 @@ public class AssemblyTreeTests
[AvaloniaTest] [AvaloniaTest]
public async Task Auto_Loaded_Assemblies_Are_Not_Persisted() public async Task Auto_Loaded_Assemblies_Are_Not_Persisted()
{ {
// Auto-loaded assemblies are an artefact of an in-flight decompile, not of the user's
// curated list — saving the assembly list must omit them while keeping the ones the
// user actually opened.
// Arrange — boot, wait for assemblies, open + select a method that triggers auto-load.
var 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!;
@ -532,6 +668,7 @@ public class AssemblyTreeTests
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>( var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
loaded.ShortName, "System.Net.Http", "System.Net.Http.HttpClient"); loaded.ShortName, "System.Net.Http", "System.Net.Http.HttpClient");
typeNode.EnsureLazyChildren(); typeNode.EnsureLazyChildren();
typeNode.IsExpanded = true;
var methodNode = typeNode.Children.OfType<MethodTreeNode>() var methodNode = typeNode.Children.OfType<MethodTreeNode>()
.First(m => m.MethodDefinition.Name == "CancelPendingRequests"); .First(m => m.MethodDefinition.Name == "CancelPendingRequests");
@ -541,6 +678,8 @@ public class AssemblyTreeTests
await Waiters.WaitForAsync(() => await Waiters.WaitForAsync(() =>
vm.AssemblyTreeModel.AssemblyList!.GetAssemblies().Any(a => a.IsAutoLoaded)); vm.AssemblyTreeModel.AssemblyList!.GetAssemblies().Any(a => a.IsAutoLoaded));
// Act — save the assembly list to disk, then read the file back and tally the persisted
// FileName entries.
var settingsService = AppComposition.Current.GetExport<SettingsService>(); var settingsService = AppComposition.Current.GetExport<SettingsService>();
var manager = settingsService.AssemblyListManager; var manager = settingsService.AssemblyListManager;
manager.SaveList(vm.AssemblyTreeModel.AssemblyList!); manager.SaveList(vm.AssemblyTreeModel.AssemblyList!);
@ -551,6 +690,7 @@ public class AssemblyTreeTests
.Select(e => e.Value) .Select(e => e.Value)
.ToHashSet(System.StringComparer.OrdinalIgnoreCase); .ToHashSet(System.StringComparer.OrdinalIgnoreCase);
// Assert — every IsAutoLoaded assembly was omitted; every manual one is present.
var assemblies = vm.AssemblyTreeModel.AssemblyList!.GetAssemblies(); var assemblies = vm.AssemblyTreeModel.AssemblyList!.GetAssemblies();
var autoLoaded = assemblies.Where(a => a.IsAutoLoaded).ToList(); var autoLoaded = assemblies.Where(a => a.IsAutoLoaded).ToList();
autoLoaded.Should().NotBeEmpty(); autoLoaded.Should().NotBeEmpty();
@ -565,6 +705,10 @@ public class AssemblyTreeTests
[AvaloniaTest] [AvaloniaTest]
public async Task Opening_Assembly_Adds_It_To_The_Tree() public async Task Opening_Assembly_Adds_It_To_The_Tree()
{ {
// OpenCommand must add the freshly-opened assembly to the AssemblyList, materialise a
// matching tree node, and select that node so the user sees their newly-opened file.
// Arrange — boot, wait for assemblies, snapshot the initial count.
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!;
@ -573,6 +717,7 @@ public class AssemblyTreeTests
var newAsmPath = typeof(System.Net.Http.HttpClient).Assembly.Location; var newAsmPath = typeof(System.Net.Http.HttpClient).Assembly.Location;
var initialCount = vm.AssemblyTreeModel.AssemblyList!.Count; var initialCount = vm.AssemblyTreeModel.AssemblyList!.Count;
// Act — fire OpenCommand on a new path and wait for the load to complete.
var registry = AppComposition.Current.GetExport<MainMenuCommandRegistry>(); var registry = AppComposition.Current.GetExport<MainMenuCommandRegistry>();
var openCommand = registry.Commands var openCommand = registry.Commands
.Single(c => c.Metadata.Header == nameof(Resources._Open)) .Single(c => c.Metadata.Header == nameof(Resources._Open))
@ -587,6 +732,8 @@ public class AssemblyTreeTests
.First(a => string.Equals(a.FileName, newAsmPath, System.StringComparison.OrdinalIgnoreCase)); .First(a => string.Equals(a.FileName, newAsmPath, System.StringComparison.OrdinalIgnoreCase));
await loaded.GetLoadResultAsync(); await loaded.GetLoadResultAsync();
// Assert — count incremented by one; the corresponding tree node points at the
// freshly-loaded assembly and is selected.
vm.AssemblyTreeModel.AssemblyList!.Count.Should().Be(initialCount + 1); vm.AssemblyTreeModel.AssemblyList!.Count.Should().Be(initialCount + 1);
var node = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>(loaded.ShortName); var node = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>(loaded.ShortName);
node.LoadedAssembly.Should().BeSameAs(loaded); node.LoadedAssembly.Should().BeSameAs(loaded);
@ -596,6 +743,13 @@ public class AssemblyTreeTests
[AvaloniaTest] [AvaloniaTest]
public async Task User_Click_On_Visible_Row_Does_Not_Recentre_Viewport() public async Task User_Click_On_Visible_Row_Does_Not_Recentre_Viewport()
{ {
// CenterRowInView must skip the recentre when the clicked row is already fully visible.
// Regression test: the original implementation used a clickInProgress flag that fired
// during file picker await/resume, dragging the viewport on every click. The fix is an
// in-viewport early-return.
// Arrange — boot, wait for assemblies, expand a sub-tree large enough to scroll. Park
// the viewport mid-list so clicks are at non-zero offset.
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!;
@ -604,17 +758,19 @@ public class AssemblyTreeTests
var enumerable = vm.AssemblyTreeModel.FindNode<TypeTreeNode>( var enumerable = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable"); "System.Linq", "System.Linq", "System.Linq.Enumerable");
enumerable.EnsureLazyChildren(); enumerable.EnsureLazyChildren();
enumerable.IsExpanded = true;
var ns = (NamespaceTreeNode)enumerable.Parent!; var ns = (NamespaceTreeNode)enumerable.Parent!;
ns.EnsureLazyChildren(); ns.EnsureLazyChildren();
ns.IsExpanded = true;
foreach (var child in ns.Children.OfType<TypeTreeNode>()) foreach (var child in ns.Children.OfType<TypeTreeNode>())
{
child.EnsureLazyChildren(); child.EnsureLazyChildren();
child.IsExpanded = true;
}
var pane = await window.WaitForComponent<AssemblyListPane>(); var pane = await window.WaitForComponent<AssemblyListPane>();
var grid = await pane.WaitForComponent<DataGrid>(); var grid = await pane.WaitForComponent<DataGrid>();
// Park the viewport mid-list so the next-clicked row sits at a non-zero offset. The
// regression would re-centre on click and snap the offset back; we want offset to
// stay put.
vm.AssemblyTreeModel.SelectedItem = enumerable; vm.AssemblyTreeModel.SelectedItem = enumerable;
await Waiters.WaitForAsync(() => ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, enumerable)); await Waiters.WaitForAsync(() => ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, enumerable));
for (int i = 0; i < 8; i++) for (int i = 0; i < 8; i++)
@ -633,9 +789,9 @@ public class AssemblyTreeTests
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");
// Pick a different visible row to click on; clicking an already-visible row must NOT // Pick the bottom-most visible non-selected row — that's the strictest probe. If the
// move the viewport. Use the *bottom-most* visible row for a strict check — if the bug // bug regressed, CenterRowInView would scroll it up to the middle and offset would
// regressed, CenterRowInView would scroll it up to the middle and offset would change. // change.
var candidateRow = grid.GetVisualDescendants().OfType<DataGridRow>() var candidateRow = grid.GetVisualDescendants().OfType<DataGridRow>()
.Where(r => !r.IsSelected .Where(r => !r.IsSelected
&& r.TranslatePoint(new Point(0, 0), scrollViewer) is { } p && r.TranslatePoint(new Point(0, 0), scrollViewer) is { } p
@ -646,7 +802,7 @@ public class AssemblyTreeTests
var offsetBefore = scrollViewer.Offset.Y; var offsetBefore = scrollViewer.Offset.Y;
// Real pointer click — setting SelectedItem programmatically would fire DataGrid's // Act — real pointer click. Setting SelectedItem programmatically would fire DataGrid's
// internal ScrollIntoView too, which a real user click does not. // internal ScrollIntoView too, which a real user click does not.
var rowCentre = candidateRow!.TranslatePoint( var rowCentre = candidateRow!.TranslatePoint(
new Point(candidateRow.Bounds.Width / 2, candidateRow.Bounds.Height / 2), new Point(candidateRow.Bounds.Width / 2, candidateRow.Bounds.Height / 2),
@ -660,6 +816,7 @@ public class AssemblyTreeTests
await Task.Delay(25); await Task.Delay(25);
} }
// Assert — viewport offset is unchanged (within 1px tolerance for layout jitter).
scrollViewer.Offset.Y.Should().BeApproximately(offsetBefore, 1.0, scrollViewer.Offset.Y.Should().BeApproximately(offsetBefore, 1.0,
"clicking an already-visible row must not move the viewport"); "clicking an already-visible row must not move the viewport");
} }
@ -667,6 +824,10 @@ public class AssemblyTreeTests
[AvaloniaTest] [AvaloniaTest]
public async Task Save_Code_Command_Dispatches_Single_Selected_Node_Save_Override() public async Task Save_Code_Command_Dispatches_Single_Selected_Node_Save_Override()
{ {
// SaveCommand on a single ILSpyTreeNode selection must call the node's own Save()
// method — that's how nodes (e.g. resources) opt into a custom save dialog/format.
// Arrange — boot, wait for assemblies, plant a probe node that records when Save runs.
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!;
@ -675,12 +836,14 @@ public class AssemblyTreeTests
var probe = new SaveProbeNode(); var probe = new SaveProbeNode();
vm.AssemblyTreeModel.SelectedItem = probe; vm.AssemblyTreeModel.SelectedItem = probe;
// Act — fire the Save Code main-menu command.
var registry = AppComposition.Current.GetExport<MainMenuCommandRegistry>(); var registry = AppComposition.Current.GetExport<MainMenuCommandRegistry>();
var saveCmd = registry.Commands var saveCmd = registry.Commands
.Single(c => c.Metadata.Header == nameof(Resources._SaveCode)) .Single(c => c.Metadata.Header == nameof(Resources._SaveCode))
.CreateExport().Value; .CreateExport().Value;
saveCmd.Execute(null); saveCmd.Execute(null);
// Assert — probe's Save() ran.
probe.SaveCalled.Should().BeTrue( probe.SaveCalled.Should().BeTrue(
"Save Code on a single ILSpyTreeNode selection must dispatch through ILSpyTreeNode.Save()"); "Save Code on a single ILSpyTreeNode selection must dispatch through ILSpyTreeNode.Save()");
} }
@ -699,6 +862,11 @@ public class AssemblyTreeTests
[AvaloniaTest] [AvaloniaTest]
public async Task Save_Code_Command_Writes_Full_Decompilation_To_Picked_Path() public async Task Save_Code_Command_Writes_Full_Decompilation_To_Picked_Path()
{ {
// When SaveCommand falls through (no node-specific Save override), it must run a full
// decompilation (DecompilationOptions.FullDecompilation = true) of the selection and
// write the resulting C# to the user-picked path.
// Arrange — boot, wait for assemblies, select a method, wait for the in-tab decompile.
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!;
@ -707,11 +875,14 @@ public class AssemblyTreeTests
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");
typeNode.EnsureLazyChildren(); typeNode.EnsureLazyChildren();
typeNode.IsExpanded = true;
var method = typeNode.Children.OfType<MethodTreeNode>() var method = typeNode.Children.OfType<MethodTreeNode>()
.First(m => m.MethodDefinition.Name == "AsEnumerable"); .First(m => m.MethodDefinition.Name == "AsEnumerable");
vm.AssemblyTreeModel.SelectedItem = method; vm.AssemblyTreeModel.SelectedItem = method;
await vm.DockWorkspace.WaitForDecompiledTextAsync(); await vm.DockWorkspace.WaitForDecompiledTextAsync();
// Act — invoke SaveCodeAsync with a temp path (bypassing the file picker so the test
// is deterministic).
var registry = AppComposition.Current.GetExport<MainMenuCommandRegistry>(); var registry = AppComposition.Current.GetExport<MainMenuCommandRegistry>();
var saveCmd = (global::ILSpy.Commands.SaveCommand)registry.Commands var saveCmd = (global::ILSpy.Commands.SaveCommand)registry.Commands
.Single(c => c.Metadata.Header == nameof(Resources._SaveCode)) .Single(c => c.Metadata.Header == nameof(Resources._SaveCode))
@ -724,6 +895,8 @@ public class AssemblyTreeTests
{ {
await saveCmd.SaveCodeAsync(tempFile); await saveCmd.SaveCodeAsync(tempFile);
// Assert — file exists; contents include the method name and a body fragment that
// only appears with FullDecompilation = true.
System.IO.File.Exists(tempFile).Should().BeTrue(); System.IO.File.Exists(tempFile).Should().BeTrue();
var contents = await System.IO.File.ReadAllTextAsync(tempFile); var contents = await System.IO.File.ReadAllTextAsync(tempFile);
contents.Should().Contain("AsEnumerable", contents.Should().Contain("AsEnumerable",
@ -741,12 +914,16 @@ public class AssemblyTreeTests
[AvaloniaTest] [AvaloniaTest]
public async Task Delete_Key_On_Selected_Assembly_Unloads_It_From_The_List() public async Task Delete_Key_On_Selected_Assembly_Unloads_It_From_The_List()
{ {
// Pressing Del on a selected AssemblyTreeNode must unload that assembly from the
// AssemblyList while leaving siblings intact.
// Arrange — boot, wait for assemblies, pick the assembly to evict + a sibling we
// expect to survive, select the sacrificial one, focus the grid.
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!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3);
// Pick the assembly we'll evict + a sibling we expect to survive.
var sacrificialName = typeof(System.Linq.Enumerable).Assembly.GetName().Name!; var sacrificialName = typeof(System.Linq.Enumerable).Assembly.GetName().Name!;
var survivorName = typeof(object).Assembly.GetName().Name!; var survivorName = typeof(object).Assembly.GetName().Name!;
var sacrificialNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>(sacrificialName); var sacrificialNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>(sacrificialName);
@ -758,7 +935,8 @@ public class AssemblyTreeTests
grid.Focus(); grid.Focus();
Dispatcher.UIThread.RunJobs(); Dispatcher.UIThread.RunJobs();
// Headless keyboard event — simulates the user pressing Del while the tree has focus. // Act — headless keyboard event simulating the user pressing Del while the tree has
// focus.
global::Avalonia.Headless.HeadlessWindowExtensions.KeyPress( global::Avalonia.Headless.HeadlessWindowExtensions.KeyPress(
window, window,
global::Avalonia.Input.Key.Delete, global::Avalonia.Input.Key.Delete,
@ -766,6 +944,7 @@ public class AssemblyTreeTests
global::Avalonia.Input.PhysicalKey.Delete, global::Avalonia.Input.PhysicalKey.Delete,
null); null);
// Assert — the sacrificial assembly is gone from the list; the survivor remains.
await Waiters.WaitForAsync(() => await Waiters.WaitForAsync(() =>
!vm.AssemblyTreeModel.AssemblyList!.GetAssemblies() !vm.AssemblyTreeModel.AssemblyList!.GetAssemblies()
.Any(a => string.Equals(a.ShortName, sacrificialName, System.StringComparison.Ordinal))); .Any(a => string.Equals(a.ShortName, sacrificialName, System.StringComparison.Ordinal)));
@ -778,12 +957,16 @@ public class AssemblyTreeTests
[AvaloniaTest] [AvaloniaTest]
public async Task Clear_Assembly_List_Command_Empties_The_Active_List() public async Task Clear_Assembly_List_Command_Empties_The_Active_List()
{ {
// The Clear Assembly List menu command must drop every entry from the active list.
// Arrange — boot, wait for assemblies, baseline the non-empty count.
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!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3);
vm.AssemblyTreeModel.AssemblyList!.Count.Should().BeGreaterThan(0); vm.AssemblyTreeModel.AssemblyList!.Count.Should().BeGreaterThan(0);
// Act — fire the Clear command.
var registry = AppComposition.Current.GetExport<MainMenuCommandRegistry>(); var registry = AppComposition.Current.GetExport<MainMenuCommandRegistry>();
var clearCmd = registry.Commands var clearCmd = registry.Commands
.Single(c => c.Metadata.Header == nameof(Resources.ClearAssemblyList)) .Single(c => c.Metadata.Header == nameof(Resources.ClearAssemblyList))
@ -791,6 +974,7 @@ public class AssemblyTreeTests
clearCmd.CanExecute(null).Should().BeTrue("non-empty list must enable Clear"); clearCmd.CanExecute(null).Should().BeTrue("non-empty list must enable Clear");
clearCmd.Execute(null); clearCmd.Execute(null);
// Assert — the list goes to zero entries.
await Waiters.WaitForAsync(() => vm.AssemblyTreeModel.AssemblyList!.Count == 0); await Waiters.WaitForAsync(() => vm.AssemblyTreeModel.AssemblyList!.Count == 0);
vm.AssemblyTreeModel.AssemblyList!.Count.Should().Be(0); vm.AssemblyTreeModel.AssemblyList!.Count.Should().Be(0);
} }
@ -798,13 +982,17 @@ public class AssemblyTreeTests
[AvaloniaTest] [AvaloniaTest]
public async Task Remove_Assemblies_With_Load_Errors_Drops_Only_Broken_Entries() public async Task Remove_Assemblies_With_Load_Errors_Drops_Only_Broken_Entries()
{ {
// The "Remove Assemblies with Load Errors" command must drop every entry whose load
// failed — and *only* those, leaving valid assemblies in place.
// Arrange — boot, wait for assemblies, plant a "broken" entry: a real file that isn't
// a PE binary. AssemblyList records it but the load fails — that's the state the
// command targets.
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!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3);
// Add a "broken" assembly: a real file that isn't a PE binary. AssemblyList records
// it but the load fails — that's exactly the state the command targets.
var brokenPath = System.IO.Path.Combine( var brokenPath = System.IO.Path.Combine(
System.IO.Path.GetTempPath(), System.IO.Path.GetTempPath(),
"ilspy-bogus-" + System.Guid.NewGuid().ToString("N") + ".dll"); "ilspy-bogus-" + System.Guid.NewGuid().ToString("N") + ".dll");
@ -826,6 +1014,7 @@ public class AssemblyTreeTests
.ToList(); .ToList();
validBefore.Should().NotBeEmpty("at least one real assembly must remain to verify it's spared"); validBefore.Should().NotBeEmpty("at least one real assembly must remain to verify it's spared");
// Act — fire the command.
var registry = AppComposition.Current.GetExport<MainMenuCommandRegistry>(); var registry = AppComposition.Current.GetExport<MainMenuCommandRegistry>();
var cmd = registry.Commands var cmd = registry.Commands
.Single(c => c.Metadata.Header == nameof(Resources._RemoveAssembliesWithLoadErrors)) .Single(c => c.Metadata.Header == nameof(Resources._RemoveAssembliesWithLoadErrors))
@ -837,6 +1026,7 @@ public class AssemblyTreeTests
!vm.AssemblyTreeModel.AssemblyList!.GetAssemblies() !vm.AssemblyTreeModel.AssemblyList!.GetAssemblies()
.Any(a => string.Equals(a.FileName, brokenPath, System.StringComparison.OrdinalIgnoreCase))); .Any(a => string.Equals(a.FileName, brokenPath, System.StringComparison.OrdinalIgnoreCase)));
// Assert — broken path gone; every previously-valid path still present.
var remaining = vm.AssemblyTreeModel.AssemblyList!.GetAssemblies().Select(a => a.FileName).ToList(); var remaining = vm.AssemblyTreeModel.AssemblyList!.GetAssemblies().Select(a => a.FileName).ToList();
remaining.Should().NotContain(brokenPath, "the broken assembly should be removed"); remaining.Should().NotContain(brokenPath, "the broken assembly should be removed");
foreach (var path in validBefore) foreach (var path in validBefore)
@ -852,6 +1042,13 @@ public class AssemblyTreeTests
[AvaloniaTest] [AvaloniaTest]
public async Task Setting_SharpTreeNode_IsExpanded_Expands_Row_In_Grid() public async Task Setting_SharpTreeNode_IsExpanded_Expands_Row_In_Grid()
{ {
// ProDataGrid's HierarchicalOptions.IsExpandedPropertyPath wires SharpTreeNode.IsExpanded
// to the grid's wrapper expansion state via reflection + INotifyPropertyChanged. Setting
// IsExpanded on the model must propagate to the grid wrapper without any manual
// subscription bookkeeping.
// Arrange — boot, wait for assemblies, locate the realised DataGrid and the
// HierarchicalModel wrapper for the assembly node.
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!;
@ -861,22 +1058,23 @@ public class AssemblyTreeTests
var grid = await pane.WaitForComponent<DataGrid>(); var grid = await pane.WaitForComponent<DataGrid>();
var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq"); var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq");
// Before: grid wrapper for assemblyNode must exist and not be expanded.
var hm = (global::Avalonia.Controls.DataGridHierarchical.IHierarchicalModel)grid.HierarchicalModel!; var hm = (global::Avalonia.Controls.DataGridHierarchical.IHierarchicalModel)grid.HierarchicalModel!;
var hNode = hm.FindNode(assemblyNode); var hNode = hm.FindNode(assemblyNode);
hNode.Should().NotBeNull(); hNode.Should().NotBeNull();
hNode!.IsExpanded.Should().BeFalse("baseline: top-level assembly rows start collapsed"); hNode!.IsExpanded.Should().BeFalse("baseline: top-level assembly rows start collapsed");
// Production-shaped action: just toggle the model property. ProDataGrid's // Act — production-shaped action: just toggle the model property.
// HierarchicalOptions.IsExpandedPropertyPath observes INPC and pushes to the wrapper. assemblyNode.EnsureLazyChildren();
assemblyNode.IsExpanded = true; assemblyNode.IsExpanded = true;
// Drain the dispatcher so the wiring (PropertyChanged → hm.Expand) settles.
for (int i = 0; i < 5; i++) for (int i = 0; i < 5; i++)
{ {
Dispatcher.UIThread.RunJobs(); Dispatcher.UIThread.RunJobs();
await Task.Delay(20); await Task.Delay(20);
} }
// Assert — the grid wrapper now reports IsExpanded == true.
hm.FindNode(assemblyNode)!.IsExpanded.Should().BeTrue( hm.FindNode(assemblyNode)!.IsExpanded.Should().BeTrue(
"setting SharpTreeNode.IsExpanded must propagate to the HierarchicalModel wrapper"); "setting SharpTreeNode.IsExpanded must propagate to the HierarchicalModel wrapper");
} }

118
ILSpy.Tests/Editor/DecompilerViewTests.cs

@ -45,6 +45,11 @@ public class DecompilerViewTests
[AvaloniaTest] [AvaloniaTest]
public async Task Selecting_AsEnumerable_Method_Decompiles_Into_Document_View() public async Task Selecting_AsEnumerable_Method_Decompiles_Into_Document_View()
{ {
// Selecting a MethodTreeNode in the assembly tree must trigger a decompile and the
// resulting C# (signature + body) must land in the document tab. Also verifies the
// row scrolls into view (CenteredInView assertion) so the user sees the selection.
// Arrange — boot the window, wait for assemblies, expand Enumerable, locate AsEnumerable.
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!;
@ -52,13 +57,16 @@ public class DecompilerViewTests
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");
typeNode.EnsureLazyChildren(); typeNode.IsExpanded = true;
var methodNode = typeNode.Children.OfType<MethodTreeNode>() var methodNode = typeNode.Children.OfType<MethodTreeNode>()
.Single(m => m.MethodDefinition.Name == "AsEnumerable"); .Single(m => m.MethodDefinition.Name == "AsEnumerable");
methodNode.MethodDefinition.IsExtensionMethod.Should().BeTrue(); methodNode.MethodDefinition.IsExtensionMethod.Should().BeTrue();
// Act — select the method node and wait for the decompile.
vm.AssemblyTreeModel.SelectedItem = methodNode; vm.AssemblyTreeModel.SelectedItem = methodNode;
var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync();
// Assert — signature + body fragments are present and the row centred in the tree.
tab.Text.Should().Contain("AsEnumerable"); tab.Text.Should().Contain("AsEnumerable");
tab.Text.Should().Contain("IEnumerable<TSource>"); tab.Text.Should().Contain("IEnumerable<TSource>");
tab.Text.Should().Contain("return source"); tab.Text.Should().Contain("return source");
@ -69,18 +77,25 @@ public class DecompilerViewTests
[AvaloniaTest] [AvaloniaTest]
public async Task Selecting_References_Folder_Decompiles_References_Listing() public async Task Selecting_References_Folder_Decompiles_References_Listing()
{ {
// The References folder under each assembly is a synthetic node; selecting it emits a
// summary listing (target framework, runtime pack, referenced assemblies, load log)
// rather than decompiled C#.
// Arrange — boot, wait for assemblies, expand the System.Linq assembly node.
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!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3);
var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq"); var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq");
assemblyNode.EnsureLazyChildren(); assemblyNode.IsExpanded = true;
var refFolder = assemblyNode.Children.OfType<ReferenceFolderTreeNode>().Single(); var refFolder = assemblyNode.Children.OfType<ReferenceFolderTreeNode>().Single();
// Act — select the References folder and wait for its decompile output.
vm.AssemblyTreeModel.SelectedItem = refFolder; vm.AssemblyTreeModel.SelectedItem = refFolder;
var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync();
// Assert — every header and a representative referenced assembly land in the listing.
tab.Text.Should().Contain("Detected TargetFramework-Id:"); tab.Text.Should().Contain("Detected TargetFramework-Id:");
tab.Text.Should().Contain("Detected RuntimePack:"); tab.Text.Should().Contain("Detected RuntimePack:");
tab.Text.Should().Contain("Referenced assemblies (in metadata order):"); tab.Text.Should().Contain("Referenced assemblies (in metadata order):");
@ -91,6 +106,10 @@ public class DecompilerViewTests
[AvaloniaTest] [AvaloniaTest]
public async Task Selecting_Type_Node_Decompiles_Type_Definition() public async Task Selecting_Type_Node_Decompiles_Type_Definition()
{ {
// Selecting a TypeTreeNode (System.Version) must decompile the full class declaration
// including representative members.
// Arrange — boot, wait for assemblies, find System.Version.
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!;
@ -100,9 +119,11 @@ public class DecompilerViewTests
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>( var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
coreLib, "System", "System.Version"); coreLib, "System", "System.Version");
// Act — select the type node, wait for the decompile.
vm.AssemblyTreeModel.SelectedItem = typeNode; vm.AssemblyTreeModel.SelectedItem = typeNode;
var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync();
// Assert — class declaration + representative property names land in the output.
tab.Text.Should().Contain("class Version"); tab.Text.Should().Contain("class Version");
tab.Text.Should().Contain("Major"); tab.Text.Should().Contain("Major");
tab.Text.Should().Contain("Minor"); tab.Text.Should().Contain("Minor");
@ -111,6 +132,10 @@ public class DecompilerViewTests
[AvaloniaTest] [AvaloniaTest]
public async Task Selecting_Property_Node_Decompiles_Property() public async Task Selecting_Property_Node_Decompiles_Property()
{ {
// PropertyTreeNode → just the single property's declaration (with type prefix), not
// the full enclosing class.
// Arrange — boot, wait for assemblies, expand System.Version, grab Major.
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!;
@ -119,19 +144,25 @@ public class DecompilerViewTests
var coreLib = typeof(object).Assembly.GetName().Name!; var coreLib = typeof(object).Assembly.GetName().Name!;
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>( var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
coreLib, "System", "System.Version"); coreLib, "System", "System.Version");
typeNode.EnsureLazyChildren(); typeNode.IsExpanded = true;
var propertyNode = typeNode.Children.OfType<PropertyTreeNode>() var propertyNode = typeNode.Children.OfType<PropertyTreeNode>()
.Single(p => p.PropertyDefinition.Name == "Major"); .Single(p => p.PropertyDefinition.Name == "Major");
// Act — select the property, wait for the decompile.
vm.AssemblyTreeModel.SelectedItem = propertyNode; vm.AssemblyTreeModel.SelectedItem = propertyNode;
var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync();
// Assert — property declaration lands in the output.
tab.Text.Should().Contain("public int Major"); tab.Text.Should().Contain("public int Major");
} }
[AvaloniaTest] [AvaloniaTest]
public async Task Selecting_Field_Node_Decompiles_Field() public async Task Selecting_Field_Node_Decompiles_Field()
{ {
// FieldTreeNode (System.Math.PI) → decompiled field declaration with its constant
// initializer.
// Arrange — boot, wait for assemblies, expand System.Math, grab PI.
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!;
@ -140,13 +171,15 @@ public class DecompilerViewTests
var coreLib = typeof(object).Assembly.GetName().Name!; var coreLib = typeof(object).Assembly.GetName().Name!;
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>( var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
coreLib, "System", "System.Math"); coreLib, "System", "System.Math");
typeNode.EnsureLazyChildren(); typeNode.IsExpanded = true;
var fieldNode = typeNode.Children.OfType<FieldTreeNode>() var fieldNode = typeNode.Children.OfType<FieldTreeNode>()
.Single(f => f.FieldDefinition.Name == "PI"); .Single(f => f.FieldDefinition.Name == "PI");
// Act — select the field, wait for the decompile.
vm.AssemblyTreeModel.SelectedItem = fieldNode; vm.AssemblyTreeModel.SelectedItem = fieldNode;
var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync();
// Assert — declaration and constant value both land.
tab.Text.Should().Contain("public const double PI"); tab.Text.Should().Contain("public const double PI");
tab.Text.Should().Contain("3.14159"); tab.Text.Should().Contain("3.14159");
} }
@ -154,6 +187,9 @@ public class DecompilerViewTests
[AvaloniaTest] [AvaloniaTest]
public async Task Selecting_Event_Node_Decompiles_Event() public async Task Selecting_Event_Node_Decompiles_Event()
{ {
// EventTreeNode (AppDomain.ProcessExit) → decompiled event declaration.
// Arrange — boot, wait for assemblies, expand AppDomain, grab ProcessExit.
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!;
@ -162,13 +198,15 @@ public class DecompilerViewTests
var coreLib = typeof(object).Assembly.GetName().Name!; var coreLib = typeof(object).Assembly.GetName().Name!;
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>( var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
coreLib, "System", "System.AppDomain"); coreLib, "System", "System.AppDomain");
typeNode.EnsureLazyChildren(); typeNode.IsExpanded = true;
var eventNode = typeNode.Children.OfType<EventTreeNode>() var eventNode = typeNode.Children.OfType<EventTreeNode>()
.Single(e => e.EventDefinition.Name == "ProcessExit"); .Single(e => e.EventDefinition.Name == "ProcessExit");
// Act — select the event, wait for the decompile.
vm.AssemblyTreeModel.SelectedItem = eventNode; vm.AssemblyTreeModel.SelectedItem = eventNode;
var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync();
// Assert — event keyword + name land in the output.
tab.Text.Should().Contain("event EventHandler"); tab.Text.Should().Contain("event EventHandler");
tab.Text.Should().Contain("ProcessExit"); tab.Text.Should().Contain("ProcessExit");
} }
@ -176,6 +214,9 @@ public class DecompilerViewTests
[AvaloniaTest] [AvaloniaTest]
public async Task Selecting_Namespace_Node_Decompiles_Namespace() public async Task Selecting_Namespace_Node_Decompiles_Namespace()
{ {
// NamespaceTreeNode → decompiled namespace contents (the small types it contains).
// Arrange — boot, wait for assemblies, locate System.Runtime.Versioning.
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!;
@ -185,9 +226,11 @@ public class DecompilerViewTests
var namespaceNode = vm.AssemblyTreeModel.FindNode<NamespaceTreeNode>( var namespaceNode = vm.AssemblyTreeModel.FindNode<NamespaceTreeNode>(
coreLib, "System.Runtime.Versioning"); coreLib, "System.Runtime.Versioning");
// Act — select the namespace, wait for the decompile.
vm.AssemblyTreeModel.SelectedItem = namespaceNode; vm.AssemblyTreeModel.SelectedItem = namespaceNode;
var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync();
// Assert — representative type names land in the output.
tab.Text.Should().Contain("TargetFrameworkAttribute"); tab.Text.Should().Contain("TargetFrameworkAttribute");
tab.Text.Should().Contain("SupportedOSPlatformAttribute"); tab.Text.Should().Contain("SupportedOSPlatformAttribute");
} }
@ -195,14 +238,18 @@ public class DecompilerViewTests
[AvaloniaTest] [AvaloniaTest]
public async Task Decompiler_Tab_Title_Tracks_Tree_Node_Text_When_Node_Loads_Late() public async Task Decompiler_Tab_Title_Tracks_Tree_Node_Text_When_Node_Loads_Late()
{ {
// Opening a fresh assembly via OpenCommand selects it before LoadedAssembly.Text has
// the full "(version, tfm)" suffix wired up. The tab title used to lock in the bare
// ShortName at that moment; it must instead update once Text becomes available.
// Arrange — boot, wait for assemblies.
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!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3);
// Open a fresh assembly via the command and immediately let the OpenCommand select it, // Act 1 — fire OpenCommand on a new path. OpenCommand selects the new node before its
// before LoadedAssembly.Text has the rich "(version, tfm)" suffix wired up. This is the // rich Text (with version + tfm) settles, exercising the late-update path.
// timing window where the tab title used to lock in the bare ShortName.
var newAsmPath = typeof(System.Net.Http.HttpClient).Assembly.Location; var newAsmPath = typeof(System.Net.Http.HttpClient).Assembly.Location;
var registry = AppComposition.Current.GetExport<MainMenuCommandRegistry>(); var registry = AppComposition.Current.GetExport<MainMenuCommandRegistry>();
var openCommand = registry.Commands var openCommand = registry.Commands
@ -214,10 +261,13 @@ public class DecompilerViewTests
vm.AssemblyTreeModel.SelectedItem is AssemblyTreeNode n vm.AssemblyTreeModel.SelectedItem is AssemblyTreeNode n
&& string.Equals(n.LoadedAssembly.FileName, newAsmPath, System.StringComparison.OrdinalIgnoreCase)); && string.Equals(n.LoadedAssembly.FileName, newAsmPath, System.StringComparison.OrdinalIgnoreCase));
// Act 2 — wait for the rich form of LoadedAssembly.Text to materialise.
var node = (AssemblyTreeNode)vm.AssemblyTreeModel.SelectedItem!; var node = (AssemblyTreeNode)vm.AssemblyTreeModel.SelectedItem!;
await node.LoadedAssembly.GetLoadResultAsync(); await node.LoadedAssembly.GetLoadResultAsync();
var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync();
// Assert — once Text is rich, the tab title catches up. Sanity-check the form is rich
// (otherwise the test isn't exercising the late-update path).
await Waiters.WaitForAsync(() => string.Equals(tab.Title, node.Text?.ToString(), System.StringComparison.Ordinal)); await Waiters.WaitForAsync(() => string.Equals(tab.Title, node.Text?.ToString(), System.StringComparison.Ordinal));
node.Text!.ToString().Should().NotBe(node.LoadedAssembly.ShortName, node.Text!.ToString().Should().NotBe(node.LoadedAssembly.ShortName,
"the rich form (with version + tfm) must be available, otherwise the test isn't exercising the late-update path"); "the rich form (with version + tfm) must be available, otherwise the test isn't exercising the late-update path");
@ -227,6 +277,12 @@ public class DecompilerViewTests
[AvaloniaTest] [AvaloniaTest]
public async Task Decompile_Tab_Title_Cycles_Round_Spinner_While_Decompiling() public async Task Decompile_Tab_Title_Cycles_Round_Spinner_While_Decompiling()
{ {
// While a decompile is in flight the tab title should prefix with one of ten Braille
// spinner glyphs so the user sees activity. The editor's Text must NOT receive the
// glyph (only the title bar). Once decompilation finishes, the glyph clears.
// Arrange — boot, wait for assemblies, run an initial decompile so the second selection
// produces a real "in flight" period (not the cold-start one).
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!;
@ -240,6 +296,7 @@ public class DecompilerViewTests
var editorTexts = new List<string>(); var editorTexts = new List<string>();
tab.PropertyChanged += OnTabPropertyChanged; tab.PropertyChanged += OnTabPropertyChanged;
// Act — kick off a second decompile and observe Title/Text changes throughout.
var coreLib = typeof(object).Assembly.GetName().Name!; var coreLib = typeof(object).Assembly.GetName().Name!;
var second = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(coreLib, "System", "System.Version"); var second = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(coreLib, "System", "System.Version");
vm.AssemblyTreeModel.SelectedItem = second; vm.AssemblyTreeModel.SelectedItem = second;
@ -247,6 +304,8 @@ public class DecompilerViewTests
tab.PropertyChanged -= OnTabPropertyChanged; tab.PropertyChanged -= OnTabPropertyChanged;
// Assert — at least one captured title contained a spinner glyph; no editor Text ever
// did; the final title is glyph-free.
var glyphs = new[] { '⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏' }; var glyphs = new[] { '⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏' };
titles.Should().Contain(t => glyphs.Any(t.Contains), titles.Should().Contain(t => glyphs.Any(t.Contains),
"the tab title must show one of the Braille spinner frames while decompilation is in flight"); "the tab title must show one of the Braille spinner frames while decompilation is in flight");
@ -267,6 +326,10 @@ public class DecompilerViewTests
[AvaloniaTest] [AvaloniaTest]
public async Task Multi_Selection_Tab_Title_Joins_All_Selected_Names_With_Comma() public async Task Multi_Selection_Tab_Title_Joins_All_Selected_Names_With_Comma()
{ {
// Selecting several nodes via Ctrl+Click feeds them all into one decompile. The tab
// title must list every selected node's display text comma-joined.
// Arrange — boot, wait for assemblies, expand Enumerable, grab two methods.
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!;
@ -274,23 +337,30 @@ public class DecompilerViewTests
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");
typeNode.EnsureLazyChildren(); typeNode.IsExpanded = true;
var asEnumerable = typeNode.Children.OfType<MethodTreeNode>() var asEnumerable = typeNode.Children.OfType<MethodTreeNode>()
.First(m => m.MethodDefinition.Name == "AsEnumerable"); .First(m => m.MethodDefinition.Name == "AsEnumerable");
var empty = typeNode.Children.OfType<MethodTreeNode>() var empty = typeNode.Children.OfType<MethodTreeNode>()
.First(m => m.MethodDefinition.Name == "Empty"); .First(m => m.MethodDefinition.Name == "Empty");
// Act — replace the selection with both nodes.
vm.AssemblyTreeModel.SelectedItems.Clear(); vm.AssemblyTreeModel.SelectedItems.Clear();
vm.AssemblyTreeModel.SelectedItems.Add(asEnumerable); vm.AssemblyTreeModel.SelectedItems.Add(asEnumerable);
vm.AssemblyTreeModel.SelectedItems.Add(empty); vm.AssemblyTreeModel.SelectedItems.Add(empty);
var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync();
// Assert — title is the two display texts joined with ", ".
tab.Title.Should().Be($"{asEnumerable.Text}, {empty.Text}"); tab.Title.Should().Be($"{asEnumerable.Text}, {empty.Text}");
} }
[AvaloniaTest] [AvaloniaTest]
public async Task Multi_Selecting_Methods_Decompiles_All_Of_Them_Into_One_View() public async Task Multi_Selecting_Methods_Decompiles_All_Of_Them_Into_One_View()
{ {
// Multi-selection isn't just a title concern — both methods' bodies must actually be
// decompiled into one combined document.
// Arrange — boot, wait for assemblies, expand Enumerable, grab two methods.
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!;
@ -298,35 +368,46 @@ public class DecompilerViewTests
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");
typeNode.EnsureLazyChildren(); typeNode.IsExpanded = true;
var asEnumerable = typeNode.Children.OfType<MethodTreeNode>() var asEnumerable = typeNode.Children.OfType<MethodTreeNode>()
.First(m => m.MethodDefinition.Name == "AsEnumerable"); .First(m => m.MethodDefinition.Name == "AsEnumerable");
var empty = typeNode.Children.OfType<MethodTreeNode>() var empty = typeNode.Children.OfType<MethodTreeNode>()
.First(m => m.MethodDefinition.Name == "Empty"); .First(m => m.MethodDefinition.Name == "Empty");
// Act — multi-select the two methods.
vm.AssemblyTreeModel.SelectedItems.Clear(); vm.AssemblyTreeModel.SelectedItems.Clear();
vm.AssemblyTreeModel.SelectedItems.Add(asEnumerable); vm.AssemblyTreeModel.SelectedItems.Add(asEnumerable);
vm.AssemblyTreeModel.SelectedItems.Add(empty); vm.AssemblyTreeModel.SelectedItems.Add(empty);
var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync();
// Assert — both names AND a body-fragment from one of them confirm both bodies
// actually rendered (not just signatures).
tab.Text.Should().Contain("AsEnumerable"); tab.Text.Should().Contain("AsEnumerable");
tab.Text.Should().Contain("Empty"); tab.Text.Should().Contain("Empty");
// Body fragment from AsEnumerable to confirm both bodies actually rendered.
tab.Text.Should().Contain("return source"); tab.Text.Should().Contain("return source");
} }
[AvaloniaTest] [AvaloniaTest]
public async Task Selecting_Assembly_Node_Emits_Header_And_Assembly_Attributes() public async Task Selecting_Assembly_Node_Emits_Header_And_Assembly_Attributes()
{ {
// AssemblyTreeNode → emit a "manifest"-style summary header (file, identity, runtime,
// architecture, global type) plus the assembly-level attributes block.
// Arrange — boot, wait for assemblies, find System.Linq.
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!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3);
var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq"); var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq");
// Act — select the assembly node, wait for the decompile.
vm.AssemblyTreeModel.SelectedItem = assemblyNode; vm.AssemblyTreeModel.SelectedItem = assemblyNode;
var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync();
// Assert — every documented header line and at least one assembly attribute land.
tab.Text.Should().Contain("// " + assemblyNode.LoadedAssembly.FileName); tab.Text.Should().Contain("// " + assemblyNode.LoadedAssembly.FileName);
tab.Text.Should().Contain("// System.Linq, Version="); tab.Text.Should().Contain("// System.Linq, Version=");
tab.Text.Should().Contain("// Global type: <Module>"); tab.Text.Should().Contain("// Global type: <Module>");
@ -338,18 +419,23 @@ public class DecompilerViewTests
[AvaloniaTest] [AvaloniaTest]
public async Task Xml_Resource_Installs_FoldingManager_With_Folds() public async Task Xml_Resource_Installs_FoldingManager_With_Folds()
{ {
// XmlResourceEntryNode bumps the editor into XML mode (SyntaxExtension=".xml"), and
// DecompilerTextView.ApplyDocument must wire AvaloniaEdit's XmlFoldingStrategy so
// multi-line elements become collapsible.
// Arrange — boot, wait for assemblies, seed the decompiler tab with an assembly view
// so the document/editor wiring is realised.
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!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3);
// Seed the decompiler tab.
var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq"); var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq");
vm.AssemblyTreeModel.SelectedItem = assemblyNode; vm.AssemblyTreeModel.SelectedItem = assemblyNode;
var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync();
// Drop in a synthetic XML resource. XmlResourceEntryNode → SyntaxExtension=".xml" → // Act — drop a synthetic XML resource into the tab. XmlResourceEntryNode triggers
// DecompilerTextView.ApplyDocument runs XmlFoldingStrategy. // SyntaxExtension=".xml" and the XmlFoldingStrategy install.
var xml = "<root>\n <child attr=\"v\">\n text\n </child>\n <other>\n </other>\n</root>"; var xml = "<root>\n <child attr=\"v\">\n text\n </child>\n <other>\n </other>\n</root>";
var bytes = System.Text.Encoding.UTF8.GetBytes(xml); var bytes = System.Text.Encoding.UTF8.GetBytes(xml);
tab.CurrentNode = new XmlResourceEntryNode("test.xml", () => new System.IO.MemoryStream(bytes)); tab.CurrentNode = new XmlResourceEntryNode("test.xml", () => new System.IO.MemoryStream(bytes));
@ -363,8 +449,8 @@ public class DecompilerViewTests
await Task.Delay(20); await Task.Delay(20);
} }
// FoldingManager is held in a private field — reflection is the lightest probe; a // Assert — FoldingManager is installed (private field, reflected) and produced fold
// public accessor purely for tests would leak detail. // ranges for the multi-line elements.
var fmField = typeof(DecompilerTextView).GetField("activeFoldingManager", var fmField = typeof(DecompilerTextView).GetField("activeFoldingManager",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
fmField.Should().NotBeNull(); fmField.Should().NotBeNull();

7
ILSpy.Tests/MainWindow/MainMenuTests.cs

@ -56,6 +56,11 @@ public class MainMenuTests
[AvaloniaTest] [AvaloniaTest]
public async Task Main_Menu_Items_Display_Input_Gestures() public async Task Main_Menu_Items_Display_Input_Gestures()
{ {
// File → Open carries an InputGestureText="Ctrl+O" attribute on its
// [ExportMainMenuCommand]; verifies both the displayed gesture (right side of the menu
// item) and the actual HotKey are wired up so Ctrl+O fires from the keyboard too.
// Arrange — boot MainWindow and wait for the File menu to be populated by MEF.
var window = AppComposition.Current.GetExport<MainWindow>(); var window = AppComposition.Current.GetExport<MainWindow>();
window.Show(); window.Show();
@ -65,10 +70,12 @@ public class MainMenuTests
&& menu.Items.OfType<MenuItem>().Single(m => (string?)m.Tag == nameof(Resources._File)) && menu.Items.OfType<MenuItem>().Single(m => (string?)m.Tag == nameof(Resources._File))
.Items.OfType<MenuItem>().Any()); .Items.OfType<MenuItem>().Any());
// Act — locate the Open MenuItem under File.
var fileMenu = menu.Items.OfType<MenuItem>().Single(m => (string?)m.Tag == nameof(Resources._File)); var fileMenu = menu.Items.OfType<MenuItem>().Single(m => (string?)m.Tag == nameof(Resources._File));
var openItem = fileMenu.Items.OfType<MenuItem>() var openItem = fileMenu.Items.OfType<MenuItem>()
.Single(m => string.Equals(m.Header as string, Resources._Open, System.StringComparison.Ordinal)); .Single(m => string.Equals(m.Header as string, Resources._Open, System.StringComparison.Ordinal));
// Assert — both display gesture and hot-key bind to Ctrl+O.
openItem.InputGesture.Should().NotBeNull(); openItem.InputGesture.Should().NotBeNull();
openItem.InputGesture!.Should().Be(KeyGesture.Parse("Ctrl+O")); openItem.InputGesture!.Should().Be(KeyGesture.Parse("Ctrl+O"));
openItem.HotKey.Should().Be(KeyGesture.Parse("Ctrl+O")); openItem.HotKey.Should().Be(KeyGesture.Parse("Ctrl+O"));

31
ILSpy.Tests/MainWindow/MainWindowTests.cs

@ -43,9 +43,14 @@ public class MainWindowTests
[AvaloniaTest] [AvaloniaTest]
public void MainWindow_Resolves_From_Composition_And_Shows() public void MainWindow_Resolves_From_Composition_And_Shows()
{ {
// Smoke-test that MEF resolves MainWindow, the window shows, and its DataContext is the
// MainWindowViewModel — the foundation every other UI test relies on.
// Arrange + Act — resolve and show.
var window = AppComposition.Current.GetExport<MainWindow>(); var window = AppComposition.Current.GetExport<MainWindow>();
window.Show(); window.Show();
// Assert — visible, titled, with the correct DataContext.
window.IsVisible.Should().BeTrue(); window.IsVisible.Should().BeTrue();
window.Title.Should().Be("ILSpy"); window.Title.Should().Be("ILSpy");
window.DataContext.Should().BeOfType<MainWindowViewModel>(); window.DataContext.Should().BeOfType<MainWindowViewModel>();
@ -54,12 +59,17 @@ public class MainWindowTests
[AvaloniaTest] [AvaloniaTest]
public async Task Assembly_Tree_Pane_Is_Visible_In_Layout() public async Task Assembly_Tree_Pane_Is_Visible_In_Layout()
{ {
// The dock layout must materialise an AssemblyListPane on first show with non-zero
// bounds, otherwise the user is staring at a blank window.
// Arrange + Act — boot the window and wait for the pane to be realized in the visual tree.
var window = AppComposition.Current.GetExport<MainWindow>(); var window = AppComposition.Current.GetExport<MainWindow>();
window.Show(); window.Show();
await Waiters.WaitForAsync(() => window.GetVisualDescendants().OfType<AssemblyListPane>().Any()); await Waiters.WaitForAsync(() => window.GetVisualDescendants().OfType<AssemblyListPane>().Any());
var pane = window.GetVisualDescendants().OfType<AssemblyListPane>().Single(); var pane = window.GetVisualDescendants().OfType<AssemblyListPane>().Single();
// Assert — visible with positive width and height.
pane.IsVisible.Should().BeTrue(); pane.IsVisible.Should().BeTrue();
pane.Bounds.Width.Should().BeGreaterThan(0); pane.Bounds.Width.Should().BeGreaterThan(0);
pane.Bounds.Height.Should().BeGreaterThan(0); pane.Bounds.Height.Should().BeGreaterThan(0);
@ -68,17 +78,21 @@ public class MainWindowTests
[AvaloniaTest] [AvaloniaTest]
public async Task Toolbar_Disabled_Button_Renders_Dimmed_Icon() public async Task Toolbar_Disabled_Button_Renders_Dimmed_Icon()
{ {
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
// At startup the Back button is disabled (no nav history yet — the bound Command's // At startup the Back button is disabled (no nav history yet — the bound Command's
// CanExecute returns false). Its icon should render visibly dimmer so the user can // CanExecute returns false). Its icon should render visibly dimmer so the user can
// tell at a glance that it's not clickable. // tell at a glance that it's not clickable.
// Arrange + Act — show the window, wait until at least one disabled icon-bearing button
// has been laid out.
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
await Waiters.WaitForAsync(() => window.GetVisualDescendants() await Waiters.WaitForAsync(() => window.GetVisualDescendants()
.OfType<Button>() .OfType<Button>()
.Any(b => !b.IsEffectivelyEnabled .Any(b => !b.IsEffectivelyEnabled
&& b.GetVisualDescendants().OfType<Image>().Any())); && b.GetVisualDescendants().OfType<Image>().Any()));
// Assert — every disabled toolbar Image must render at <= 0.35 opacity.
var dimmedImages = window.GetVisualDescendants() var dimmedImages = window.GetVisualDescendants()
.OfType<Button>() .OfType<Button>()
.Where(b => !b.IsEffectivelyEnabled) .Where(b => !b.IsEffectivelyEnabled)
@ -92,6 +106,10 @@ public class MainWindowTests
[AvaloniaTest] [AvaloniaTest]
public async Task Toolbar_Has_Open_Button_Wired_To_Open_Command() public async Task Toolbar_Has_Open_Button_Wired_To_Open_Command()
{ {
// The MEF-driven Open button (Tag = "Open") must be present in the toolbar with its
// Command resolved and enabled.
// Arrange + Act — show window, wait for the MEF-built Open button to land.
var window = AppComposition.Current.GetExport<MainWindow>(); var window = AppComposition.Current.GetExport<MainWindow>();
window.Show(); window.Show();
@ -103,6 +121,7 @@ public class MainWindowTests
.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));
// Assert — Command is wired and CanExecute is true.
openButton.Command.Should().NotBeNull(); openButton.Command.Should().NotBeNull();
openButton.Command!.CanExecute(null).Should().BeTrue(); openButton.Command!.CanExecute(null).Should().BeTrue();
} }
@ -110,6 +129,10 @@ public class MainWindowTests
[AvaloniaTest] [AvaloniaTest]
public async Task Taskbar_Progress_Goes_Indeterminate_While_Decompiling_Then_Clears() public async Task Taskbar_Progress_Goes_Indeterminate_While_Decompiling_Then_Clears()
{ {
// Selecting a node kicks off a decompile; while it's running the taskbar progress must
// transition to Indeterminate, and once done it must drop back to None.
// Arrange — boot window, wait for assemblies, subscribe to TaskbarProgressService events.
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!;
@ -120,12 +143,14 @@ public class MainWindowTests
void Observe(TaskbarProgressState s) => states.Add(s); void Observe(TaskbarProgressState s) => states.Add(s);
service.StateChanged += Observe; service.StateChanged += Observe;
// Act — trigger a decompile.
var node = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq"); var node = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq");
vm.AssemblyTreeModel.SelectedItem = node; vm.AssemblyTreeModel.SelectedItem = node;
await vm.DockWorkspace.WaitForDecompiledTextAsync(); await vm.DockWorkspace.WaitForDecompiledTextAsync();
service.StateChanged -= Observe; service.StateChanged -= Observe;
// Assert — Indeterminate appeared mid-flight and None is the final state.
states.Should().Contain(TaskbarProgressState.Indeterminate, states.Should().Contain(TaskbarProgressState.Indeterminate,
"taskbar must show indeterminate progress while a decompile is in flight"); "taskbar must show indeterminate progress while a decompile is in flight");
states.Last().Should().Be(TaskbarProgressState.None, states.Last().Should().Be(TaskbarProgressState.None,

39
ILSpy.Tests/Navigation/NavigationTests.cs

@ -40,6 +40,12 @@ public class NavigationTests
[AvaloniaTest] [AvaloniaTest]
public async Task Back_Navigation_Restores_Previously_Selected_Node() public async Task Back_Navigation_Restores_Previously_Selected_Node()
{ {
// Selecting two methods in succession records both in the navigation history; pressing
// the Back command must restore the previously-selected node, re-decompile it, and
// scroll the tree so that node is back in view.
// Arrange — boot the window, wait for assemblies, expand Enumerable, capture two methods
// that we'll bounce between.
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!;
@ -47,12 +53,13 @@ public class NavigationTests
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");
typeNode.EnsureLazyChildren(); typeNode.IsExpanded = true;
var firstMethod = typeNode.Children.OfType<MethodTreeNode>() var firstMethod = typeNode.Children.OfType<MethodTreeNode>()
.Single(m => m.MethodDefinition.Name == "AsEnumerable"); .Single(m => m.MethodDefinition.Name == "AsEnumerable");
var secondMethod = typeNode.Children.OfType<MethodTreeNode>() var secondMethod = typeNode.Children.OfType<MethodTreeNode>()
.First(m => m.MethodDefinition.Name == "Empty"); .First(m => m.MethodDefinition.Name == "Empty");
// Act 1 — select AsEnumerable, wait for it to decompile.
vm.AssemblyTreeModel.SelectedItem = firstMethod; vm.AssemblyTreeModel.SelectedItem = firstMethod;
var firstTab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); var firstTab = await vm.DockWorkspace.WaitForDecompiledTextAsync();
firstTab.Text.Should().Contain("AsEnumerable"); firstTab.Text.Should().Contain("AsEnumerable");
@ -61,14 +68,18 @@ public class NavigationTests
// 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.
await Task.Delay(600); await Task.Delay(600);
// Act 2 — select Empty, wait for its decompile.
vm.AssemblyTreeModel.SelectedItem = secondMethod; vm.AssemblyTreeModel.SelectedItem = secondMethod;
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");
// Act 3 — fire NavigateBack.
vm.DockWorkspace.NavigateBackCommand.CanExecute(null).Should().BeTrue(); vm.DockWorkspace.NavigateBackCommand.CanExecute(null).Should().BeTrue();
vm.DockWorkspace.NavigateBackCommand.Execute(null); vm.DockWorkspace.NavigateBackCommand.Execute(null);
// Assert — selection restores to AsEnumerable, the document re-decompiles to its body,
// and the row is centred back into view.
await Waiters.WaitForAsync(() => ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, firstMethod)); await Waiters.WaitForAsync(() => ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, firstMethod));
var restoredTab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); var restoredTab = await vm.DockWorkspace.WaitForDecompiledTextAsync();
restoredTab.Text.Should().Contain("AsEnumerable"); restoredTab.Text.Should().Contain("AsEnumerable");
@ -80,6 +91,12 @@ public class NavigationTests
[AvaloniaTest] [AvaloniaTest]
public async Task Back_SplitButton_Dropdown_Lists_History_And_Jumps_To_Selected_Entry() public async Task Back_SplitButton_Dropdown_Lists_History_And_Jumps_To_Selected_Entry()
{ {
// The Back button on the toolbar is a SplitButton whose chevron drops a list of recent
// history entries. Verifies that the menu populates newest-first from the back stack,
// and that picking a non-immediate entry jumps multiple steps in one click (with the
// displaced entries pushed onto the forward stack).
// Arrange — boot, wait for assemblies, expand Enumerable, capture three methods.
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!;
@ -87,7 +104,7 @@ public class NavigationTests
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");
typeNode.EnsureLazyChildren(); typeNode.IsExpanded = true;
var methodA = typeNode.Children.OfType<MethodTreeNode>() var methodA = typeNode.Children.OfType<MethodTreeNode>()
.First(m => m.MethodDefinition.Name == "AsEnumerable"); .First(m => m.MethodDefinition.Name == "AsEnumerable");
var methodB = typeNode.Children.OfType<MethodTreeNode>() var methodB = typeNode.Children.OfType<MethodTreeNode>()
@ -95,8 +112,8 @@ public class NavigationTests
var methodC = typeNode.Children.OfType<MethodTreeNode>() var methodC = typeNode.Children.OfType<MethodTreeNode>()
.First(m => m.MethodDefinition.Name == "Range"); .First(m => m.MethodDefinition.Name == "Range");
// Three distinct selections with >0.6s gaps so each lands as its own entry on the back stack // Act 1 — three distinct selections with >0.6s gaps so each lands as its own entry on
// (NavigationHistory collapses sub-0.5s rapid succession into one entry). // the back stack (NavigationHistory collapses sub-0.5s rapid succession into one entry).
vm.AssemblyTreeModel.SelectedItem = methodA; vm.AssemblyTreeModel.SelectedItem = methodA;
await vm.DockWorkspace.WaitForDecompiledTextAsync(); await vm.DockWorkspace.WaitForDecompiledTextAsync();
await Task.Delay(600); await Task.Delay(600);
@ -106,27 +123,27 @@ public class NavigationTests
vm.AssemblyTreeModel.SelectedItem = methodC; vm.AssemblyTreeModel.SelectedItem = methodC;
await vm.DockWorkspace.WaitForDecompiledTextAsync(); await vm.DockWorkspace.WaitForDecompiledTextAsync();
// Open the Back SplitButton's flyout — the Opening handler populates the menu from the // Act 2 — open the Back SplitButton's flyout. The Opening handler populates the menu
// current back history. // from the current back history.
var backSplit = window.GetVisualDescendants().OfType<SplitButton>() var backSplit = window.GetVisualDescendants().OfType<SplitButton>()
.Single(sb => sb.Name == "BackSplitButton"); .Single(sb => sb.Name == "BackSplitButton");
var flyout = (MenuFlyout)backSplit.Flyout!; var flyout = (MenuFlyout)backSplit.Flyout!;
flyout.ShowAt(backSplit); flyout.ShowAt(backSplit);
await Waiters.WaitForAsync(() => flyout.Items.OfType<MenuItem>().Count() >= 2); await Waiters.WaitForAsync(() => flyout.Items.OfType<MenuItem>().Count() >= 2);
// Newest-first: index 0 is the immediate previous selection (methodB), // Assert 1 — newest-first ordering: index 0 is the immediate previous selection
// index 1 is the one before that (methodA). // (methodB), index 1 is the one before that (methodA).
var items = flyout.Items.OfType<MenuItem>().ToList(); var items = flyout.Items.OfType<MenuItem>().ToList();
((string)items[0].Header!).Should().Be((string)methodB.Text); ((string)items[0].Header!).Should().Be((string)methodB.Text);
((string)items[1].Header!).Should().Be((string)methodA.Text); ((string)items[1].Header!).Should().Be((string)methodA.Text);
items[1].CommandParameter.Should().BeSameAs(methodA); items[1].CommandParameter.Should().BeSameAs(methodA);
// 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));
// The two displaced entries (methodC, methodB) are now on the forward stack, so Forward // Assert 2 — the two displaced entries (methodC, methodB) are now on the forward stack,
// becomes available. // so Forward becomes available.
vm.DockWorkspace.NavigateForwardCommand.CanExecute(null).Should().BeTrue(); vm.DockWorkspace.NavigateForwardCommand.CanExecute(null).Should().BeTrue();
} }
} }

50
ILSpy.Tests/Resources/IconCompositionTests.cs

@ -32,7 +32,13 @@ public class IconCompositionTests
[AvaloniaTest] [AvaloniaTest]
public void Public_Method_Has_No_Overlays_And_Full_Sized_Base() public void Public_Method_Has_No_Overlays_And_Full_Sized_Base()
{ {
// Public members are the unmarked default — no accessibility overlay glyph and no
// shrinkdown; the base image renders 1:1.
// Arrange + Act — request a Public Method icon.
var icon = (LayeredImage)Images.GetIcon(Images.Method, AccessOverlayIcon.Public); var icon = (LayeredImage)Images.GetIcon(Images.Method, AccessOverlayIcon.Public);
// Assert — base is the Method bitmap, no overlays, full scale.
icon.BaseImage.Should().BeSameAs(Images.Method); icon.BaseImage.Should().BeSameAs(Images.Method);
icon.Overlays.Should().BeEmpty(); icon.Overlays.Should().BeEmpty();
icon.BaseScale.Should().Be(1.0); icon.BaseScale.Should().Be(1.0);
@ -41,7 +47,13 @@ public class IconCompositionTests
[AvaloniaTest] [AvaloniaTest]
public void Private_Method_Composes_Method_Plus_OverlayPrivate_At_80_Percent() public void Private_Method_Composes_Method_Plus_OverlayPrivate_At_80_Percent()
{ {
// Adding an accessibility overlay (Private here) shrinks the base to 80% so the overlay
// glyph has visual room to sit in the bottom-right corner.
// Arrange + Act — request a Private Method icon.
var icon = (LayeredImage)Images.GetIcon(Images.Method, AccessOverlayIcon.Private); var icon = (LayeredImage)Images.GetIcon(Images.Method, AccessOverlayIcon.Private);
// Assert — base shrinks to 0.8, exactly one overlay (the Private padlock).
icon.BaseImage.Should().BeSameAs(Images.Method); icon.BaseImage.Should().BeSameAs(Images.Method);
icon.BaseScale.Should().Be(0.8); icon.BaseScale.Should().Be(0.8);
icon.Overlays.Should().Equal(Images.OverlayPrivate); icon.Overlays.Should().Equal(Images.OverlayPrivate);
@ -50,7 +62,13 @@ public class IconCompositionTests
[AvaloniaTest] [AvaloniaTest]
public void Static_Public_Field_Composes_Field_Plus_OverlayStatic_At_80_Percent() public void Static_Public_Field_Composes_Field_Plus_OverlayStatic_At_80_Percent()
{ {
// `static` adds a Static overlay glyph even when the member is Public — any non-default
// modifier triggers the 0.8 base shrink.
// Arrange + Act — request a static Public Field icon.
var icon = (LayeredImage)Images.GetIcon(Images.Field, AccessOverlayIcon.Public, isStatic: true); var icon = (LayeredImage)Images.GetIcon(Images.Field, AccessOverlayIcon.Public, isStatic: true);
// Assert — base shrinks to 0.8 with just the Static glyph layered on top.
icon.BaseImage.Should().BeSameAs(Images.Field); icon.BaseImage.Should().BeSameAs(Images.Field);
icon.BaseScale.Should().Be(0.8); icon.BaseScale.Should().Be(0.8);
icon.Overlays.Should().Equal(Images.OverlayStatic); icon.Overlays.Should().Equal(Images.OverlayStatic);
@ -59,7 +77,12 @@ public class IconCompositionTests
[AvaloniaTest] [AvaloniaTest]
public void Extension_Method_Composes_Method_Plus_OverlayExtension_At_80_Percent() public void Extension_Method_Composes_Method_Plus_OverlayExtension_At_80_Percent()
{ {
// Extension methods get the dedicated Extension overlay even when the member is Public.
// Arrange + Act — request a Public extension Method icon.
var icon = (LayeredImage)Images.GetIcon(Images.Method, AccessOverlayIcon.Public, isExtension: true); var icon = (LayeredImage)Images.GetIcon(Images.Method, AccessOverlayIcon.Public, isExtension: true);
// Assert — base shrinks to 0.8 with the Extension glyph as the lone overlay.
icon.BaseImage.Should().BeSameAs(Images.Method); icon.BaseImage.Should().BeSameAs(Images.Method);
icon.BaseScale.Should().Be(0.8); icon.BaseScale.Should().Be(0.8);
icon.Overlays.Should().Equal(Images.OverlayExtension); icon.Overlays.Should().Equal(Images.OverlayExtension);
@ -68,8 +91,14 @@ public class IconCompositionTests
[AvaloniaTest] [AvaloniaTest]
public void Static_Private_Method_Layers_Static_Then_Private_Over_Method_At_80_Percent() public void Static_Private_Method_Layers_Static_Then_Private_Over_Method_At_80_Percent()
{ {
// Order matches the WPF host's draw sequence: extension → static → accessibility. // Order matters: the layering sequence (extension → static → accessibility) determines
// which glyphs end up on top when corners overlap. A static private method must layer
// Static under Private so the accessibility glyph remains visible.
// Arrange + Act — request a static Private Method icon.
var icon = (LayeredImage)Images.GetIcon(Images.Method, AccessOverlayIcon.Private, isStatic: true); var icon = (LayeredImage)Images.GetIcon(Images.Method, AccessOverlayIcon.Private, isStatic: true);
// Assert — overlays are Static followed by Private (back-to-front draw order).
icon.BaseImage.Should().BeSameAs(Images.Method); icon.BaseImage.Should().BeSameAs(Images.Method);
icon.BaseScale.Should().Be(0.8); icon.BaseScale.Should().Be(0.8);
icon.Overlays.Should().Equal(Images.OverlayStatic, Images.OverlayPrivate); icon.Overlays.Should().Equal(Images.OverlayStatic, Images.OverlayPrivate);
@ -78,8 +107,14 @@ public class IconCompositionTests
[AvaloniaTest] [AvaloniaTest]
public void Static_Extension_Method_Layers_Extension_Then_Static_Then_Internal() public void Static_Extension_Method_Layers_Extension_Then_Static_Then_Internal()
{ {
// All three modifiers at once exercises the full layering rule: extension → static →
// accessibility, in that draw order.
// Arrange + Act — request an internal static extension Method icon.
var icon = (LayeredImage)Images.GetIcon(Images.Method, AccessOverlayIcon.Internal, var icon = (LayeredImage)Images.GetIcon(Images.Method, AccessOverlayIcon.Internal,
isStatic: true, isExtension: true); isStatic: true, isExtension: true);
// Assert — three overlays in the documented order.
icon.BaseImage.Should().BeSameAs(Images.Method); icon.BaseImage.Should().BeSameAs(Images.Method);
icon.BaseScale.Should().Be(0.8); icon.BaseScale.Should().Be(0.8);
icon.Overlays.Should().Equal(Images.OverlayExtension, Images.OverlayStatic, Images.OverlayInternal); icon.Overlays.Should().Equal(Images.OverlayExtension, Images.OverlayStatic, Images.OverlayInternal);
@ -88,7 +123,13 @@ public class IconCompositionTests
[AvaloniaTest] [AvaloniaTest]
public void Protected_Event_Composes_Event_Plus_OverlayProtected() public void Protected_Event_Composes_Event_Plus_OverlayProtected()
{ {
// Sanity-check that the same composition rules apply across base bitmaps (Event here,
// not just Method/Field).
// Arrange + Act — request a Protected Event icon.
var icon = (LayeredImage)Images.GetIcon(Images.Event, AccessOverlayIcon.Protected); var icon = (LayeredImage)Images.GetIcon(Images.Event, AccessOverlayIcon.Protected);
// Assert — base shrinks to 0.8 with the Protected glyph as the lone overlay.
icon.BaseImage.Should().BeSameAs(Images.Event); icon.BaseImage.Should().BeSameAs(Images.Event);
icon.BaseScale.Should().Be(0.8); icon.BaseScale.Should().Be(0.8);
icon.Overlays.Should().Equal(Images.OverlayProtected); icon.Overlays.Should().Equal(Images.OverlayProtected);
@ -97,8 +138,15 @@ public class IconCompositionTests
[AvaloniaTest] [AvaloniaTest]
public void GetIcon_Caches_Same_Composition() public void GetIcon_Caches_Same_Composition()
{ {
// Decompiling a large assembly produces hundreds of identically-decorated members;
// GetIcon must memoise on (base, overlay, isStatic, isExtension) so we don't recreate
// the same LayeredImage repeatedly.
// Arrange + Act — request the same composition twice.
var first = Images.GetIcon(Images.Property, AccessOverlayIcon.Internal); var first = Images.GetIcon(Images.Property, AccessOverlayIcon.Internal);
var second = Images.GetIcon(Images.Property, AccessOverlayIcon.Internal); var second = Images.GetIcon(Images.Property, AccessOverlayIcon.Internal);
// Assert — reference equality proves caching.
first.Should().BeSameAs(second, "GetIcon must memoise by (base, overlay, isStatic, isExtension)"); first.Should().BeSameAs(second, "GetIcon must memoise by (base, overlay, isStatic, isExtension)");
} }
} }

56
ILSpy.Tests/Resources/ResourceFactoryTests.cs

@ -63,14 +63,22 @@ public class ResourceFactoryTests
[TestCase("strings.resources")] [TestCase("strings.resources")]
public void Typed_Resource_Names_Route_To_Specialised_Node(string name) public void Typed_Resource_Names_Route_To_Specialised_Node(string name)
{ {
// Each known resource extension (.xsd/.xml/.png/.bmp/.ico/.cur/.resources/...) has a
// dedicated ResourceNodeFactory that produces a richer node than the generic
// ResourceTreeNode fallback. Verifies the dispatcher picks the typed handler for every
// recognised extension regardless of the stream's actual contents.
// Arrange — boot composition so the static ResourceNodeFactories list is populated; build
// a tiny dummy payload (typed-routing must be name-based, not content-based).
EnsureComposition(); EnsureComposition();
// Tiny payload — node creation must not depend on stream contents being parseable.
var payload = Encoding.UTF8.GetBytes("<root />"); var payload = Encoding.UTF8.GetBytes("<root />");
// Act — dispatch the resource through the factory pipeline.
var node = ResourceEntryNode.Create(new ByteArrayResource(name, payload)); var node = ResourceEntryNode.Create(new ByteArrayResource(name, payload));
// Anything ending in a known extension must NOT fall back to the generic node — the // Assert — the produced node is anything *but* the generic fallback. Compare by exact
// dispatcher's whole job is to pick the right typed handler. Compare by exact type // type (not BeOfType) since SharpTreeNode has a custom .Should() extension in this
// (not BeOfType) since SharpTreeNode has a custom .Should() extension in this assembly. // assembly that hides BeOfType.
node.GetType().Should().NotBe(typeof(ResourceTreeNode), node.GetType().Should().NotBe(typeof(ResourceTreeNode),
$"resource '{name}' should be routed to a specialised node, not the generic fallback"); $"resource '{name}' should be routed to a specialised node, not the generic fallback");
} }
@ -78,14 +86,30 @@ public class ResourceFactoryTests
[AvaloniaTest] [AvaloniaTest]
public void Unknown_Extension_Falls_Back_To_Generic_Resource_Node() public void Unknown_Extension_Falls_Back_To_Generic_Resource_Node()
{ {
// Resources whose extension isn't in any factory's claim list must fall back to the
// plain ResourceTreeNode (which renders as a hex/text dump) rather than throwing or
// silently dropping the entry.
// Arrange — boot composition; pick an extension nobody handles.
EnsureComposition(); EnsureComposition();
// Act — dispatch the unknown-extension resource.
var node = ResourceEntryNode.Create(new ByteArrayResource("data.bin", new byte[] { 1, 2, 3 })); var node = ResourceEntryNode.Create(new ByteArrayResource("data.bin", new byte[] { 1, 2, 3 }));
// Assert — exactly the generic ResourceTreeNode comes back.
node.GetType().Should().Be(typeof(ResourceTreeNode)); node.GetType().Should().Be(typeof(ResourceTreeNode));
} }
[AvaloniaTest] [AvaloniaTest]
public void Resources_File_Decompile_Emits_UIElements_For_String_And_Object_Tables() public void Resources_File_Decompile_Emits_UIElements_For_String_And_Object_Tables()
{ {
// A .resources file should render inline as two DataGrids (one for string entries, one
// for non-string objects) sitting alongside the inherited Save button. Drives the
// ResourcesFileTreeNode.Decompile implementation that emits AddUIElement calls instead
// of plain comment lines.
// Arrange — boot composition, build a fixture .resources stream with mixed string and
// numeric entries, force lazy children, grab the active language for WriteCommentLine.
EnsureComposition(); EnsureComposition();
var node = (ResourcesFileTreeNode)ResourceEntryNode.Create( var node = (ResourcesFileTreeNode)ResourceEntryNode.Create(
new ByteArrayResource("strings.resources", BuildResources( new ByteArrayResource("strings.resources", BuildResources(
@ -97,13 +121,13 @@ public class ResourceFactoryTests
}))); })));
node.EnsureLazyChildren(); node.EnsureLazyChildren();
var output = new AvaloniaEditTextOutput(); var output = new AvaloniaEditTextOutput();
// Use the active language so WriteCommentLine works without reaching into MEF state.
var language = AppComposition.Current.GetExport<LanguageService>().CurrentLanguage; var language = AppComposition.Current.GetExport<LanguageService>().CurrentLanguage;
// Act — decompile the resource node into the AvaloniaEdit text output.
node.Decompile(language, output, new DecompilationOptions()); node.Decompile(language, output, new DecompilationOptions());
// Two grids (string + object) on top of the inherited Save button = 3 UI elements. // Assert — exactly three UI elements (string grid + object grid + inherited Save button)
// The previous implementation emitted comment lines and only the inherited Save button, // and they all materialise as Avalonia Controls.
// so this drives adding two AddUIElement(...) calls in ResourcesFileTreeNode.Decompile.
output.UIElements.Should().HaveCount(3, output.UIElements.Should().HaveCount(3,
".resources should render a string table + object table inline alongside the Save button"); ".resources should render a string table + object table inline alongside the Save button");
var realised = output.UIElements.Select(kv => kv.Value.Value).ToList(); var realised = output.UIElements.Select(kv => kv.Value.Value).ToList();
@ -113,6 +137,12 @@ public class ResourceFactoryTests
[AvaloniaTest] [AvaloniaTest]
public void Resources_File_WriteResX_Round_Trips_String_And_Object_Entries() public void Resources_File_WriteResX_Round_Trips_String_And_Object_Entries()
{ {
// ResourcesFileTreeNode.WriteResX is the format converter behind the "Save as ResX"
// option in the resource Save dialog. Verifies it produces well-formed ResX XML with
// both string and non-string entries faithfully serialised.
// Arrange — boot composition; build a fixture .resources stream with one string and one
// integer entry; force lazy children so the resource enumeration is realised.
EnsureComposition(); EnsureComposition();
var node = (ResourcesFileTreeNode)ResourceEntryNode.Create( var node = (ResourcesFileTreeNode)ResourceEntryNode.Create(
new ByteArrayResource("strings.resources", BuildResources( new ByteArrayResource("strings.resources", BuildResources(
@ -122,11 +152,12 @@ public class ResourceFactoryTests
}))); })));
node.EnsureLazyChildren(); node.EnsureLazyChildren();
// Act — write the ResX XML into a memory stream and decode it.
var ms = new MemoryStream(); var ms = new MemoryStream();
node.WriteResX(ms); node.WriteResX(ms);
var resx = Encoding.UTF8.GetString(ms.ToArray()); var resx = Encoding.UTF8.GetString(ms.ToArray());
// ResX is XML; spot-check both entries land with name + value markup. // Assert — both entries land with proper name + value markup (ResX is XML).
resx.Should().Contain("<data name=\"greeting\""); resx.Should().Contain("<data name=\"greeting\"");
resx.Should().Contain("<value>hello</value>"); resx.Should().Contain("<value>hello</value>");
resx.Should().Contain("<data name=\"answer\""); resx.Should().Contain("<data name=\"answer\"");
@ -136,8 +167,15 @@ public class ResourceFactoryTests
[Test] [Test]
public void FilePickers_ParseFilter_Splits_Pipe_Separated_Display_And_Patterns() public void FilePickers_ParseFilter_Splits_Pipe_Separated_Display_And_Patterns()
{ {
// FilePickers.ParseFilter translates the legacy WPF "Display|*.ext|Display|*.ext"
// filter string into Avalonia FilePickerFileType records. Verifies the splitter pairs
// up display name with the matching glob pattern correctly.
// Arrange + Act — feed a two-format filter string through the parser.
var types = global::ILSpy.Commands.FilePickers.ParseFilter( var types = global::ILSpy.Commands.FilePickers.ParseFilter(
"Resources file (*.resources)|*.resources|Resource XML (*.resx)|*.resx"); "Resources file (*.resources)|*.resources|Resource XML (*.resx)|*.resx");
// Assert — two file types come back, each with the right display name and pattern.
types.Should().HaveCount(2); types.Should().HaveCount(2);
types[0].Name.Should().Be("Resources file (*.resources)"); types[0].Name.Should().Be("Resources file (*.resources)");
types[0].Patterns.Should().BeEquivalentTo(new[] { "*.resources" }); types[0].Patterns.Should().BeEquivalentTo(new[] { "*.resources" });

Loading…
Cancel
Save