mirror of
https://github.com/AtomUI/AtomUI.git
synced 2026-10-19 06:58:57 +08:00
482 lines
19 KiB
C#
482 lines
19 KiB
C#
using System.Xml.Linq;
|
|
using AtomUI.Controls;
|
|
using AtomUI.Theme;
|
|
using AtomUI.Theme.Schema;
|
|
using Avalonia;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Controls.Presenters;
|
|
using Avalonia.Controls.Primitives;
|
|
using Avalonia.Media;
|
|
using Avalonia.Styling;
|
|
using Avalonia.Threading;
|
|
using Avalonia.VisualTree;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using AtomTreeView = AtomUI.Desktop.Controls.TreeView;
|
|
using AtomTreeViewItem = AtomUI.Desktop.Controls.TreeViewItem;
|
|
using AvaloniaWindow = Avalonia.Controls.Window;
|
|
|
|
namespace AtomUI.Desktop.Controls.Tests.TreeView;
|
|
|
|
public class TreeViewSemanticPartTests
|
|
{
|
|
private const string ItemClass = "semantic-item";
|
|
private const string ScopeHeaderClass = "semantic-scope-header";
|
|
private const string SwitcherClass = "semantic-item-switcher";
|
|
private const string IconClass = "semantic-item-icon";
|
|
private const string TitleClass = "semantic-item-title";
|
|
private const string IndicatorClass = "semantic-item-indicator";
|
|
private const string OwnerClass = "semantic-owner";
|
|
|
|
static TreeViewSemanticPartTests()
|
|
{
|
|
AvaloniaTestApp.EnsureInitialized();
|
|
}
|
|
|
|
[Fact]
|
|
public void Registered_Descriptors_Expose_Only_The_Approved_Parts()
|
|
{
|
|
var registry = Application.Current.ShouldNotBeNull()
|
|
.GetThemeManager().ShouldNotBeNull()
|
|
.SemanticParts;
|
|
|
|
// TreeView owns the root and its direct (top-level) node containers.
|
|
registry.TryGetControl(typeof(AtomTreeView), out var treeViewDescriptor).ShouldBeTrue();
|
|
treeViewDescriptor.ShouldNotBeNull();
|
|
treeViewDescriptor.Parts.Select(static part => part.Name)
|
|
.ShouldBe(["root", "item"]);
|
|
AssertRoot(treeViewDescriptor.Parts.Single(static part => part.Name == "root"), typeof(AtomTreeView));
|
|
AssertPart(treeViewDescriptor.Parts.Single(static part => part.Name == "item"),
|
|
ItemClass,
|
|
typeof(AtomTreeViewItem),
|
|
"> .semantic-item");
|
|
|
|
// TreeViewItem is a recursive nested owner: its own child containers and the
|
|
// switcher / icon / title content regions of each node.
|
|
registry.TryGetControl(typeof(AtomTreeViewItem), out var itemDescriptor).ShouldBeTrue();
|
|
itemDescriptor.ShouldNotBeNull();
|
|
itemDescriptor.Parts.Select(static part => part.Name)
|
|
.ShouldBe(
|
|
[
|
|
"root",
|
|
"item",
|
|
"itemIcon",
|
|
"itemIndicator",
|
|
"itemSwitcher",
|
|
"itemTitle"
|
|
]);
|
|
AssertRoot(itemDescriptor.Parts.Single(static part => part.Name == "root"), typeof(AtomTreeViewItem));
|
|
AssertPart(itemDescriptor.Parts.Single(static part => part.Name == "item"),
|
|
ItemClass,
|
|
typeof(AtomTreeViewItem),
|
|
"> .semantic-item");
|
|
AssertPart(itemDescriptor.Parts.Single(static part => part.Name == "itemSwitcher"),
|
|
SwitcherClass,
|
|
typeof(ToggleButton),
|
|
"/template/ .semantic-scope-header /template/ .semantic-item-switcher");
|
|
AssertPart(itemDescriptor.Parts.Single(static part => part.Name == "itemIcon"),
|
|
IconClass,
|
|
typeof(IconPresenter),
|
|
"/template/ .semantic-scope-header /template/ .semantic-item-icon");
|
|
AssertPart(itemDescriptor.Parts.Single(static part => part.Name == "itemTitle"),
|
|
TitleClass,
|
|
typeof(ContentPresenter),
|
|
"/template/ .semantic-scope-header /template/ .semantic-item-title");
|
|
AssertPart(itemDescriptor.Parts.Single(static part => part.Name == "itemIndicator"),
|
|
IndicatorClass,
|
|
typeof(ToggleButton),
|
|
"/template/ .semantic-scope-header /template/ .semantic-item-indicator");
|
|
|
|
registry.TryGetControl(typeof(AtomUI.Desktop.Controls.FloatableTreeView), out _).ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void Templates_Implement_Only_The_Approved_Static_Markers()
|
|
{
|
|
AssertThemeMarkers(
|
|
"src/AtomUI.Desktop.Controls/TreeView/Themes/TreeViewTheme.axaml",
|
|
Array.Empty<string>());
|
|
AssertThemeMarkers(
|
|
"src/AtomUI.Desktop.Controls/TreeView/Themes/TreeViewItemTheme.axaml",
|
|
[
|
|
"semantic-scope-header:TreeViewItemHeader"
|
|
]);
|
|
AssertThemeMarkers(
|
|
"src/AtomUI.Desktop.Controls/TreeView/Themes/TreeViewItemHeaderTheme.axaml",
|
|
[
|
|
"semantic-item-icon:IconPresenter",
|
|
"semantic-item-indicator:CheckBox",
|
|
"semantic-item-indicator:RadioButton",
|
|
"semantic-item-switcher:NodeSwitcherButton",
|
|
"semantic-item-title:ContentPresenter"
|
|
]);
|
|
AssertThemeMarkers(
|
|
"src/AtomUI.Desktop.Controls/TreeView/Themes/NodeSwitcherButtonTheme.axaml",
|
|
Array.Empty<string>());
|
|
}
|
|
|
|
[Fact]
|
|
public void Expanded_Nodes_Produce_The_Full_Marker_Set_Per_Container()
|
|
{
|
|
var treeView = CreateThreeNodeTree();
|
|
|
|
ShowInWindow(treeView, () =>
|
|
{
|
|
AssertMarkerCounts(treeView, 3);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Empty_Items_Produce_No_Item_Markers()
|
|
{
|
|
var treeView = new AtomTreeView
|
|
{
|
|
IsShowEmptyIndicator = false
|
|
};
|
|
|
|
ShowInWindow(treeView, () =>
|
|
{
|
|
AssertMarkerCounts(treeView, 0);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void TreeView_Owner_Item_Route_Reaches_Top_Level_Only()
|
|
{
|
|
var treeView = CreateThreeNodeTree();
|
|
treeView.Classes.Add(OwnerClass);
|
|
treeView.Styles.Add(new Style(selector =>
|
|
selector.OfType<AtomTreeView>().Class(OwnerClass).Child().Class(ItemClass))
|
|
{
|
|
Setters = { new Setter(Control.TagProperty, "top-item") }
|
|
});
|
|
|
|
ShowInWindow(treeView, () =>
|
|
{
|
|
GetTopLevelItems(treeView).ShouldAllBe(static item => Equals(item.Tag, "top-item"));
|
|
GetNestedItems(treeView).ShouldAllBe(static item => item.Tag == null);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void TreeViewItem_Owner_Routes_Reach_Nested_Containers_And_All_Node_Content()
|
|
{
|
|
var treeView = CreateThreeNodeTree();
|
|
treeView.Styles.Add(new Style(selector =>
|
|
selector.OfType<AtomTreeViewItem>().Child().Class(ItemClass))
|
|
{
|
|
Setters = { new Setter(Control.TagProperty, "nested-item") }
|
|
});
|
|
treeView.Styles.Add(new Style(selector =>
|
|
selector.OfType<AtomTreeViewItem>()
|
|
.Template()
|
|
.Class(ScopeHeaderClass)
|
|
.Template()
|
|
.Class(SwitcherClass))
|
|
{
|
|
Setters = { new Setter(Control.TagProperty, "switcher") }
|
|
});
|
|
treeView.Styles.Add(new Style(selector =>
|
|
selector.OfType<AtomTreeViewItem>()
|
|
.Template()
|
|
.Class(ScopeHeaderClass)
|
|
.Template()
|
|
.Class(IconClass))
|
|
{
|
|
Setters = { new Setter(Control.TagProperty, "icon") }
|
|
});
|
|
treeView.Styles.Add(new Style(selector =>
|
|
selector.OfType<AtomTreeViewItem>()
|
|
.Template()
|
|
.Class(ScopeHeaderClass)
|
|
.Template()
|
|
.Class(TitleClass))
|
|
{
|
|
Setters = { new Setter(Control.TagProperty, "title") }
|
|
});
|
|
treeView.Styles.Add(new Style(selector =>
|
|
selector.OfType<AtomTreeViewItem>()
|
|
.Template()
|
|
.Class(ScopeHeaderClass)
|
|
.Template()
|
|
.Class(IndicatorClass))
|
|
{
|
|
Setters = { new Setter(Control.TagProperty, "indicator") }
|
|
});
|
|
|
|
ShowInWindow(treeView, () =>
|
|
{
|
|
// Nested containers are the child items of the top-level TreeViewItem.
|
|
GetNestedItems(treeView).ShouldAllBe(static item => Equals(item.Tag, "nested-item"));
|
|
GetTopLevelItems(treeView).ShouldAllBe(static item => item.Tag == null);
|
|
|
|
// Content parts are owner-scoped to TreeViewItem, so every node's
|
|
// switcher / icon / title / indicator is reached regardless of depth.
|
|
GetSemanticSwitchers(treeView).ShouldAllBe(static switcher => Equals(switcher.Tag, "switcher"));
|
|
GetSemanticIcons(treeView).ShouldAllBe(static icon => Equals(icon.Tag, "icon"));
|
|
GetSemanticTitles(treeView).ShouldAllBe(static title => Equals(title.Tag, "title"));
|
|
GetSemanticIndicators(treeView).ShouldAllBe(static indicator => Equals(indicator.Tag, "indicator"));
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Generated_Route_Styles_Apply_Real_Setters()
|
|
{
|
|
var treeView = CreateThreeNodeTree();
|
|
var switcherBrush = new SolidColorBrush(Colors.LightYellow);
|
|
var iconBrush = new SolidColorBrush(Colors.DarkGreen);
|
|
treeView.Classes.Add(OwnerClass);
|
|
treeView.Styles.Add(new Style(selector =>
|
|
selector.OfType<AtomTreeView>().Class(OwnerClass).Child().Class(ItemClass))
|
|
{
|
|
Setters = { new Setter(TemplatedControl.MinHeightProperty, 40d) }
|
|
});
|
|
treeView.Styles.Add(new Style(selector =>
|
|
selector.OfType<AtomTreeViewItem>()
|
|
.Template()
|
|
.Class(ScopeHeaderClass)
|
|
.Template()
|
|
.Class(SwitcherClass))
|
|
{
|
|
Setters = { new Setter(TemplatedControl.BackgroundProperty, switcherBrush) }
|
|
});
|
|
treeView.Styles.Add(new Style(selector =>
|
|
selector.OfType<AtomTreeViewItem>()
|
|
.Template()
|
|
.Class(ScopeHeaderClass)
|
|
.Template()
|
|
.Class(IconClass))
|
|
{
|
|
Setters = { new Setter(IconPresenter.IconBrushProperty, iconBrush) }
|
|
});
|
|
treeView.Styles.Add(new Style(selector =>
|
|
selector.OfType<AtomTreeViewItem>()
|
|
.Template()
|
|
.Class(ScopeHeaderClass)
|
|
.Template()
|
|
.Class(TitleClass))
|
|
{
|
|
Setters = { new Setter(ContentPresenter.MarginProperty, new Thickness(8, 0, 0, 0)) }
|
|
});
|
|
treeView.Styles.Add(new Style(selector =>
|
|
selector.OfType<AtomTreeViewItem>()
|
|
.Template()
|
|
.Class(ScopeHeaderClass)
|
|
.Template()
|
|
.Class(IndicatorClass))
|
|
{
|
|
Setters = { new Setter(TemplatedControl.MarginProperty, new Thickness(0, 0, 8, 0)) }
|
|
});
|
|
|
|
ShowInWindow(treeView, () =>
|
|
{
|
|
// TreeView's item route styles only the direct (top-level) containers.
|
|
GetTopLevelItems(treeView).ShouldAllBe(static item => Equals(item.MinHeight, 40d));
|
|
GetNestedItems(treeView).ShouldAllBe(static item => !Equals(item.MinHeight, 40d));
|
|
|
|
// TreeViewItem content routes reach every node's content.
|
|
GetSemanticSwitchers(treeView)
|
|
.ShouldAllBe(static switcher => HasColor(switcher.Background, Colors.LightYellow));
|
|
GetSemanticIcons(treeView)
|
|
.ShouldAllBe(static icon => HasColor(icon.IconBrush, Colors.DarkGreen));
|
|
GetSemanticTitles(treeView)
|
|
.ShouldAllBe(static title => title.Margin == new Thickness(8, 0, 0, 0));
|
|
GetSemanticIndicators(treeView)
|
|
.ShouldAllBe(static indicator => indicator.Margin == new Thickness(0, 0, 8, 0));
|
|
});
|
|
}
|
|
|
|
private static AtomTreeView CreateThreeNodeTree()
|
|
{
|
|
var childA = new TreeItemNode { Header = "child-a", ItemKey = "0-0" };
|
|
var childB = new TreeItemNode { Header = "child-b", ItemKey = "0-1" };
|
|
var root = new TreeItemNode
|
|
{
|
|
Header = "root",
|
|
ItemKey = "0",
|
|
IsExpanded = true,
|
|
Children = [childA, childB]
|
|
};
|
|
return new AtomTreeView
|
|
{
|
|
IsShowEmptyIndicator = false,
|
|
ItemsSource = new[] { root }
|
|
};
|
|
}
|
|
|
|
private static void AssertMarkerCounts(AtomTreeView treeView, int expectedItems)
|
|
{
|
|
GetSemanticItems(treeView).Length.ShouldBe(expectedItems);
|
|
GetScopeHeaders(treeView).Length.ShouldBe(expectedItems);
|
|
GetSemanticSwitchers(treeView).Length.ShouldBe(expectedItems);
|
|
GetSemanticIcons(treeView).Length.ShouldBe(expectedItems);
|
|
GetSemanticTitles(treeView).Length.ShouldBe(expectedItems);
|
|
// Each container hosts a checkbox and a radio alternative, only one of
|
|
// which is visible depending on ToggleType.
|
|
GetSemanticIndicators(treeView).Length.ShouldBe(expectedItems * 2);
|
|
}
|
|
|
|
private static AtomTreeViewItem[] GetSemanticItems(AtomTreeView treeView)
|
|
{
|
|
return treeView.GetVisualDescendants()
|
|
.OfType<AtomTreeViewItem>()
|
|
.Where(static item => item.Classes.Contains(ItemClass))
|
|
.ToArray();
|
|
}
|
|
|
|
private static AtomTreeViewItem[] GetTopLevelItems(AtomTreeView treeView)
|
|
{
|
|
return GetSemanticItems(treeView)
|
|
.Where(item => ReferenceEquals(item.Parent, treeView))
|
|
.ToArray();
|
|
}
|
|
|
|
private static AtomTreeViewItem[] GetNestedItems(AtomTreeView treeView)
|
|
{
|
|
return GetSemanticItems(treeView)
|
|
.Where(static item => item.Parent is AtomTreeViewItem)
|
|
.ToArray();
|
|
}
|
|
|
|
private static Visual[] GetScopeHeaders(AtomTreeView treeView)
|
|
{
|
|
return treeView.GetVisualDescendants()
|
|
.Where(static visual => visual.Classes.Contains(ScopeHeaderClass))
|
|
.ToArray();
|
|
}
|
|
|
|
private static ToggleButton[] GetSemanticSwitchers(AtomTreeView treeView)
|
|
{
|
|
return treeView.GetVisualDescendants()
|
|
.OfType<ToggleButton>()
|
|
.Where(static switcher => switcher.Classes.Contains(SwitcherClass))
|
|
.ToArray();
|
|
}
|
|
|
|
private static IconPresenter[] GetSemanticIcons(AtomTreeView treeView)
|
|
{
|
|
return treeView.GetVisualDescendants()
|
|
.OfType<IconPresenter>()
|
|
.Where(static icon => icon.Classes.Contains(IconClass))
|
|
.ToArray();
|
|
}
|
|
|
|
private static ContentPresenter[] GetSemanticTitles(AtomTreeView treeView)
|
|
{
|
|
return treeView.GetVisualDescendants()
|
|
.OfType<ContentPresenter>()
|
|
.Where(static title => title.Classes.Contains(TitleClass))
|
|
.ToArray();
|
|
}
|
|
|
|
private static ToggleButton[] GetSemanticIndicators(AtomTreeView treeView)
|
|
{
|
|
return treeView.GetVisualDescendants()
|
|
.OfType<ToggleButton>()
|
|
.Where(static indicator => indicator.Classes.Contains(IndicatorClass))
|
|
.ToArray();
|
|
}
|
|
|
|
private static bool HasColor(IBrush? brush, Color color)
|
|
{
|
|
return brush is SolidColorBrush solid && solid.Color == color;
|
|
}
|
|
|
|
private static void ShowInWindow(Control content, Action assertion)
|
|
{
|
|
var window = new AvaloniaWindow
|
|
{
|
|
Width = 800,
|
|
Height = 600,
|
|
Content = content
|
|
};
|
|
|
|
try
|
|
{
|
|
window.Show();
|
|
Dispatcher.UIThread.RunJobs();
|
|
assertion();
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
}
|
|
|
|
private static void AssertRoot(SemanticPartDescriptor part, Type contractType)
|
|
{
|
|
part.Path.ShouldBe("root");
|
|
part.SelectorClass.ShouldBeNull();
|
|
part.ContractType.ShouldBe(contractType);
|
|
part.Cardinality.ShouldBe(SemanticPartCardinality.Single);
|
|
part.Customization.ShouldBe(SemanticPartCustomization.Root);
|
|
part.CrossVisualRoot.ShouldBeFalse();
|
|
part.RuntimeCreated.ShouldBeFalse();
|
|
}
|
|
|
|
private static void AssertPart(
|
|
SemanticPartDescriptor part,
|
|
string selectorClass,
|
|
Type contractType,
|
|
string selectorRoute)
|
|
{
|
|
part.Path.ShouldBe(part.Name);
|
|
part.SelectorClass.ShouldBe(selectorClass);
|
|
part.SelectorRoute.ShouldBe(selectorRoute);
|
|
part.ContractType.ShouldBe(contractType);
|
|
part.Cardinality.ShouldBe(SemanticPartCardinality.Multiple);
|
|
part.Customization.ShouldBe(SemanticPartCustomization.Selector);
|
|
part.CrossVisualRoot.ShouldBeFalse();
|
|
part.RuntimeCreated.ShouldBeTrue();
|
|
part.Since.ShouldBe("6.2.0");
|
|
}
|
|
|
|
private static void AssertThemeMarkers(string relativePath, string[] expectedMarkers)
|
|
{
|
|
var document = XDocument.Load(GetRepoFile(relativePath), LoadOptions.SetLineInfo);
|
|
var literalSemanticMarkers = document.Descendants()
|
|
.Attributes()
|
|
.Where(static attribute =>
|
|
attribute.Name.LocalName == "Classes" &&
|
|
attribute.Value.Split(
|
|
(char[]?)null,
|
|
StringSplitOptions.RemoveEmptyEntries)
|
|
.Any(static value => value.StartsWith(
|
|
"semantic-",
|
|
StringComparison.Ordinal)))
|
|
.ToArray();
|
|
var classPropertyMarkers = document.Descendants()
|
|
.SelectMany(static element => element.Attributes()
|
|
.Where(static attribute => attribute.Name.LocalName.StartsWith(
|
|
"Classes.semantic-",
|
|
StringComparison.Ordinal))
|
|
.Select(attribute => (Element: element, Attribute: attribute)))
|
|
.ToArray();
|
|
|
|
literalSemanticMarkers.ShouldBeEmpty();
|
|
classPropertyMarkers.ShouldAllBe(static marker =>
|
|
string.Equals(marker.Attribute.Value, "true", StringComparison.OrdinalIgnoreCase));
|
|
classPropertyMarkers.Select(static marker =>
|
|
$"{marker.Attribute.Name.LocalName["Classes.".Length..]}:{marker.Element.Name.LocalName}")
|
|
.OrderBy(static value => value, StringComparer.Ordinal)
|
|
.ShouldBe(expectedMarkers);
|
|
}
|
|
|
|
private static string GetRepoFile(string relativePath)
|
|
{
|
|
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
|
while (directory is not null)
|
|
{
|
|
var candidate = Path.Combine(directory.FullName, relativePath);
|
|
if (File.Exists(candidate))
|
|
{
|
|
return candidate;
|
|
}
|
|
|
|
directory = directory.Parent;
|
|
}
|
|
|
|
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
|
|
}
|
|
}
|