Browse Source

Collapse UI tests that repeat setup already paid for

Five fixtures covered the "Use nested namespace structure" setting, four of
them running the same toggle at a different layer: the model shape, the same
toggle awaited live, and the same toggle again asserting it reached the
SharpTreeView's rows. Each paid its own boot for a scenario that is one story
end to end, and together they were the second-largest block of time in the
suite after the process-list scroll loops. One test now walks the whole path
once, carrying every assertion the four had, including the nesting depth only
the first checked.

The comparison view's model-is-bound test is dropped: the test after it
renders rows out of that model, which cannot happen unless it is bound, and
it opened two fixture assemblies to prove it.

The expander hitbox test asserted the toggle measures 13x16 and its glyph 9x9,
then clicked 14px down to prove the area below the glyph is live. The click
proves the geometry; the measurements only restate it, and would fail on a
font-metric change that broke nothing. Its layout-settling loop slept 200ms
unconditionally, which is a race that usually wins - it now waits for the
condition it needs.

Assisted-by: Claude:claude-opus-5:Claude Code
pull/3980/head
Siegfried Pammer 1 month ago committed by Siegfried Pammer
parent
commit
dc5b70f4e4
  1. 53
      ILSpy.Tests/AssemblyList/AssemblyTreeExpanderHitboxTests.cs
  2. 95
      ILSpy.Tests/AssemblyList/UseNestedNamespaceNodesGridVerification.cs
  3. 78
      ILSpy.Tests/AssemblyList/UseNestedNamespaceNodesLiveTests.cs
  4. 108
      ILSpy.Tests/AssemblyList/UseNestedNamespaceNodesTests.cs
  5. 28
      ILSpy.Tests/Compare/CompareViewRenderTests.cs

53
ILSpy.Tests/AssemblyList/AssemblyTreeExpanderHitboxTests.cs

