fix(dock): enforce tree invariants, sync selection, and fix AOT warnings

This commit is contained in:
2026-03-28 12:38:29 +09:00
parent 8ba976b0ba
commit 4052ffb854
4 changed files with 216 additions and 4 deletions

View File

@@ -7,12 +7,45 @@ namespace Ghost.Editor.Core.Controls.Internal.Docking;
public partial class DockGroupNode : DockNode public partial class DockGroupNode : DockNode
{ {
[ObservableProperty] [ObservableProperty]
private Orientation _orientation = Orientation.Horizontal; public partial Orientation Orientation { get; set; }
public DockGroupNode()
{
Orientation = Orientation.Horizontal;
}
public ObservableCollection<DockNode> Children { get; } = new(); public ObservableCollection<DockNode> Children { get; } = new();
public void AddChild(DockNode node) public void AddChild(DockNode node)
{ {
ArgumentNullException.ThrowIfNull(node);
if (node == this)
{
throw new InvalidOperationException("Cannot add a node to itself.");
}
if (Children.Contains(node))
{
return;
}
// Check for cycles
var current = this.Parent;
while (current != null)
{
if (current == node)
{
throw new InvalidOperationException("Cannot add an ancestor as a child (cycle detected).");
}
current = current.Parent;
}
if (node.Parent != null && node.Parent != this)
{
node.Parent.RemoveChild(node);
}
node.Parent = this; node.Parent = this;
Children.Add(node); Children.Add(node);
} }

View File

@@ -5,5 +5,5 @@ namespace Ghost.Editor.Core.Controls.Internal.Docking;
public abstract partial class DockNode : ObservableObject public abstract partial class DockNode : ObservableObject
{ {
[ObservableProperty] [ObservableProperty]
private DockGroupNode? _parent; public partial DockGroupNode? Parent { get; set; }
} }

View File

@@ -8,8 +8,64 @@ public partial class DockPanelNode : DockNode
public ObservableCollection<object> Items { get; } = new(); public ObservableCollection<object> Items { get; } = new();
[ObservableProperty] [ObservableProperty]
private int _selectedIndex = -1; public partial int SelectedIndex { get; set; }
[ObservableProperty] [ObservableProperty]
private object? _selectedItem; public partial object? SelectedItem { get; set; }
public DockPanelNode()
{
SelectedIndex = -1;
Items.CollectionChanged += OnItemsCollectionChanged;
}
private void OnItemsCollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
if (Items.Count == 0)
{
SelectedIndex = -1;
SelectedItem = null;
}
else if (SelectedIndex >= Items.Count)
{
SelectedIndex = Items.Count - 1;
}
else if (SelectedIndex == -1 && Items.Count > 0)
{
SelectedIndex = 0;
}
}
partial void OnSelectedIndexChanged(int value)
{
if (value >= 0 && value < Items.Count)
{
SelectedItem = Items[value];
}
else if (value == -1)
{
SelectedItem = null;
}
else
{
// Clamp or reset if out of bounds
SelectedIndex = Items.Count > 0 ? 0 : -1;
}
}
partial void OnSelectedItemChanged(object? value)
{
if (value == null)
{
SelectedIndex = -1;
}
else
{
int index = Items.IndexOf(value);
if (index != -1)
{
SelectedIndex = index;
}
}
}
} }

View File

@@ -0,0 +1,123 @@
using Ghost.Editor.Core.Controls.Internal.Docking;
using Microsoft.UI.Xaml.Controls;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Ghost.UnitTest;
[TestClass]
public class DockingModelTest
{
[TestMethod]
public void TestAddChild_SetsParent()
{
var group = new DockGroupNode();
var child = new DockPanelNode();
group.AddChild(child);
Assert.AreEqual(group, child.Parent);
Assert.IsTrue(group.Children.Contains(child));
}
[TestMethod]
public void TestAddChild_RemovesFromOldParent()
{
var group1 = new DockGroupNode();
var group2 = new DockGroupNode();
var child = new DockPanelNode();
group1.AddChild(child);
group2.AddChild(child);
Assert.AreEqual(group2, child.Parent);
Assert.IsFalse(group1.Children.Contains(child));
Assert.IsTrue(group2.Children.Contains(child));
}
[TestMethod]
public void TestAddChild_PreventsCycle()
{
var group1 = new DockGroupNode();
var group2 = new DockGroupNode();
group1.AddChild(group2);
bool thrown = false;
try
{
group2.AddChild(group1);
}
catch (InvalidOperationException)
{
thrown = true;
}
Assert.IsTrue(thrown);
}
[TestMethod]
public void TestRemoveChild_ClearsParent()
{
var group = new DockGroupNode();
var child = new DockPanelNode();
group.AddChild(child);
group.RemoveChild(child);
Assert.IsNull(child.Parent);
Assert.IsFalse(group.Children.Contains(child));
}
[TestMethod]
public void TestPanel_SelectionSync_IndexToItem()
{
var panel = new DockPanelNode();
var item1 = new object();
var item2 = new object();
panel.Items.Add(item1);
panel.Items.Add(item2);
panel.SelectedIndex = 1;
Assert.AreEqual(item2, panel.SelectedItem);
panel.SelectedIndex = 0;
Assert.AreEqual(item1, panel.SelectedItem);
panel.SelectedIndex = -1;
Assert.IsNull(panel.SelectedItem);
}
[TestMethod]
public void TestPanel_SelectionSync_ItemToIndex()
{
var panel = new DockPanelNode();
var item1 = new object();
var item2 = new object();
panel.Items.Add(item1);
panel.Items.Add(item2);
panel.SelectedItem = item2;
Assert.AreEqual(1, panel.SelectedIndex);
panel.SelectedItem = item1;
Assert.AreEqual(0, panel.SelectedIndex);
panel.SelectedItem = null;
Assert.AreEqual(-1, panel.SelectedIndex);
}
[TestMethod]
public void TestPanel_CollectionChanged_UpdatesSelection()
{
var panel = new DockPanelNode();
var item1 = new object();
panel.Items.Add(item1);
Assert.AreEqual(0, panel.SelectedIndex);
Assert.AreEqual(item1, panel.SelectedItem);
panel.Items.Remove(item1);
Assert.AreEqual(-1, panel.SelectedIndex);
Assert.IsNull(panel.SelectedItem);
}
}