mirror of
https://github.com/AtomUI/AtomUI.git
synced 2026-10-19 15:20:25 +08:00
413 lines
13 KiB
C#
413 lines
13 KiB
C#
using System.Reflection;
|
|
using System.Runtime.CompilerServices;
|
|
using AtomUI.Controls;
|
|
using Avalonia;
|
|
using Avalonia.Collections;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Data;
|
|
using Avalonia.Markup.Xaml.MarkupExtensions;
|
|
using Avalonia.Threading;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using AtomTreeViewItem = AtomUI.Desktop.Controls.TreeViewItem;
|
|
using AvaloniaWindow = Avalonia.Controls.Window;
|
|
|
|
namespace AtomUI.Desktop.Controls.Tests.TreeView;
|
|
|
|
public class TreeViewBindableNodeTests
|
|
{
|
|
private const string HeaderResourceKey = "TreeViewBindableNodeTests.Header";
|
|
|
|
static TreeViewBindableNodeTests()
|
|
{
|
|
AvaloniaTestApp.EnsureInitialized();
|
|
}
|
|
|
|
[Fact]
|
|
public void BindableTreeItemNode_Children_Update_ParentNode()
|
|
{
|
|
var parent = new BindableTreeItemNode { Header = "Parent" };
|
|
var child = new BindableTreeItemNode { Header = "Child" };
|
|
|
|
parent.Children.Add(child);
|
|
|
|
child.ParentNode.ShouldBeSameAs(parent);
|
|
|
|
parent.Children.Remove(child);
|
|
|
|
child.ParentNode.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void BindableTreeItemNode_Children_Clear_Detaches_Previous_ParentNode()
|
|
{
|
|
var parent = new BindableTreeItemNode { Header = "Parent" };
|
|
var child = new BindableTreeItemNode { Header = "Child" };
|
|
parent.Children.Add(child);
|
|
|
|
parent.Children.Clear();
|
|
|
|
child.ParentNode.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void BindableTreeItemNode_Property_Changes_Update_Realized_Container()
|
|
{
|
|
var node = new BindableTreeItemNode
|
|
{
|
|
Header = "Root",
|
|
ItemKey = "root",
|
|
IsChecked = false,
|
|
IsEnabled = true,
|
|
IsExpanded = false,
|
|
IsIndicatorEnabled = true,
|
|
IsLeaf = false,
|
|
Value = "root-value"
|
|
};
|
|
var treeView = new AtomUI.Desktop.Controls.TreeView
|
|
{
|
|
ToggleType = ItemToggleType.CheckBox,
|
|
IsMotionEnabled = false,
|
|
IsShowEmptyIndicator = false,
|
|
ItemsSource = new[] { node }
|
|
};
|
|
|
|
ShowInWindow(treeView, () =>
|
|
{
|
|
var container = (AtomTreeViewItem)treeView.ContainerFromItem(node)!;
|
|
|
|
node.ItemKey = "updated-root";
|
|
node.IsChecked = true;
|
|
node.IsEnabled = false;
|
|
node.IsExpanded = true;
|
|
node.IsIndicatorEnabled = false;
|
|
node.IsLeaf = true;
|
|
node.Value = "updated-value";
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
container.Header.ShouldBeSameAs(node);
|
|
container.ItemKey.ShouldBe("updated-root");
|
|
container.IsChecked.ShouldBe(true);
|
|
container.IsEnabled.ShouldBeFalse();
|
|
container.IsExpanded.ShouldBeTrue();
|
|
container.IsIndicatorEnabled.ShouldBeFalse();
|
|
container.IsLeaf.ShouldBeTrue();
|
|
container.Value.ShouldBe("updated-value");
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Container_State_Changes_Write_Back_To_BindableTreeItemNode()
|
|
{
|
|
var node = new BindableTreeItemNode
|
|
{
|
|
Header = "Root",
|
|
IsChecked = false,
|
|
IsExpanded = false,
|
|
IsSelected = false
|
|
};
|
|
var treeView = new AtomUI.Desktop.Controls.TreeView
|
|
{
|
|
ToggleType = ItemToggleType.CheckBox,
|
|
IsMotionEnabled = false,
|
|
IsShowEmptyIndicator = false,
|
|
ItemsSource = new[] { node }
|
|
};
|
|
|
|
ShowInWindow(treeView, () =>
|
|
{
|
|
var container = (AtomTreeViewItem)treeView.ContainerFromItem(node)!;
|
|
|
|
container.SetCurrentValue(AtomTreeViewItem.IsCheckedProperty, true);
|
|
container.SetCurrentValue(AtomTreeViewItem.IsExpandedProperty, true);
|
|
container.SetCurrentValue(AtomTreeViewItem.IsSelectedProperty, true);
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
node.IsChecked.ShouldBe(true);
|
|
node.IsExpanded.ShouldBeTrue();
|
|
node.IsSelected.ShouldBeTrue();
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void BindableNode_VisualTree_Reattach_Continues_To_Sync_Node_State()
|
|
{
|
|
var child = new BindableTreeItemNode
|
|
{
|
|
Header = "Child"
|
|
};
|
|
var parent = new BindableTreeItemNode
|
|
{
|
|
Header = "Parent",
|
|
IsExpanded = true,
|
|
Children = [child]
|
|
};
|
|
var treeView = new AtomUI.Desktop.Controls.TreeView
|
|
{
|
|
ToggleType = ItemToggleType.CheckBox,
|
|
IsMotionEnabled = false,
|
|
IsShowEmptyIndicator = false,
|
|
ItemsSource = new[] { parent }
|
|
};
|
|
var window = new AvaloniaWindow
|
|
{
|
|
Width = 420,
|
|
Height = 320,
|
|
Content = treeView
|
|
};
|
|
|
|
try
|
|
{
|
|
window.Show();
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
var parentContainer = (AtomTreeViewItem)treeView.ContainerFromItem(parent)!;
|
|
parentContainer.ContainerFromItem(child).ShouldBeAssignableTo<AtomTreeViewItem>();
|
|
|
|
window.Content = null;
|
|
Dispatcher.UIThread.RunJobs();
|
|
window.Content = treeView;
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
var reopenedParentContainer = (AtomTreeViewItem)treeView.ContainerFromItem(parent)!;
|
|
var reopenedChildContainer = (AtomTreeViewItem)reopenedParentContainer.ContainerFromItem(child)!;
|
|
reopenedParentContainer.ShouldBeSameAs(parentContainer);
|
|
reopenedChildContainer.SetCurrentValue(AtomTreeViewItem.IsCheckedProperty, true);
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
child.IsChecked.ShouldBe(true);
|
|
}
|
|
finally
|
|
{
|
|
window.Content = null;
|
|
window.Close();
|
|
Dispatcher.UIThread.RunJobs();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Dynamic_Resource_Header_Uses_Owner_TreeView_Resources()
|
|
{
|
|
var resourceKey = CreateResourceKey();
|
|
Application.Current!.Resources[resourceKey] = "Application header";
|
|
|
|
var node = new BindableTreeItemNode();
|
|
BindDynamicResource(node, BindableTreeItemNode.HeaderProperty, resourceKey, Application.Current);
|
|
|
|
var treeView = new AtomUI.Desktop.Controls.TreeView
|
|
{
|
|
IsShowEmptyIndicator = false,
|
|
ItemsSource = new[] { node }
|
|
};
|
|
treeView.Resources[resourceKey] = "TreeView header";
|
|
|
|
ShowInWindow(treeView, () =>
|
|
{
|
|
node.Header.ShouldBe("TreeView header");
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Removed_BindableTreeItemNode_Without_DynamicResource_Is_Not_Rooted()
|
|
{
|
|
var nodeReference = CreateRemovedPlainNodeReference();
|
|
|
|
CollectGarbage();
|
|
|
|
nodeReference.IsAlive.ShouldBeFalse(
|
|
"TreeView container recycling must not keep a removed BindableTreeItemNode alive");
|
|
}
|
|
|
|
[Fact]
|
|
public void Dynamic_Resource_Header_Does_Not_Root_Unowned_BindableTreeItemNode()
|
|
{
|
|
var nodeReference = CreateUnownedNodeReference(CreateResourceKey());
|
|
|
|
CollectGarbage();
|
|
|
|
nodeReference.IsAlive.ShouldBeFalse(
|
|
"BindableTreeItemNode dynamic resources must not be rooted when no owner is attached");
|
|
}
|
|
|
|
[Fact]
|
|
public void Dynamic_Resource_Header_Does_Not_Root_Removed_BindableTreeItemNode()
|
|
{
|
|
var nodeReference = CreateRemovedNodeReference(CreateResourceKey());
|
|
|
|
CollectGarbage();
|
|
|
|
nodeReference.IsAlive.ShouldBeFalse(
|
|
"BindableTreeItemNode dynamic resources must not keep a removed node alive through Application.ResourcesChanged");
|
|
}
|
|
|
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
|
private static WeakReference CreateRemovedPlainNodeReference()
|
|
{
|
|
BindableTreeItemNode? node = new BindableTreeItemNode { Header = "Plain header" };
|
|
AvaloniaList<BindableTreeItemNode>? nodes = new AvaloniaList<BindableTreeItemNode> { node };
|
|
AtomUI.Desktop.Controls.TreeView? treeView = new AtomUI.Desktop.Controls.TreeView
|
|
{
|
|
IsShowEmptyIndicator = false,
|
|
ItemsSource = nodes
|
|
};
|
|
|
|
var window = new AvaloniaWindow
|
|
{
|
|
Width = 420,
|
|
Height = 320,
|
|
Content = treeView
|
|
};
|
|
|
|
try
|
|
{
|
|
window.Show();
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
nodes.Remove(node);
|
|
RunDispatcherJobsUntil(() => treeView.ContainerFromItem(node) is null);
|
|
|
|
var nodeReference = new WeakReference(node);
|
|
treeView.ItemsSource = null;
|
|
node = null;
|
|
nodes = null;
|
|
treeView = null;
|
|
return nodeReference;
|
|
}
|
|
finally
|
|
{
|
|
window.Content = null;
|
|
window.Close();
|
|
Dispatcher.UIThread.RunJobs();
|
|
}
|
|
}
|
|
|
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
|
private static WeakReference CreateUnownedNodeReference(string resourceKey)
|
|
{
|
|
Application.Current!.Resources[resourceKey] = "Application header";
|
|
|
|
var node = new BindableTreeItemNode();
|
|
BindDynamicResource(node, BindableTreeItemNode.HeaderProperty, resourceKey, Application.Current);
|
|
|
|
node.Header.ShouldBe("Application header");
|
|
return new WeakReference(node);
|
|
}
|
|
|
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
|
private static WeakReference CreateRemovedNodeReference(string resourceKey)
|
|
{
|
|
Application.Current!.Resources[resourceKey] = "Application header";
|
|
|
|
BindableTreeItemNode? node = new BindableTreeItemNode();
|
|
BindDynamicResource(node, BindableTreeItemNode.HeaderProperty, resourceKey, Application.Current);
|
|
|
|
AvaloniaList<BindableTreeItemNode>? nodes = new AvaloniaList<BindableTreeItemNode> { node };
|
|
AtomUI.Desktop.Controls.TreeView? treeView = new AtomUI.Desktop.Controls.TreeView
|
|
{
|
|
IsShowEmptyIndicator = false,
|
|
ItemsSource = nodes
|
|
};
|
|
treeView.Resources[resourceKey] = "TreeView header";
|
|
|
|
var window = new AvaloniaWindow
|
|
{
|
|
Width = 420,
|
|
Height = 320,
|
|
Content = treeView
|
|
};
|
|
|
|
try
|
|
{
|
|
window.Show();
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
node.Header.ShouldBe("TreeView header");
|
|
nodes.Remove(node);
|
|
RunDispatcherJobsUntil(() => treeView.ContainerFromItem(node) is null);
|
|
node.Header.ShouldBe("Application header");
|
|
var nodeReference = new WeakReference(node);
|
|
treeView.ItemsSource = null;
|
|
node = null;
|
|
nodes = null;
|
|
treeView = null;
|
|
return nodeReference;
|
|
}
|
|
finally
|
|
{
|
|
window.Content = null;
|
|
window.Close();
|
|
Dispatcher.UIThread.RunJobs();
|
|
}
|
|
}
|
|
|
|
private static void ShowInWindow(Control content, Action assertion)
|
|
{
|
|
var window = new AvaloniaWindow
|
|
{
|
|
Width = 420,
|
|
Height = 320,
|
|
Content = content
|
|
};
|
|
|
|
try
|
|
{
|
|
window.Show();
|
|
Dispatcher.UIThread.RunJobs();
|
|
assertion();
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
}
|
|
|
|
private static string CreateResourceKey()
|
|
{
|
|
return $"{HeaderResourceKey}.{Guid.NewGuid():N}";
|
|
}
|
|
|
|
private static void BindDynamicResource(AvaloniaObject target, AvaloniaProperty property, object key, object? anchor)
|
|
{
|
|
const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic;
|
|
var bindMethod = typeof(AvaloniaObject).GetMethod(
|
|
"Bind",
|
|
flags,
|
|
binder: null,
|
|
types: [typeof(AvaloniaProperty), typeof(BindingBase), typeof(object)],
|
|
modifiers: null);
|
|
|
|
bindMethod.ShouldNotBeNull();
|
|
|
|
var extension = new DynamicResourceExtension(key);
|
|
var anchorField = typeof(DynamicResourceExtension).GetField("_anchor", flags);
|
|
anchorField.ShouldNotBeNull();
|
|
anchorField.SetValue(extension, anchor);
|
|
|
|
bindMethod.Invoke(target, [property, extension, anchor]);
|
|
}
|
|
|
|
private static void CollectGarbage()
|
|
{
|
|
for (var i = 0; i < 3; i++)
|
|
{
|
|
GC.Collect();
|
|
GC.WaitForPendingFinalizers();
|
|
GC.Collect();
|
|
}
|
|
|
|
Dispatcher.UIThread.RunJobs();
|
|
}
|
|
|
|
private static void RunDispatcherJobsUntil(Func<bool> condition, int maxPasses = 128)
|
|
{
|
|
for (var i = 0; i < maxPasses; i++)
|
|
{
|
|
Dispatcher.UIThread.RunJobs();
|
|
if (condition())
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|