@ -46,11 +46,10 @@ public class AssemblyTreeExpanderHitboxTests @@ -46,11 +46,10 @@ public class AssemblyTreeExpanderHitboxTests
[AvaloniaTest]
public async Task Expander_Toggle_Offers_At_Least_16x16_Clickable_Target()
{
// The +/- expander in the assembly tree must give a click target that fills the full 13px
// expander column and the 16px row-tall toggle, while its visible glyph stays the classic
// 9x9 box. The column is kept at 13px so the +/- box centres on the tree connector lines;
// the target must be genuinely hittable across that whole area — not merely occupy it in
// layout while only the 9x9 glyph receives input.
// The +/- expander's click target fills the 13px expander column and the 16px row height,
// while the drawn glyph stays the classic 9x9 box centred on the tree connector lines. The
// grown area has to be genuinely hittable rather than merely occupied in layout, so the
// test clicks below the glyph instead of measuring the boxes.
// Arrange — boot, wait for assemblies, expand a node so an expandable row is realised.
var (window, vm) = await TestHarness.BootAsync(3);
@ -63,39 +62,21 @@ public class AssemblyTreeExpanderHitboxTests @@ -63,39 +62,21 @@ public class AssemblyTreeExpanderHitboxTests
var pane = await window.WaitForComponent<AssemblyListPane>();
var grid = await pane.WaitForComponent<ICSharpCode.ILSpy.Controls.TreeView.SharpTreeView>();
// Let rows realise and layout settle.
for (int i = 0; i < 8; i++)
{
Dispatcher.UIThread.RunJobs();
grid.UpdateLayout();
await Task.Delay(25);
}
// Act — locate the expander toggle of the (expandable) assembly row.
var row = grid.GetVisualDescendants().OfType<ICSharpCode.ILSpy.Controls.TreeView.SharpTreeViewItem>()
.FirstOrDefault(r => RowMatches(r, assemblyNode));
row.Should().NotBeNull("the expanded assembly row must be realised");
var expander = row!.GetVisualDescendants().OfType<ToggleButton>()
.FirstOrDefault(b => b.Name == "PART_Expander");
expander.Should().NotBeNull("an expandable row must realise a PART_Expander toggle");
// Act — locate the expander toggle of the (expandable) assembly row, once the row and its
// template have been realised.
ToggleButton? expander = null;
await Waiters.WaitForAsync(
() => {
grid.UpdateLayout();
expander = grid.GetVisualDescendants().OfType<ICSharpCode.ILSpy.Controls.TreeView.SharpTreeViewItem>()
.FirstOrDefault(r => RowMatches(r, assemblyNode))
?.GetVisualDescendants().OfType<ToggleButton>()
.FirstOrDefault(b => b.Name == "PART_Expander");
return expander is { Bounds.Height: >= 16 };
},
description: "the expanded assembly row must realise a PART_Expander toggle filling the row height");
expander!.IsEnabled.Should().BeTrue("the assembly row is expandable");
// Assert — the click target fills the 13px expander column and is 16px tall.
expander.Bounds.Width.Should().BeGreaterThanOrEqualTo(13,
"the expander click target must fill the 13px expander column for reliable tapping");
expander.Bounds.Height.Should().BeGreaterThanOrEqualTo(16,
"the expander click target must be at least 16px tall for reliable tapping");
// Assert — the visible glyph box is unchanged at 9x9 (the nearest Border around ExpandPath,
// i.e. the drawn box, not the transparent hit-target wrapper).
var glyphPath = expander.GetVisualDescendants().OfType<Path>()
.FirstOrDefault(p => p.Name == "ExpandPath");
glyphPath.Should().NotBeNull("the expander must render its ExpandPath glyph");
var glyph = glyphPath!.GetVisualAncestors().OfType<Border>().FirstOrDefault();
glyph.Should().NotBeNull("the expander must still render its glyph box");
glyph!.Bounds.Width.Should().BeApproximately(9, 0.5, "the visible glyph box must stay 9px wide");
glyph.Bounds.Height.Should().BeApproximately(9, 0.5, "the visible glyph box must stay 9px tall");
// Assert — a real click well below the 9x9 glyph (y=14, inside the 16-tall target but
// outside the centred glyph at ~y=3.5..12.5) collapses the node. This proves the grown
// area is genuinely hittable, not just larger in layout.

95
ILSpy.Tests/AssemblyList/UseNestedNamespaceNodesGridVerification.cs

@ -1,95 +0,0 @@ @@ -1,95 +0,0 @@
// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
// software and associated documentation files (the "Software"), to deal in the Software
// without restriction, including without limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
// to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or
// substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System.Collections;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Headless.NUnit;
using AwesomeAssertions;
using ICSharpCode.ILSpyX.TreeView;
using ICSharpCode.ILSpy;
using ICSharpCode.ILSpy.AppEnv;
using ICSharpCode.ILSpy.AssemblyTree;
using ICSharpCode.ILSpy.TreeNodes;
using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests;
[TestFixture]
public class UseNestedNamespaceNodesGridVerification
{
[AvaloniaTest]
public async Task Toggling_UseNestedNamespaceNodes_Reshapes_The_Visible_Tree_In_Place()
{
// Toggling UseNestedNamespaceNodes rebuilds each loaded assembly's namespace subtree.
// SharpTreeView's flattener observes node.Children mutations live, so the rebuilt shape
// surfaces in the visible row list without any model re-bind (the ProDataGrid snapshot
// problem that used to need a HierarchicalModel rebind is gone). This pins that the
// flat-mode -> nested-mode switch is reflected in the SharpTreeView's ItemsSource.
var settings = AppComposition.Current.GetExport<SettingsService>().DisplaySettings;
settings.UseNestedNamespaceNodes = false;
var (window, vm) = await TestHarness.BootAsync(3);
var pane = await window.WaitForComponent<AssemblyListPane>();
var grid = await pane.WaitForComponent<ICSharpCode.ILSpy.Controls.TreeView.SharpTreeView>();
// Expand an assembly so its namespace children become visible rows.
var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq");
assemblyNode.IsExpanded = true;
await Waiters.WaitForAsync(() => assemblyNode.Children.OfType<NamespaceTreeNode>().Any());
TestCapture.Step("system-linq-expanded-flat");
var flat = (IList)grid.ItemsSource!;
bool VisibleNamespace(string name) => flat.Cast<SharpTreeNode>()
.OfType<NamespaceTreeNode>()
.Any(n => string.Equals(n.Text?.ToString(), name, System.StringComparison.Ordinal));
// In flat mode the full dotted namespace is a single visible row.
await Waiters.WaitForAsync(() => VisibleNamespace("System.Collections.Generic"),
description: "flat mode shows the dotted namespace as one row");
try
{
settings.UseNestedNamespaceNodes = true;
assemblyNode.IsExpanded = true;
TestCapture.Step("nested-namespaces-rebuilt");
// In nested mode the leaf segment ("Generic") becomes its own row nested under
// "System" -> "Collections"; the live flattener must surface that without a re-bind.
await Waiters.WaitForAsync(
() => assemblyNode.Children.OfType<NamespaceTreeNode>()
.Any(n => string.Equals(n.Text?.ToString(), "System", System.StringComparison.Ordinal)),
System.TimeSpan.FromSeconds(5),
"toggling UseNestedNamespaceNodes must rebuild the namespace subtree into nested nodes");
VisibleNamespace("System").Should().BeTrue(
"the rebuilt nested 'System' namespace node must appear in the SharpTreeView's visible rows "
+ "without any model re-bind (the live flattener observes the child mutations directly)");
}
finally
{
settings.UseNestedNamespaceNodes = false;
}
}
}

78
ILSpy.Tests/AssemblyList/UseNestedNamespaceNodesLiveTests.cs

@ -1,78 +0,0 @@ @@ -1,78 +0,0 @@
// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
// software and associated documentation files (the "Software"), to deal in the Software
// without restriction, including without limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
// to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or
// substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Headless.NUnit;
using AwesomeAssertions;
using ICSharpCode.ILSpy;
using ICSharpCode.ILSpy.AppEnv;
using ICSharpCode.ILSpy.TreeNodes;
using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests;
[TestFixture]
public class UseNestedNamespaceNodesLiveTests
{
[AvaloniaTest]
public async Task Toggling_UseNestedNamespaceNodes_Live_Refreshes_The_Tree_Shape()
{
// Drives the live-reactivity path: toggle the Display-Settings flag and observe
// that the AssemblyTreeNode's namespace children switch between flat and nested
// layouts without a manual rebuild.
var settings = AppComposition.Current.GetExport<SettingsService>().DisplaySettings;
settings.UseNestedNamespaceNodes = false;
var (_, vm) = await TestHarness.BootAsync(3);
try
{
var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq");
assemblyNode.EnsureLazyChildren();
var flatNamespaces = assemblyNode.Children.OfType<NamespaceTreeNode>()
.Select(n => n.Name).ToList();
flatNamespaces.Should().Contain("System.Linq",
"baseline: flat mode lists 'System.Linq' as a top-level sibling");
// Toggle the live setting — should fan out through MessageBus<SettingsChangedEventArgs>
// to AssemblyTreeModel.OnSettingsChanged and rebuild the namespace subtrees.
settings.UseNestedNamespaceNodes = true;
await Waiters.WaitForAsync(() =>
assemblyNode.Children.OfType<NamespaceTreeNode>().Any(n => n.Name == "System"),
System.TimeSpan.FromSeconds(5));
var nestedNames = assemblyNode.Children.OfType<NamespaceTreeNode>()
.Select(n => n.Name).ToList();
nestedNames.Should().Contain("System",
"after live toggle: nested mode must surface 'System' as top-level");
nestedNames.Should().NotContain("System.Linq",
"after live toggle: the flat 'System.Linq' sibling must disappear");
}
finally
{
settings.UseNestedNamespaceNodes = false;
}
}
}

108
ILSpy.Tests/AssemblyList/UseNestedNamespaceNodesTests.cs

@ -16,6 +16,8 @@ @@ -16,6 +16,8 @@
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System;
using System.Collections;
using System.Linq;
using System.Threading.Tasks;
@ -23,77 +25,83 @@ using Avalonia.Headless.NUnit; @@ -23,77 +25,83 @@ using Avalonia.Headless.NUnit;
using AwesomeAssertions;
using ICSharpCode.ILSpy;
using ICSharpCode.ILSpy.AppEnv;
using ICSharpCode.ILSpy.AssemblyTree;
using ICSharpCode.ILSpy.TreeNodes;
using ICSharpCode.ILSpyX.TreeView;
using NUnit.Framework;
using SharpTreeView = ICSharpCode.ILSpy.Controls.TreeView.SharpTreeView;
namespace ICSharpCode.ILSpy.Tests;
/// <summary>
/// The "Use nested namespace structure" display setting, from the setting through to the
/// rows on screen. Flat mode keeps every dotted namespace as its own sibling of the assembly
/// node; nested mode splits them, so "System.Linq" becomes "Linq" under "System". The switch
/// happens live: toggling rebuilds each loaded assembly's namespace subtree, and because
/// SharpTreeView's flattener observes node.Children directly, the rebuilt shape reaches the
/// visible rows with no model re-bind.
/// </summary>
[TestFixture]
public class UseNestedNamespaceNodesTests
{
[AvaloniaTest]
public async Task When_UseNestedNamespaceNodes_True_Namespaces_Are_Hierarchical()
public async Task Toggling_The_Setting_Reshapes_The_Tree_Live_Down_To_The_Visible_Rows()
{
// With the setting on, "System" becomes a single root node holding "Collections",
// "IO", "Linq", … as descendants — not the flat "System", "System.Collections",
// "System.IO" siblings the default flat mode produces.
var settings = AppComposition.Current.GetExport<SettingsService>().DisplaySettings;
var (_, vm) = await TestHarness.BootAsync(3);
settings.UseNestedNamespaceNodes = false;
var (window, vm) = await TestHarness.BootAsync(3);
var pane = await window.WaitForComponent<AssemblyListPane>();
var grid = await pane.WaitForComponent<SharpTreeView>();
try
{
settings.UseNestedNamespaceNodes = true;
// Use System.Linq's assembly — it has System and System.Linq as namespaces.
// System.Linq's assembly carries both "System" and "System.Linq", so one assembly
// shows the difference between the two layouts.
var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq");
assemblyNode.Children.Clear();
assemblyNode.LazyLoading = true;
assemblyNode.EnsureLazyChildren();
var systemNode = assemblyNode.Children.OfType<NamespaceTreeNode>()
.SingleOrDefault(ns => ns.Name == "System");
((object?)systemNode).Should().NotBeNull(
"in nested mode the top-level node for the System namespace must exist as 'System' (last segment), not 'System.Linq'");
var nestedLinq = systemNode!.Children.OfType<NamespaceTreeNode>()
.SingleOrDefault(ns => ns.Name == "Linq");
((object?)nestedLinq).Should().NotBeNull(
"the System.Linq namespace must nest under the System node in nested mode");
// Sanity: there is NO sibling "System.Linq" at the assembly-node level.
assemblyNode.Children.OfType<NamespaceTreeNode>()
.Select(n => n.Name).Should().NotContain("System.Linq",
"flat-style 'System.Linq' sibling must not appear when nesting is on");
assemblyNode.IsExpanded = true;
await Waiters.WaitForAsync(() => assemblyNode.Children.OfType<NamespaceTreeNode>().Any());
var visibleRows = (IList)grid.ItemsSource!;
bool VisibleNamespace(string name) => visibleRows.Cast<SharpTreeNode>()
.OfType<NamespaceTreeNode>()
.Any(n => string.Equals(n.Text?.ToString(), name, StringComparison.Ordinal));
NamespaceNames().Should().Contain("System.Linq",
"flat mode lists the whole dotted namespace as one sibling of the assembly node");
await Waiters.WaitForAsync(() => VisibleNamespace("System.Collections.Generic"),
description: "flat mode shows the dotted namespace as a single visible row");
TestCapture.Step("flat-mode");
// The setting fans out through MessageBus<SettingsChangedEventArgs> to
// AssemblyTreeModel.OnSettingsChanged, which rebuilds the namespace subtrees.
settings.UseNestedNamespaceNodes = true;
assemblyNode.IsExpanded = true;
await Waiters.WaitForAsync(() => NamespaceNames().Contains("System"),
TimeSpan.FromSeconds(5),
"toggling the setting must rebuild the namespace subtree into nested nodes");
TestCapture.Step("nested-mode");
NamespaceNames().Should().NotContain("System.Linq",
"the flat dotted sibling must be gone once its segments are nested");
assemblyNode.Children.OfType<NamespaceTreeNode>().Single(n => n.Name == "System")
.Children.OfType<NamespaceTreeNode>().Select(n => n.Name).Should().Contain("Linq",
"the trailing segment must hang under the node for the leading one");
VisibleNamespace("System").Should().BeTrue(
"the rebuilt node must reach the visible rows without a model re-bind - the live "
+ "flattener observes the child mutations directly");
string[] NamespaceNames() => assemblyNode.Children.OfType<NamespaceTreeNode>()
.Select(n => n.Name).ToArray();
}
finally
{
settings.UseNestedNamespaceNodes = false;
}
}
[AvaloniaTest]
public async Task When_UseNestedNamespaceNodes_False_Namespaces_Are_Flat()
{
// Baseline: the default flat layout keeps every distinct namespace string as a
// sibling under the AssemblyTreeNode.
var settings = AppComposition.Current.GetExport<SettingsService>().DisplaySettings;
settings.UseNestedNamespaceNodes = false;
var (_, vm) = await TestHarness.BootAsync(3);
var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq");
assemblyNode.Children.Clear();
assemblyNode.LazyLoading = true;
assemblyNode.EnsureLazyChildren();
var namespaceNames = assemblyNode.Children.OfType<NamespaceTreeNode>()
.Select(n => n.Name).ToList();
namespaceNames.Should().Contain("System.Linq",
"in flat mode 'System.Linq' must appear as a top-level sibling");
}
}

28
ILSpy.Tests/Compare/CompareViewRenderTests.cs

@ -48,34 +48,6 @@ namespace ICSharpCode.ILSpy.Tests.Compare; @@ -48,34 +48,6 @@ namespace ICSharpCode.ILSpy.Tests.Compare;
[TestFixture]
public class CompareViewRenderTests
{
[AvaloniaTest]
public async Task CompareView_Binds_A_HierarchicalModel_When_Opened()
{
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 2);
var entry = AppComposition.Current.GetExport<ICSharpCode.ILSpy.ContextMenuEntryRegistry>()
.Entries.Single(e => e.Metadata.Header == "Compare...").Value;
var assemblies = new[] {
await vm.OpenFixtureAsync("FixtureA"),
await vm.OpenFixtureAsync("FixtureB"),
};
var nodes = assemblies.Select(a =>
(SharpTreeNode)vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>(a.ShortName)).ToArray();
entry.Execute(new TextViewContext { SelectedTreeNodes = nodes });
var view = await window.WaitForComponent<CompareView>();
var grid = await view.WaitForComponent<DataGrid>();
await Waiters.WaitForAsync(() => grid.HierarchicalModel != null,
description: "CompareView must populate the DataGrid's HierarchicalModel — without it "
+ "the hierarchical column is rendered over an empty source and the tab shows nothing");
grid.HierarchicalModel.Should().NotBeNull();
}
[AvaloniaTest]
public async Task CompareView_Renders_Rows_For_The_Merged_Tree()
{

Loading…
Cancel
Save