using System.Xml.Linq; using AtomUI.Controls; using AtomUI.Controls.Primitives; using AtomUI.Theme; using AtomUI.Theme.Schema; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives; using Avalonia.Styling; using Avalonia.Threading; using Avalonia.VisualTree; using Shouldly; using Xunit; using AtomUIDropdownButton = AtomUI.Desktop.Controls.DropdownButton; using AvaloniaWindow = Avalonia.Controls.Window; namespace AtomUI.Desktop.Controls.Tests.DropdownButtonControl; public class DropdownButtonSemanticPartTests { private const string DropdownButtonThemePath = "src/AtomUI.Desktop.Controls/Buttons/Themes/DropdownButtonTheme.axaml"; private const string DropdownButtonBaseThemePath = "src/AtomUI.Desktop.Controls/Buttons/Themes/DropdownButtonBaseTheme.axaml"; private static readonly string[] ApprovedPartNames = [ "root", "item", "itemContent", "itemIcon", "itemTitle", "popup.root" ]; static DropdownButtonSemanticPartTests() { AvaloniaTestApp.EnsureInitialized(); } [Fact] public void Registered_Descriptor_Exposes_Only_The_Approved_DropdownButton_Parts() { var registry = Application.Current.ShouldNotBeNull() .GetThemeManager().ShouldNotBeNull() .SemanticParts; registry.TryGetControl(typeof(AtomUIDropdownButton), out var descriptor).ShouldBeTrue(); descriptor.ShouldNotBeNull(); descriptor.Parts.Select(static part => part.Name).ShouldBe(ApprovedPartNames); AssertRoot(descriptor, typeof(AtomUIDropdownButton)); AssertPart(descriptor, "popup.root", "semantic-popup-root", typeof(AtomUI.Desktop.Controls.ArrowDecoratedBox), ">> .semantic-popup-root"); AssertPart(descriptor, "item", "semantic-item", typeof(AtomUI.Desktop.Controls.MenuItem), ">> .semantic-item", SemanticPartCardinality.Multiple); AssertPart(descriptor, "itemIcon", "semantic-item-icon", typeof(IconPresenter), ">> .semantic-item /template/ .semantic-item-icon"); AssertPart(descriptor, "itemContent", "semantic-item-content", typeof(ContentPresenter), ">> .semantic-item /template/ .semantic-item-content"); AssertPart(descriptor, "itemTitle", "semantic-item-title", typeof(ContentPresenter), ">> .semantic-item-title-group /template/ .semantic-item-title", SemanticPartCardinality.Multiple); } [Fact] public void Built_In_Templates_Carry_No_Trigger_Side_Static_Markers() { // antd 的 Dropdown Semantic DOM 全部位于弹层侧,触发器模板节点上不允许存在静态 semantic marker。 var themeDocument = XDocument.Load(GetRepoFile(DropdownButtonThemePath), LoadOptions.SetLineInfo); var themeTemplates = themeDocument.Descendants() .Where(static element => element.Name.LocalName == "ControlTemplate").ToArray(); themeTemplates.Length.ShouldBe(1); var baseDocument = XDocument.Load(GetRepoFile(DropdownButtonBaseThemePath), LoadOptions.SetLineInfo); var baseTemplates = baseDocument.Descendants() .Where(static element => element.Name.LocalName == "ControlTemplate").ToArray(); baseTemplates.Length.ShouldBe(3); foreach (var template in themeTemplates.Concat(baseTemplates)) { CollectMarkers(template).ShouldBeEmpty(); } } [Fact] public void Popup_Parts_Expose_Markers_When_Opened() { var dropdownButton = CreatePinnedDropdownButton(); ShowInWindow(dropdownButton, window => { window.GetVisualDescendants() .OfType() .Single(control => control.Classes.Contains("semantic-popup-root")) .ShouldNotBeNull(); window.GetVisualDescendants() .OfType() .Count(control => control.Classes.Contains("semantic-item")) .ShouldBeGreaterThanOrEqualTo(3); // Realize the nested submenu so the MenuItem container path is exercised: // the "Paste" submenu items are only materialized once the submenu opens, // so open it and require the nested items to carry the popup item marker too. var pasteParent = window.GetVisualDescendants() .OfType() .First(item => Equals(item.Header, "Paste") && item.Items.Count > 0); pasteParent.IsSubMenuOpen = true; Dispatcher.UIThread.RunJobs(); window.UpdateLayout(); Dispatcher.UIThread.RunJobs(); window.GetVisualDescendants() .OfType() .First(item => Equals(item.Header, "Paste from history")) .Classes.Contains("semantic-item") .ShouldBeTrue(); window.GetVisualDescendants() .OfType() .Count(control => control.Classes.Contains("semantic-item")) .ShouldBeGreaterThanOrEqualTo(5); window.GetVisualDescendants() .OfType() .Any(control => control.Classes.Contains("semantic-item-icon")) .ShouldBeTrue(); window.GetVisualDescendants() .OfType() .Any(control => control.Classes.Contains("semantic-item-content")) .ShouldBeTrue(); window.GetVisualDescendants() .OfType() .Single(control => control.Classes.Contains("semantic-item-title-group")) .ShouldNotBeNull(); window.GetVisualDescendants() .OfType() .Single(control => control.Classes.Contains("semantic-item-title")) .ShouldNotBeNull(); }); } [Fact] public void Generated_Popup_Semantic_Styles_Apply_To_The_Popup_Targets() { var registry = Application.Current.ShouldNotBeNull() .GetThemeManager().ShouldNotBeNull() .SemanticParts; registry.TryGetControl(typeof(AtomUIDropdownButton), out var descriptor).ShouldBeTrue(); descriptor.ShouldNotBeNull(); var dropdownButton = CreatePinnedDropdownButton(); dropdownButton.Classes.Add("semantic-owner"); var ownerStyle = new Style(selector => selector.OfType().Class("semantic-owner")); foreach (var part in descriptor.Parts.Where(static part => part.StyleType != null)) { var partStyle = (Style)Activator.CreateInstance(part.StyleType.ShouldNotBeNull()).ShouldNotBeNull(); partStyle.Setters.Add(new Setter(Control.TagProperty, part.Name)); ownerStyle.Children.Add(partStyle); } dropdownButton.Styles.Add(ownerStyle); ShowInWindow(dropdownButton, window => { window.GetVisualDescendants() .OfType() .Single(control => control.Classes.Contains("semantic-popup-root")) .Tag.ShouldBe("popup.root"); window.GetVisualDescendants() .OfType() .First(control => control.Classes.Contains("semantic-item")) .Tag.ShouldBe("item"); window.GetVisualDescendants() .OfType() .First(control => control.Classes.Contains("semantic-item-icon")) .Tag.ShouldBe("itemIcon"); window.GetVisualDescendants() .OfType() .First(control => control.Classes.Contains("semantic-item-content")) .Tag.ShouldBe("itemContent"); window.GetVisualDescendants() .OfType() .First(control => control.Classes.Contains("semantic-item-title")) .Tag.ShouldBe("itemTitle"); }); } [Fact] public void Pinned_Open_Disables_Light_Dismiss_On_The_Flyout_Popup() { var dropdownButton = CreatePinnedDropdownButton(); ShowInWindow(dropdownButton, window => { var popup = dropdownButton.DropdownFlyout.ShouldNotBeNull().Popup; popup.ShouldNotBeNull(); popup.IsOpen.ShouldBeTrue(); popup.IsLightDismissEnabled.ShouldBeFalse( "a pinned preview popup must not leave a light-dismiss overlay blocking the rest of the window"); }); } [Fact] public void Pinned_Popup_With_Declaratively_Open_Submenu_Does_Not_Crash_On_Initial_Open() { var dropdownButton = CreatePinnedDropdownButton(); var pasteParent = dropdownButton.DropdownFlyout.ShouldNotBeNull().Items .OfType() .First(item => Equals(item.Header, "Paste") && item.Items.Count > 0); // 声明式打开(对应 Gallery SemanticPartPreview 的 IsSubMenuOpen="True",antd defaultOpenKeys)。 pasteParent.IsSubMenuOpen = true; ShowInWindow(dropdownButton, window => { dropdownButton.DropdownFlyout.Popup.ShouldNotBeNull().IsOpen.ShouldBeTrue(); pasteParent.IsSubMenuOpen.ShouldBeTrue(); window.GetVisualDescendants() .OfType() .First(item => Equals(item.Header, "Paste from history")) .ShouldNotBeNull(); }); } private static AtomUIDropdownButton CreatePinnedDropdownButton() { return new AtomUIDropdownButton { Content = "Actions", IsMotionEnabled = false, IsPopupPinnedOpen = true, DropdownFlyout = new AtomUI.Desktop.Controls.MenuFlyout { Items = { new AtomUI.Desktop.Controls.MenuItemGroup { Header = "Group title", Items = { new AtomUI.Desktop.Controls.MenuItem { Header = "1st menu item" }, new AtomUI.Desktop.Controls.MenuItem { Header = "2nd menu item" } } }, new AtomUI.Desktop.Controls.MenuItem { Header = "Cut" }, new AtomUI.Desktop.Controls.MenuItem { Header = "Copy" }, new AtomUI.Desktop.Controls.MenuItem { Header = "Paste", Items = { new AtomUI.Desktop.Controls.MenuItem { Header = "Paste" }, new AtomUI.Desktop.Controls.MenuItem { Header = "Paste from history" } } } } } }; } private static void AssertRoot(ControlSemanticDescriptor descriptor, Type controlType) { var root = descriptor.Parts.Single(static part => part.Name == "root"); root.Path.ShouldBe("root"); root.SelectorClass.ShouldBeNull(); root.SelectorRoute.ShouldBeNull(); root.ContractType.ShouldBe(controlType); root.Cardinality.ShouldBe(SemanticPartCardinality.Single); root.Customization.ShouldBe(SemanticPartCustomization.Root); root.StyleType.ShouldBeNull(); } private static void AssertPart( ControlSemanticDescriptor descriptor, string name, string selectorClass, Type contractType, string selectorRoute, SemanticPartCardinality cardinality = SemanticPartCardinality.Single) { var part = descriptor.Parts.Single(candidate => candidate.Name == name); part.Path.ShouldBe(name); part.SelectorClass.ShouldBe(selectorClass); part.SelectorRoute.ShouldBe(selectorRoute); part.ContractType.ShouldBe(contractType); part.Cardinality.ShouldBe(cardinality); part.Customization.ShouldBe(SemanticPartCustomization.Selector); part.Since.ShouldBe("6.2.0"); part.StyleType.ShouldNotBeNull(); } private static string[] CollectMarkers(XElement template) { return template.Descendants() .SelectMany(static element => element.Attributes() .Where(static attribute => attribute.Name.LocalName.StartsWith( "Classes.semantic-", StringComparison.Ordinal))) .Select(static attribute => $"{attribute.Name.LocalName["Classes.".Length..]}:{attribute.Parent!.Name.LocalName}") .ToArray(); } private static void ShowInWindow(Control content, Action assertion) { var overlayPanel = new ScopeAwareOverlayLayerPanel { Width = 640, Height = 480 }; overlayPanel.Children.Add(content); var visualLayerManager = new VisualLayerManager { Child = overlayPanel }; EnablePopupOverlayLayer(visualLayerManager); var window = new AvaloniaWindow { Width = 640, Height = 480, Content = visualLayerManager }; try { window.Show(); Dispatcher.UIThread.RunJobs(); content.ApplyTemplate(); window.UpdateLayout(); Dispatcher.UIThread.RunJobs(); assertion(window); } finally { window.Close(); } } private static void EnablePopupOverlayLayer(VisualLayerManager visualLayerManager) { var property = typeof(VisualLayerManager).GetProperty( "EnablePopupOverlayLayer", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); property.ShouldNotBeNull(); property.SetValue(visualLayerManager, true); } 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}'."); } }