using System.Xml.Linq; using AtomUI.Controls; using AtomUI.MotionScene; using AtomUI.Theme; using AtomUI.Theme.Schema; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Presenters; using Avalonia.Media; using Avalonia.Styling; using Avalonia.Threading; using Avalonia.VisualTree; using Shouldly; using Xunit; using AtomUISelectableTextBlock = AtomUI.Desktop.Controls.SelectableTextBlock; using AvaloniaWindow = Avalonia.Controls.Window; namespace AtomUI.Desktop.Controls.Tests.Notifications; public class NotificationSemanticPartTests { private const string CardThemePath = "src/AtomUI.Desktop.Controls/Notifications/Themes/NotificationCardTheme.axaml"; private const string ManagerThemePath = "src/AtomUI.Desktop.Controls/Notifications/Themes/WindowNotificationManagerTheme.axaml"; // manifest 顺序:隐式 root 在前,其余按 path 字典序。 private static readonly string[] ApprovedCardPartNames = ["root", "actions", "close", "description", "icon", "progress", "section", "title", "wrapper"]; private static readonly string[] ApprovedManagerPartNames = ["root", "listContent"]; static NotificationSemanticPartTests() { AvaloniaTestApp.EnsureInitialized(); } [Fact] public void Registered_Descriptors_Expose_Only_The_Approved_Notification_Parts() { var registry = Application.Current.ShouldNotBeNull() .GetThemeManager().ShouldNotBeNull() .SemanticParts; registry.TryGetControl(typeof(NotificationCard), out var cardDescriptor).ShouldBeTrue(); cardDescriptor.ShouldNotBeNull(); cardDescriptor.Parts.Select(static part => part.Name).ShouldBe(ApprovedCardPartNames); AssertRoot(cardDescriptor, typeof(NotificationCard)); AssertPart(cardDescriptor, "wrapper", "semantic-wrapper", typeof(DockPanel)); AssertPart(cardDescriptor, "icon", "semantic-icon", typeof(IconPresenter)); AssertPart(cardDescriptor, "section", "semantic-section", typeof(StackPanel)); AssertPart(cardDescriptor, "title", "semantic-title", typeof(AtomUISelectableTextBlock)); AssertPart(cardDescriptor, "description", "semantic-description", typeof(ContentPresenter)); AssertPart(cardDescriptor, "actions", "semantic-actions", typeof(ContentPresenter)); AssertPart(cardDescriptor, "close", "semantic-close", typeof(IconButton)); AssertPart(cardDescriptor, "progress", "semantic-progress", typeof(Control), runtimeCreated: true); registry.TryGetControl(typeof(WindowNotificationManager), out var managerDescriptor).ShouldBeTrue(); managerDescriptor.ShouldNotBeNull(); managerDescriptor.Parts.Select(static part => part.Name).ShouldBe(ApprovedManagerPartNames); AssertRoot(managerDescriptor, typeof(WindowNotificationManager)); AssertPart(managerDescriptor, "listContent", "semantic-list-content", typeof(ItemsControl)); } [Fact] public void Built_In_Themes_Carry_The_Approved_Static_Markers() { var cardDocument = XDocument.Load(GetRepoFile(CardThemePath), LoadOptions.SetLineInfo); CollectMarkers(cardDocument).ShouldBe( [ "semantic-actions:ContentPresenter", "semantic-close:IconButton", "semantic-description:ContentPresenter", "semantic-icon:IconPresenter", "semantic-section:StackPanel", "semantic-title:SelectableTextBlock", "semantic-wrapper:DockPanel" ]); // progress 由运行时创建,不进入静态模板 marker 集合。 cardDocument.Descendants().Any(static element => HasMarker(element, "semantic-progress")).ShouldBeFalse(); cardDocument.Descendants().Any(static element => HasMarker(element, "semantic-root")).ShouldBeFalse(); var managerDocument = XDocument.Load(GetRepoFile(ManagerThemePath), LoadOptions.SetLineInfo); CollectMarkers(managerDocument).ShouldBe(["semantic-list-content:FeedbackStackPresenter"]); managerDocument.Descendants().Any(static element => HasMarker(element, "semantic-root")).ShouldBeFalse(); } [Fact] public void Built_In_Themes_Do_Not_Consume_Semantic_Selectors() { foreach (var path in new[] { CardThemePath, ManagerThemePath }) { var document = XDocument.Load(GetRepoFile(path), LoadOptions.SetLineInfo); document.Descendants() .Where(static element => element.Name.LocalName == "Style") .Select(static element => (string?)element.Attribute("Selector")) .Where(static selector => selector?.Contains(".semantic-", StringComparison.Ordinal) == true) .ShouldBeEmpty(path); } } [Fact] public void NotificationCard_Template_Exposes_The_Upstream_Notice_Structure() { var manager = CreateManager(); var card = CreateCard(manager, NotificationType.Success, "Deployment completed", "Build #42 finished"); var window = ShowInWindow(card); try { // 模板结构:wrapper > (icon, section > (title, description)) + actions + close。 var wrapper = FindSemanticControl(card, "semantic-wrapper"); var icon = FindSemanticControl(card, "semantic-icon"); var section = FindSemanticControl(card, "semantic-section"); var title = FindSemanticControl(card, "semantic-title"); var description = FindSemanticControl(card, "semantic-description"); var actions = FindSemanticControl(card, "semantic-actions"); var close = FindSemanticControl(card, "semantic-close"); // wrapper 是 icon 与 section 的共同父级;close / actions 不在 wrapper 内。 icon.GetVisualAncestors().ShouldContain(wrapper); section.GetVisualAncestors().ShouldContain(wrapper); ReferenceEquals(title.GetVisualParent(), section).ShouldBeTrue(); ReferenceEquals(description.GetVisualParent(), section).ShouldBeTrue(); close.GetVisualAncestors().ShouldNotContain(wrapper); ReferenceEquals(actions.GetVisualParent(), wrapper).ShouldBeFalse(); // close 是覆盖层:垂直靠上,不参与 wrapper 的流式排列。 close.VerticalAlignment.ShouldBe(Avalonia.Layout.VerticalAlignment.Top); // title 在关闭区保留内边距,避免文本压到关闭按钮;描述在标题下方,不预留。 title.Padding.Right.ShouldBeGreaterThan(0); description.Padding.Right.ShouldBe(0); // 图标顶部与标题行顶部对齐,而不是被 DockPanel 的 Stretch 垂直居中(视觉上会低一截)。 var iconTop = icon.TranslatePoint(new Point(0, 0), wrapper)!.Value.Y; var titleTop = title.TranslatePoint(new Point(0, 0), wrapper)!.Value.Y; Math.Abs(iconTop - titleTop).ShouldBeLessThan(2); } finally { window.Close(); } } [Fact] public void Actions_Part_Reacts_To_The_Actions_Content() { var manager = CreateManager(); var card = CreateCard(manager, NotificationType.Information, "Title", "Body"); var window = ShowInWindow(card); try { var actions = FindSemanticControl(card, "semantic-actions"); actions.IsVisible.ShouldBeFalse(); card.Actions = new Button { Content = "Details" }; Dispatcher.UIThread.RunJobs(); actions.IsVisible.ShouldBeTrue(); actions.Content.ShouldBeOfType