mirror of
https://github.com/AtomUI/AtomUI.git
synced 2026-10-18 22:46:38 +08:00
453 lines
16 KiB
C#
453 lines
16 KiB
C#
using System.Collections.Specialized;
|
|
using System.Collections;
|
|
using System.Collections.ObjectModel;
|
|
using System.Reflection;
|
|
using AtomUI.Controls;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Controls.Templates;
|
|
using Shouldly;
|
|
using Xunit;
|
|
|
|
namespace AtomUI.Desktop.Controls.Tests.NavMenu;
|
|
|
|
public class NavMenuEntryModelTests
|
|
{
|
|
[Fact]
|
|
public void Children_Compatibility_View_Is_Created_Only_When_First_Accessed()
|
|
{
|
|
var node = new NavMenuNode();
|
|
var field = typeof(NavMenuNode).GetField("_children", BindingFlags.Instance | BindingFlags.NonPublic);
|
|
field.ShouldNotBeNull();
|
|
|
|
field.GetValue(node).ShouldBeNull();
|
|
_ = node.Entries;
|
|
field.GetValue(node).ShouldBeNull();
|
|
|
|
_ = node.Children;
|
|
field.GetValue(node).ShouldNotBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void NavMenuNode_Entries_Are_Canonical_And_Children_View_Is_Group_Transparent()
|
|
{
|
|
var directChild = new NavMenuNode { Header = "Direct" };
|
|
var groupedChild = new NavMenuNode { Header = "Grouped" };
|
|
var nestedGroup = new NavMenuGroup();
|
|
nestedGroup.Entries.Add(groupedChild);
|
|
var group = new NavMenuGroup();
|
|
group.Entries.Add(new NavMenuDivider());
|
|
group.Entries.Add(nestedGroup);
|
|
|
|
var owner = new NavMenuNode { Header = "Owner" };
|
|
owner.Entries.Add(directChild);
|
|
owner.Entries.Add(new NavMenuDivider());
|
|
owner.Entries.Add(group);
|
|
|
|
owner.Entries.ShouldBe([directChild, owner.Entries[1], group]);
|
|
owner.Children.ShouldBe([directChild, groupedChild]);
|
|
directChild.ParentNode.ShouldBeSameAs(owner);
|
|
groupedChild.ParentNode.ShouldBeSameAs(owner);
|
|
((INavMenuNode)owner).Entries.ShouldBeSameAs(owner.Entries);
|
|
}
|
|
|
|
[Fact]
|
|
public void Children_View_Writes_Through_To_The_Targets_Actual_Entry_Owner()
|
|
{
|
|
var groupedChild = new NavMenuNode { Header = "Grouped" };
|
|
var group = new NavMenuGroup();
|
|
group.Entries.Add(groupedChild);
|
|
var owner = new NavMenuNode();
|
|
owner.Entries.Add(new NavMenuDivider());
|
|
owner.Entries.Add(group);
|
|
|
|
var inserted = new NavMenuNode { Header = "Inserted" };
|
|
owner.Children.Insert(0, inserted);
|
|
|
|
group.Entries.ShouldBe([inserted, groupedChild]);
|
|
inserted.ParentNode.ShouldBeSameAs(owner);
|
|
|
|
var replacement = new NavMenuNode { Header = "Replacement" };
|
|
owner.Children[1] = replacement;
|
|
|
|
group.Entries.ShouldBe([inserted, replacement]);
|
|
groupedChild.ParentNode.ShouldBeNull();
|
|
replacement.ParentNode.ShouldBeSameAs(owner);
|
|
|
|
var appended = new NavMenuNode { Header = "Appended" };
|
|
owner.Children.Add(appended);
|
|
owner.Entries[^1].ShouldBeSameAs(appended);
|
|
|
|
owner.Children.Remove(inserted).ShouldBeTrue();
|
|
group.Entries.ShouldBe([replacement]);
|
|
|
|
owner.Children.Clear();
|
|
owner.Entries.ShouldBeEmpty();
|
|
replacement.ParentNode.ShouldBeNull();
|
|
appended.ParentNode.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void Children_View_Forwards_Pure_Node_Changes_And_Resets_For_Structural_Changes()
|
|
{
|
|
var owner = new NavMenuNode();
|
|
var changes = new List<NotifyCollectionChangedEventArgs>();
|
|
var notifier = owner.Children.ShouldBeAssignableTo<INotifyCollectionChanged>();
|
|
notifier.CollectionChanged += (_, args) => changes.Add(args);
|
|
|
|
var direct = new NavMenuNode();
|
|
owner.Entries.Add(direct);
|
|
|
|
changes.Count.ShouldBe(1);
|
|
changes[0].Action.ShouldBe(NotifyCollectionChangedAction.Add);
|
|
changes[0].NewItems!.Cast<INavMenuNode>().ShouldBe([direct]);
|
|
changes.Clear();
|
|
|
|
var group = new NavMenuGroup();
|
|
owner.Entries.Add(group);
|
|
|
|
changes.Single().Action.ShouldBe(NotifyCollectionChangedAction.Reset);
|
|
changes.Clear();
|
|
|
|
var grouped = new NavMenuNode();
|
|
group.Entries.Add(grouped);
|
|
|
|
changes.Single().Action.ShouldBe(NotifyCollectionChangedAction.Reset);
|
|
owner.Children.ShouldBe([direct, grouped]);
|
|
}
|
|
|
|
[Fact]
|
|
public void Entry_Collections_Reject_Indirect_Cycles_Before_Mutating_The_Graph()
|
|
{
|
|
var outer = new NavMenuGroup { Header = "Outer" };
|
|
var inner = new NavMenuGroup { Header = "Inner" };
|
|
outer.Entries.Add(inner);
|
|
|
|
var exception = Should.Throw<InvalidOperationException>(() => inner.Entries.Add(outer));
|
|
|
|
exception.Message.ShouldContain(nameof(NavMenuGroup));
|
|
inner.Entries.ShouldBeEmpty();
|
|
outer.Entries.ShouldBe([inner]);
|
|
}
|
|
|
|
[Fact]
|
|
public void Entry_Collections_Reject_A_NavMenuNode_Instance_That_Is_Already_Attached()
|
|
{
|
|
var child = new NavMenuNode();
|
|
var firstOwner = new NavMenuNode();
|
|
var secondOwner = new NavMenuGroup();
|
|
firstOwner.Entries.Add(child);
|
|
|
|
var sameOwnerException = Should.Throw<InvalidOperationException>(() => firstOwner.Entries.Add(child));
|
|
var otherOwnerException = Should.Throw<InvalidOperationException>(() => secondOwner.Entries.Add(child));
|
|
|
|
sameOwnerException.Message.ShouldContain(nameof(NavMenuNode));
|
|
otherOwnerException.Message.ShouldContain(nameof(NavMenuNode));
|
|
firstOwner.Entries.ShouldBe([child]);
|
|
secondOwner.Entries.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void Entry_Collections_Reject_A_NavMenuGroup_Instance_That_Is_Already_Attached()
|
|
{
|
|
var group = new NavMenuGroup();
|
|
var firstOwner = new NavMenuNode();
|
|
var secondOwner = new NavMenuNode();
|
|
firstOwner.Entries.Add(group);
|
|
|
|
var exception = Should.Throw<InvalidOperationException>(() => secondOwner.Entries.Add(group));
|
|
|
|
exception.Message.ShouldContain(nameof(NavMenuGroup));
|
|
firstOwner.Entries.ShouldBe([group]);
|
|
secondOwner.Entries.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void Stateful_Entries_Can_Be_Attached_To_A_New_Owner_After_Removal()
|
|
{
|
|
var child = new NavMenuNode();
|
|
var firstOwner = new NavMenuNode();
|
|
var secondOwner = new NavMenuGroup();
|
|
firstOwner.Entries.Add(child);
|
|
|
|
firstOwner.Entries.Remove(child).ShouldBeTrue();
|
|
secondOwner.Entries.Add(child);
|
|
|
|
firstOwner.Entries.ShouldBeEmpty();
|
|
secondOwner.Entries.ShouldBe([child]);
|
|
}
|
|
|
|
[Fact]
|
|
public void NavMenuDivider_Instance_Can_Be_Reused_Because_It_Has_No_Owned_State()
|
|
{
|
|
var divider = new NavMenuDivider();
|
|
var firstOwner = new NavMenuNode();
|
|
var secondOwner = new NavMenuGroup();
|
|
|
|
firstOwner.Entries.Add(divider);
|
|
firstOwner.Entries.Add(divider);
|
|
secondOwner.Entries.Add(divider);
|
|
|
|
firstOwner.Entries.ShouldBe([divider, divider]);
|
|
secondOwner.Entries.ShouldBe([divider]);
|
|
}
|
|
|
|
[Fact]
|
|
public void AddRange_Rejects_An_Owned_Entry_Without_Partially_Mutating_The_Target()
|
|
{
|
|
var alreadyOwned = new NavMenuNode();
|
|
var existingOwner = new NavMenuNode();
|
|
existingOwner.Entries.Add(alreadyOwned);
|
|
var fresh = new NavMenuNode();
|
|
var target = new NavMenuNode();
|
|
var targetEntries = target.Entries.ShouldBeOfType<NavMenuEntryCollection>();
|
|
|
|
Should.Throw<InvalidOperationException>(() => targetEntries.AddRange([fresh, alreadyOwned]));
|
|
|
|
target.Entries.ShouldBeEmpty();
|
|
var newOwner = new NavMenuGroup();
|
|
newOwner.Entries.Add(fresh);
|
|
newOwner.Entries.ShouldBe([fresh]);
|
|
}
|
|
|
|
[Fact]
|
|
public void AddRange_Commits_The_Whole_Batch_Before_Notifying_Observers()
|
|
{
|
|
var first = new NavMenuNode();
|
|
var second = new NavMenuNode();
|
|
var target = new NavMenuNode();
|
|
var targetEntries = target.Entries.ShouldBeOfType<NavMenuEntryCollection>();
|
|
var competingOwner = new NavMenuGroup();
|
|
InvalidOperationException? competingAttachException = null;
|
|
targetEntries.CollectionChanged += (_, _) =>
|
|
{
|
|
if (competingAttachException is null)
|
|
{
|
|
competingAttachException = Should.Throw<InvalidOperationException>(() =>
|
|
competingOwner.Entries.Add(second));
|
|
}
|
|
};
|
|
|
|
targetEntries.AddRange([first, second]);
|
|
|
|
target.Entries.ShouldBe([first, second]);
|
|
competingOwner.Entries.ShouldBeEmpty();
|
|
competingAttachException.ShouldNotBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void AddRange_Commits_Ownership_Before_Custom_Parent_Callbacks()
|
|
{
|
|
var second = new NavMenuNode();
|
|
var competingOwner = new NavMenuGroup();
|
|
InvalidOperationException? competingAttachException = null;
|
|
var first = new ReentrantLegacyNavMenuNode(parentNode =>
|
|
{
|
|
if (parentNode is not null)
|
|
{
|
|
competingAttachException = Should.Throw<InvalidOperationException>(() =>
|
|
competingOwner.Entries.Add(second));
|
|
}
|
|
});
|
|
var target = new NavMenuNode();
|
|
var targetEntries = target.Entries.ShouldBeOfType<NavMenuEntryCollection>();
|
|
|
|
targetEntries.AddRange([first, second]);
|
|
|
|
target.Entries.ShouldBe([first, second]);
|
|
competingOwner.Entries.ShouldBeEmpty();
|
|
competingAttachException.ShouldNotBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void Add_Commits_Custom_Descendant_Ownership_Before_Parent_Callbacks()
|
|
{
|
|
var descendant = new NavMenuNode();
|
|
var competingOwner = new NavMenuGroup();
|
|
InvalidOperationException? competingAttachException = null;
|
|
var wrapper = new ReentrantLegacyNavMenuNode(parentNode =>
|
|
{
|
|
if (parentNode is not null)
|
|
{
|
|
competingAttachException = Should.Throw<InvalidOperationException>(() =>
|
|
competingOwner.Entries.Add(descendant));
|
|
}
|
|
});
|
|
wrapper.MutableChildren.Add(descendant);
|
|
var target = new NavMenuNode();
|
|
|
|
target.Entries.Add(wrapper);
|
|
|
|
target.Entries.ShouldBe([wrapper]);
|
|
competingOwner.Entries.ShouldBeEmpty();
|
|
competingAttachException.ShouldNotBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void BuiltIn_Owners_Do_Not_Subscribe_To_Descendant_BuiltIn_Entry_Collections()
|
|
{
|
|
const int depth = 20;
|
|
var nodes = new List<NavMenuNode> { new() };
|
|
for (var index = 1; index < depth; index++)
|
|
{
|
|
var child = new NavMenuNode();
|
|
nodes[^1].Entries.Add(child);
|
|
nodes.Add(child);
|
|
}
|
|
|
|
nodes.Sum(node => GetNestedSourceSubscriptionCount(node.Entries)).ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public void Entry_Collection_Rejects_Custom_Wrappers_Sharing_A_BuiltIn_Descendant_Before_Root_Attachment()
|
|
{
|
|
var shared = new NavMenuNode();
|
|
var firstWrapper = new ObservableLegacyNavMenuNode();
|
|
var secondWrapper = new ObservableLegacyNavMenuNode();
|
|
firstWrapper.MutableChildren.Add(shared);
|
|
secondWrapper.MutableChildren.Add(shared);
|
|
var owner = new NavMenuNode();
|
|
owner.Entries.Add(firstWrapper);
|
|
|
|
var exception = Should.Throw<InvalidOperationException>(() => owner.Entries.Add(secondWrapper));
|
|
|
|
exception.Message.ShouldContain(nameof(NavMenuNode));
|
|
owner.Entries.ShouldBe([firstWrapper]);
|
|
}
|
|
|
|
[Fact]
|
|
public void Offline_Custom_Source_Acquires_And_Releases_BuiltIn_Descendant_Ownership()
|
|
{
|
|
var shared = new NavMenuNode();
|
|
var wrapper = new ObservableLegacyNavMenuNode();
|
|
var firstOwner = new NavMenuNode();
|
|
var secondOwner = new NavMenuGroup();
|
|
firstOwner.Entries.Add(wrapper);
|
|
|
|
wrapper.MutableChildren.Add(shared);
|
|
Should.Throw<InvalidOperationException>(() => secondOwner.Entries.Add(shared));
|
|
|
|
wrapper.MutableChildren.Remove(shared);
|
|
secondOwner.Entries.Add(shared);
|
|
|
|
secondOwner.Entries.ShouldBe([shared]);
|
|
}
|
|
|
|
[Fact]
|
|
public void Observable_Custom_Source_Rejects_A_Dynamic_Cycle_Back_To_Its_BuiltIn_Owner()
|
|
{
|
|
var owner = new NavMenuNode();
|
|
var wrapper = new ObservableLegacyNavMenuNode();
|
|
owner.Entries.Add(wrapper);
|
|
|
|
var exception = Should.Throw<InvalidOperationException>(() => wrapper.MutableChildren.Add(owner));
|
|
|
|
exception.Message.ShouldContain("cycle");
|
|
owner.TryGetStructuralOwner(out _).ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void Observable_Custom_Source_Rejects_A_Dynamic_Cycle_Back_To_A_BuiltIn_Ancestor()
|
|
{
|
|
var root = new NavMenuNode();
|
|
var child = new NavMenuNode();
|
|
var wrapper = new ObservableLegacyNavMenuNode();
|
|
root.Entries.Add(child);
|
|
child.Entries.Add(wrapper);
|
|
|
|
var exception = Should.Throw<InvalidOperationException>(() => wrapper.MutableChildren.Add(root));
|
|
|
|
exception.Message.ShouldContain("cycle");
|
|
root.TryGetStructuralOwner(out _).ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void Legacy_Custom_Node_Uses_Children_As_The_Default_Entry_Source()
|
|
{
|
|
var child = new LegacyNavMenuNode();
|
|
var owner = new LegacyNavMenuNode();
|
|
owner.MutableChildren.Add(child);
|
|
|
|
((INavMenuNode)owner).Entries.ShouldBe([child]);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuiltIn_Entry_And_Children_Collections_Satisfy_Avalonia12_ItemsSource_Contracts()
|
|
{
|
|
var node = new NavMenuNode();
|
|
|
|
node.Entries.ShouldBeAssignableTo<IList>();
|
|
node.Children.ShouldBeAssignableTo<IList>();
|
|
node.Entries.ShouldBeAssignableTo<INotifyCollectionChanged>();
|
|
node.Children.ShouldBeAssignableTo<INotifyCollectionChanged>();
|
|
}
|
|
|
|
private static int GetNestedSourceSubscriptionCount(IList<INavMenuEntry> entries)
|
|
{
|
|
var coordinatorField = typeof(NavMenuEntryCollection).GetField(
|
|
"_ownershipCoordinator",
|
|
BindingFlags.Instance | BindingFlags.NonPublic);
|
|
coordinatorField.ShouldNotBeNull();
|
|
var coordinator = coordinatorField!.GetValue(entries);
|
|
coordinator.ShouldNotBeNull();
|
|
|
|
var subscriptionsField = typeof(NavMenuEntryOwnershipCoordinator).GetField(
|
|
"_sourceSubscriptions",
|
|
BindingFlags.Instance | BindingFlags.NonPublic);
|
|
subscriptionsField.ShouldNotBeNull();
|
|
var subscriptions = subscriptionsField!.GetValue(coordinator).ShouldBeAssignableTo<IDictionary>();
|
|
return subscriptions!.Count;
|
|
}
|
|
|
|
private sealed class LegacyNavMenuNode : INavMenuNode
|
|
{
|
|
public object? Header => null;
|
|
public IDataTemplate? HeaderTemplate => null;
|
|
public EntityKey? ItemKey => null;
|
|
public PathIcon? Icon => null;
|
|
public bool IsEnabled => true;
|
|
public ITreeNode<INavMenuNode>? ParentNode { get; private set; }
|
|
public List<INavMenuNode> MutableChildren { get; } = [];
|
|
public IEnumerable<INavMenuNode> Children => MutableChildren;
|
|
|
|
public void UpdateParentNode(INavMenuNode? parentNode)
|
|
{
|
|
ParentNode = parentNode;
|
|
}
|
|
}
|
|
|
|
private sealed class ObservableLegacyNavMenuNode : INavMenuNode
|
|
{
|
|
public object? Header => null;
|
|
public IDataTemplate? HeaderTemplate => null;
|
|
public EntityKey? ItemKey => null;
|
|
public PathIcon? Icon => null;
|
|
public bool IsEnabled => true;
|
|
public ITreeNode<INavMenuNode>? ParentNode { get; private set; }
|
|
public ObservableCollection<INavMenuNode> MutableChildren { get; } = [];
|
|
public IEnumerable<INavMenuNode> Children => MutableChildren;
|
|
|
|
public void UpdateParentNode(INavMenuNode? parentNode)
|
|
{
|
|
ParentNode = parentNode;
|
|
}
|
|
}
|
|
|
|
private sealed class ReentrantLegacyNavMenuNode(Action<INavMenuNode?> parentNodeChanged) : INavMenuNode
|
|
{
|
|
public object? Header => null;
|
|
public IDataTemplate? HeaderTemplate => null;
|
|
public EntityKey? ItemKey => null;
|
|
public PathIcon? Icon => null;
|
|
public bool IsEnabled => true;
|
|
public ITreeNode<INavMenuNode>? ParentNode { get; private set; }
|
|
public ObservableCollection<INavMenuNode> MutableChildren { get; } = [];
|
|
public IEnumerable<INavMenuNode> Children => MutableChildren;
|
|
|
|
public void UpdateParentNode(INavMenuNode? parentNode)
|
|
{
|
|
ParentNode = parentNode;
|
|
parentNodeChanged(parentNode);
|
|
}
|
|
}
|
|
}
|