// 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.Generic; using System.Linq; using System.Threading.Tasks; using Avalonia.Controls; using Avalonia.Controls.Primitives; using Avalonia.Headless; using Avalonia.Headless.NUnit; using Avalonia.Input; using Avalonia.Threading; using Avalonia.VisualTree; using AwesomeAssertions; using ICSharpCode.ILSpyX.TreeView; using ICSharpCode.ILSpy.Controls.TreeView; using NUnit.Framework; namespace ICSharpCode.ILSpy.Tests.Controls; [TestFixture] public class SharpTreeViewTests { sealed class TestNode : SharpTreeNode { readonly string text; public TestNode(string text, params TestNode[] children) { this.text = text; foreach (var child in children) Children.Add(child); } public override object Text => text; } static (Window window, SharpTreeView tree, TestNode root) Host() { var b1 = new TestNode("B1"); var b = new TestNode("B", b1); var root = new TestNode("root", new TestNode("A"), b, new TestNode("C")); var tree = new SharpTreeView { ShowRoot = false, Root = root }; var window = new Window { Content = tree, Width = 300, Height = 400 }; window.Show(); Dispatcher.UIThread.RunJobs(); return (window, tree, root); } [AvaloniaTest] public void Flattens_Visible_Nodes_And_Expand_Collapse_Updates_Row_Count() { var (_, tree, root) = Host(); var b = (TestNode)root.Children[1]; tree.ItemCount.Should().Be(3, "A, B (collapsed), C are the visible top-level rows"); b.IsExpanded = true; Dispatcher.UIThread.RunJobs(); tree.ItemCount.Should().Be(4, "expanding B reveals B1"); b.IsExpanded = false; Dispatcher.UIThread.RunJobs(); tree.ItemCount.Should().Be(3, "collapsing B hides B1 again"); } [AvaloniaTest] public void Shift_Down_Extends_And_Shift_Up_Shrinks_From_The_Anchor() { // The whole reason for the rewrite: native ListBox anchor selection both extends AND // shrinks a shift-range -- the thing ProDataGrid's additive SetRowsSelection could not do. var nodes = Enumerable.Range(0, 5).Select(i => new TestNode(((char)('A' + i)).ToString())).ToArray(); var root = new TestNode("root", nodes); var tree = new SharpTreeView { ShowRoot = false, Root = root }; var window = new Window { Content = tree, Width = 300, Height = 400 }; window.Show(); Dispatcher.UIThread.RunJobs(); tree.SelectedItem = nodes[0]; Dispatcher.UIThread.RunJobs(); (tree.ContainerFromItem(nodes[0]))?.Focus(); Dispatcher.UIThread.RunJobs(); window.KeyPress(Key.Down, RawInputModifiers.Shift, PhysicalKey.ArrowDown, null); Dispatcher.UIThread.RunJobs(); window.KeyPress(Key.Down, RawInputModifiers.Shift, PhysicalKey.ArrowDown, null); Dispatcher.UIThread.RunJobs(); tree.SelectedItems!.Cast().Should() .BeEquivalentTo(new[] { nodes[0], nodes[1], nodes[2] }, "Shift+Down twice extends A..C"); window.KeyPress(Key.Up, RawInputModifiers.Shift, PhysicalKey.ArrowUp, null); Dispatcher.UIThread.RunJobs(); tree.SelectedItems!.Cast().Should() .BeEquivalentTo(new[] { nodes[0], nodes[1] }, "Shift+Up shrinks the range back toward the anchor (A,B)"); } [AvaloniaTest] public void Type_Ahead_Jumps_To_The_Visible_Node_Matching_The_Typed_Prefix() { var apple = new TestNode("Apple"); var banana = new TestNode("Banana"); var cherry = new TestNode("Cherry"); var cranberry = new TestNode("Cranberry"); var root = new TestNode("root", apple, banana, cherry, cranberry); var tree = new SharpTreeView { ShowRoot = false, Root = root }; var window = new Window { Content = tree, Width = 300, Height = 400 }; window.Show(); Dispatcher.UIThread.RunJobs(); // "cra": 'c' lands on Cherry, 'r' has to search forward to Cranberry, 'a' stays. foreach (char ch in "cra") { tree.RaiseEvent(new TextInputEventArgs { RoutedEvent = InputElement.TextInputEvent, Text = ch.ToString(), Source = tree, }); Dispatcher.UIThread.RunJobs(); } tree.SelectedItem.Should().BeSameAs(cranberry, "type-ahead refines forward to the Cr* match"); } [AvaloniaTest] public void Selecting_A_Row_Sets_The_Node_IsSelected() { var (_, tree, root) = Host(); var a = (TestNode)root.Children[0]; var c = (TestNode)root.Children[2]; tree.SelectedItem = a; Dispatcher.UIThread.RunJobs(); a.IsSelected.Should().BeTrue(); tree.SelectedItem = c; Dispatcher.UIThread.RunJobs(); c.IsSelected.Should().BeTrue(); a.IsSelected.Should().BeFalse("moving the selection clears the old node's flag"); } /// The number of rows the flattener should expose for a tree rooted in /// when the root itself is not shown. static int VisibleRowCount(SharpTreeNode root) { int count = 0; void Walk(SharpTreeNode node) { foreach (SharpTreeNode child in node.Children) { if (child.IsHidden) continue; count++; if (child.IsExpanded) Walk(child); } } Walk(root); return count; } static void AssertEveryRowResolves(SharpTreeView tree, SharpTreeNode root, string because) { tree.UpdateLayout(); Dispatcher.UIThread.RunJobs(); int expected = VisibleRowCount(root); tree.ItemCount.Should().Be(expected, because); for (int i = 0; i < expected; i++) tree.ItemsView[i].Should().NotBeNull($"row {i} must resolve after {because}"); } /// /// Collapsing and removing nodes above a scrolled viewport shrinks the flattened list under /// the virtualizing panel's realized index range. The panel must not be left indexing rows /// that no longer exist. /// [AvaloniaTest] public void Shrinking_The_Tree_Above_A_Scrolled_Viewport_Keeps_Every_Row_Resolvable() { var groups = Enumerable.Range(0, 200) .Select(i => new TestNode($"g{i}", Enumerable.Range(0, 5) .Select(j => new TestNode($"g{i}_{j}")).ToArray())) .ToArray(); var root = new TestNode("root", groups); var tree = new SharpTreeView { ShowRoot = false, Root = root }; var window = new Window { Content = tree, Width = 300, Height = 400 }; window.Show(); Dispatcher.UIThread.RunJobs(); foreach (var group in groups) group.IsExpanded = true; AssertEveryRowResolves(tree, root, "expanding every group"); tree.GetRealizedContainers().Count().Should() .BeLessThan(tree.ItemCount, "the panel must be virtualizing, or this test proves nothing"); // Park the realized range far from index 0, with a live selection inside it. tree.SelectedItem = groups[^1].Children[^1]; tree.ScrollIntoView(tree.ItemCount - 1); AssertEveryRowResolves(tree, root, "scrolling to the last row"); for (int i = 0; i < 190; i++) groups[i].IsExpanded = false; AssertEveryRowResolves(tree, root, "collapsing 190 groups above the viewport"); for (int i = 0; i < 150; i++) root.Children.RemoveAt(0); AssertEveryRowResolves(tree, root, "removing 150 groups above the viewport"); tree.ScrollIntoView(0); AssertEveryRowResolves(tree, root, "scrolling back to the top"); } /// /// A lazily loaded node replaces its placeholder with real children while the tree is /// scrolled: the row count grows and shrinks in the same gesture. /// [AvaloniaTest] public void Lazy_Loading_Under_A_Scrolled_Viewport_Keeps_Every_Row_Resolvable() { var lazy = Enumerable.Range(0, 100).Select(i => new LazyNode($"l{i}", 7)).ToArray(); var root = new TestNode("root"); foreach (var node in lazy) root.Children.Add(node); var tree = new SharpTreeView { ShowRoot = false, Root = root }; var window = new Window { Content = tree, Width = 300, Height = 400 }; window.Show(); Dispatcher.UIThread.RunJobs(); AssertEveryRowResolves(tree, root, "the initial collapsed list"); tree.ScrollIntoView(tree.ItemCount - 1); AssertEveryRowResolves(tree, root, "scrolling to the last row"); foreach (var node in lazy) { node.IsExpanded = true; AssertEveryRowResolves(tree, root, $"lazily expanding {node.Text}"); } foreach (var node in lazy) node.ReloadChildren(); AssertEveryRowResolves(tree, root, "reloading every lazy subtree in place"); } sealed class LazyNode : SharpTreeNode { readonly string text; readonly int childCount; public LazyNode(string text, int childCount) { this.text = text; this.childCount = childCount; LazyLoading = true; } public override object Text => text; protected override void LoadChildren() { for (int i = 0; i < childCount; i++) Children.Add(new TestNode($"{text}_{i}")); } } [AvaloniaTest] public void Moving_An_Expanded_Node_Reorders_The_Rendered_Rows() { var (_, tree, root) = Host(); var b = (TestNode)root.Children[1]; b.IsExpanded = true; Dispatcher.UIThread.RunJobs(); RenderedRows(tree).Should().Equal("A", "B", "B1", "C"); // B carries B1 with it: the run [B, B1] moves past C. root.Children.Move(1, 2); Dispatcher.UIThread.RunJobs(); RenderedRows(tree).Should().Equal("A", "C", "B", "B1"); } /// Builds top-level rows in a viewport too short to show /// them all, with one expandable node, so an expansion has somewhere to scroll. static (Window window, SharpTreeView tree, ScrollViewer scrollViewer, TestNode[] nodes) ShortViewport( int rootCount, int expandableIndex, int childCount) { var nodes = Enumerable.Range(0, rootCount) .Select(i => i == expandableIndex ? new TestNode($"N{i}", Enumerable.Range(0, childCount) .Select(c => new TestNode($"N{i}.{c}")).ToArray()) : new TestNode($"N{i}")) .ToArray(); var root = new TestNode("root", nodes); var tree = new SharpTreeView { ShowRoot = false, Root = root }; var window = new Window { Content = tree, Width = 300, Height = 180 }; window.Show(); Dispatcher.UIThread.RunJobs(); return (window, tree, tree.GetVisualDescendants().OfType().First(), nodes); } /// Expands a node the way a user does, with Right on its focused row. static void PressRightOn(Window window, SharpTreeView tree, SharpTreeNode node) { tree.SelectedItem = node; Dispatcher.UIThread.RunJobs(); tree.ContainerFromItem(node)?.Focus(); Dispatcher.UIThread.RunJobs(); window.KeyPress(Key.Right, RawInputModifiers.None, PhysicalKey.ArrowRight, null); Dispatcher.UIThread.RunJobs(); } [AvaloniaTest] public void Expanding_A_Node_Scrolls_Children_That_Do_Not_Fit_Into_View() { var (window, tree, scrollViewer, nodes) = ShortViewport(rootCount: 15, expandableIndex: 5, childCount: 5); var parent = nodes[5]; scrollViewer.Offset.Y.Should().Be(0, "nothing has moved the viewport yet"); PressRightOn(window, tree, parent); tree.IsNodeFullyVisible(parent.Children[^1]) .Should().BeTrue("the expansion reveals children below the viewport, so the view scrolls to show them"); tree.IsNodeFullyVisible(parent) .Should().BeTrue("the scroll is bounded by the expanded node: it never leaves the viewport"); } [AvaloniaTest] public void Expanding_A_Node_Whose_Children_Already_Fit_Leaves_The_Viewport_Alone() { var (window, tree, scrollViewer, nodes) = ShortViewport(rootCount: 15, expandableIndex: 1, childCount: 2); var parent = nodes[1]; PressRightOn(window, tree, parent); scrollViewer.Offset.Y.Should().Be(0, "the children fit below the node, so there is nothing to scroll to"); tree.IsNodeFullyVisible(parent.Children[^1]).Should().BeTrue(); } [AvaloniaTest] public void Expanding_A_Node_With_More_Children_Than_Fit_Keeps_The_Node_Visible() { var (window, tree, _, nodes) = ShortViewport(rootCount: 15, expandableIndex: 5, childCount: 30); var parent = nodes[5]; PressRightOn(window, tree, parent); tree.IsNodeFullyVisible(parent) .Should().BeTrue("showing every child would push the node off the top, so the scroll stops at the node"); tree.IsNodeFullyVisible(parent.Children[0]) .Should().BeTrue("as many children as fit are shown below it"); } [AvaloniaTest] public async Task Clicking_The_Expander_Scrolls_The_Children_Into_View() { // The mouse path never passes through SharpTreeView: the row template's toggle writes // IsExpanded straight to the node, so the reveal hangs off the toggle's Click. var (window, tree, _, nodes) = ShortViewport(rootCount: 15, expandableIndex: 5, childCount: 5); var parent = nodes[5]; await window.ClickAsync(() => tree.ContainerFromItem(parent)?.GetVisualDescendants() .OfType().FirstOrDefault(b => b.Name == "PART_Expander")); Dispatcher.UIThread.RunJobs(); parent.IsExpanded.Should().BeTrue("precondition: the click toggled the node open"); tree.IsNodeFullyVisible(parent.Children[^1]) .Should().BeTrue("a click on the expander reveals the children, just as the keyboard does"); } [AvaloniaTest] public void Expanding_A_Node_In_Code_Does_Not_Move_The_Viewport() { // Revealing a node expands its ancestors first (ScrollIntoNodeView, TreeSelectionBinder) // and positions the viewport itself afterwards; a scroll per ancestor would fight that. var (_, _, scrollViewer, nodes) = ShortViewport(rootCount: 15, expandableIndex: 5, childCount: 5); nodes[5].IsExpanded = true; Dispatcher.UIThread.RunJobs(); scrollViewer.Offset.Y.Should().Be(0, "only a user gesture reveals the children"); } static List RenderedRows(SharpTreeView tree) { var rows = new List(); for (int i = 0; i < tree.ItemCount; i++) { var container = tree.ContainerFromIndex(i); rows.Add((container?.DataContext as SharpTreeNode)?.Text?.ToString() ?? ""); } return rows; } }