mirror of
https://github.com/AtomUI/AtomUI.git
synced 2026-10-29 07:16:33 +08:00
将 ButtonSpinner 纳入 Semantic Part 改造(原排除判定撤销),按全量改造设计 §2.1 第 4 条以产品职责直接对应建立映射。 准入依据:上游稳定版 InputNumberSemanticType 公开并实际消费 root / prefix / suffix / input / actions 五个分区键,其中 actions 应用于包裹上、下步进按钮的容器, 对应 AtomUI 的 ButtonSpinnerHandle;原判定「InputNumber 的 handle 属内部区域」的 事实前提不再成立。边界如实记录:上游至今没有独立 spinner owner,本项准入依据的是 上游 actions 键叠加用户指令,而非原触发条件(新稳定版出现独立 spinner owner)自动 满足。 实现: - 新增 ButtonSpinner.SemanticParts.cs 声明六个 Part,ButtonSpinner 声明为 partial: content / innerLeftContent / innerRightContent 位于帧模板,actions 位于 owner 模板 内的手柄,increaseButton / decreaseButton 位于手柄模板;后五者声明 CrossNestedOwners。含隐式 root 共七个 Part,均为 Single。 - 帧内容槽不复用 semantic-prefix / semantic-suffix:NumericUpDown 已发布的 prefix route 是宽松后代(/template/ .semantic-scope-spinner >> .semantic-prefix),而 ButtonSpinner 的帧节点位于 NumericUpDownSpinner 子树内,复用会让该 route 命中两个 节点并破坏其已发布的 Single 契约;改用与自身公开 API 同名的 innerLeftContent / innerRightContent。 - 修复根边框定制失效:可见外框由共享帧绘制、其状态机拥有 BorderBrush,owner 的 BorderBrush 此前完全落不到帧上(ButtonSpinner、Select、TreeSelect、Cascader、 OtpLineEdit 均如此)。改为以 LocalValue 中继到帧,并带接管标记,避免嵌套 owner (NumericUpDown 的 spinner 即 ButtonSpinner)的中继被内层清除。 - 修复帧动效接线缺失:ButtonSpinner、NumericUpDown 两个模式、Select / TreeSelect / Cascader 的帧未接收 owner 的 IsMotionEnabled,回落到帧主题的共享默认值而使过渡 常开;进行中的 SolidColorBrush 过渡优先级高于中继写入的 LocalValue,令运行中的根 边框定制静默失效。模板应用前赋值不存在过渡基线,故早期测试无法暴露该缺陷。 - OtpLineEdit 保留 CellBorderBrush 的 cell 专用优先级,根 BorderBrush 作为其回落; Background 不参与中继,避免控件级透明背景覆盖 cell 主题填充。 测试: - 新增 ButtonSpinnerSemanticPartTests(9 例):descriptor 字段与顺序、六类 marker 契约、生成样式各精确命中目标、状态切换后 marker 稳定、默认主题不消费 .semantic-*, 并锁定帧槽位不复用 semantic-prefix / suffix 的回归护栏。 - 新增 ButtonSpinnerRootSurfaceTests(5 例)、NumericUpDownRootSurfaceTests 扩至 7 例、 SelectRootSurfaceRelayTests(15 例)、OtpLineEditRootSurfaceRelayTests(4 例),覆盖 定制压过 hover / focus 状态机、置空恢复状态机、模板前赋值、未定制保持 rest 状态、 实时改边框立即生效、以及帧动效接线。 - NumericUpDownSemanticPartTests 作为共享帧回归护栏全绿,证明未破坏其已发布契约。 Gallery: - ButtonSpinnerShowCase 迁移到 GalleryShowCaseHost,新增 Semantic Parts 页签与 SemanticStyles 示例(含根边框定制),补齐 9 个资源键 × 4 语言。 - Semantic Parts 页签收敛为单个预览:初版误发两个预览(Floating handle 与 Inline handle),但 IsButtonSpinnerFloatable 默认 False 且前者未显式设置,两者 渲染同一个默认常驻手柄、七行部件说明完全重复;页面测试改为断言恰好一个预览。 文档: - 新增 semantic-part.md,并重写 overview.md 中过期的 root/trigger/item/popup/motion 语义表为真实契约;补齐 ButtonSpinner 与四个输入族控件的 changelog,并在 semantic-part.md 标明「默认是常驻手柄」以免再现上述预览误判。 - 记录范围撤销:改造设计 §2.3 / §2.4、计划新增第六批任务;新增真机验收步骤文档。 验证:Desktop 4068/4068、Gallery 645/645、GalleryBase 185/185、Generator SemanticPart 54/54、AtomUI.slnx 构建 0 warning / 0 error、LLMS verify 通过 (79 控件 / 161 文件)、git diff --check 干净。真机视觉验收待用户回传截图,未宣称通过。 (cherry-pick 自 feature/semantic-buttonspinner 的 6d693d13a,并按新基线适配) - Since 统一由 6.0 改为 6.2.0:新基线的生成器新增 SemanticPartInvalidSince 校验,只接受 major.minor.patch 三段式发布版本,原值在合并后会直接阻断生成。 - 合并当日两次范围纳入:GroupBox(无上游 owner,用户指令纳入)与本 ButtonSpinner 各自使 准入家族 +1、排除控件 -1;合并后准入家族 65、排除控件 12(非任一分支单独记录的 64/13)。 - 本地化单元计数按新基线重算为 4749(原分支 4741 基于旧基线,已失效)。
374 lines
16 KiB
C#
374 lines
16 KiB
C#
using System.Xml.Linq;
|
|
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 AtomUIButtonSpinner = AtomUI.Desktop.Controls.ButtonSpinner;
|
|
using AvaloniaWindow = Avalonia.Controls.Window;
|
|
|
|
namespace AtomUI.Desktop.Controls.Tests.ButtonSpinnerTests;
|
|
|
|
public class ButtonSpinnerSemanticPartTests
|
|
{
|
|
private const string ActionsClass = "semantic-actions";
|
|
private const string ContentClass = "semantic-content";
|
|
private const string DecreaseButtonClass = "semantic-decrease-button";
|
|
private const string IncreaseButtonClass = "semantic-increase-button";
|
|
private const string InnerLeftContentClass = "semantic-inner-left-content";
|
|
private const string InnerRightContentClass = "semantic-inner-right-content";
|
|
private const string ScopeFrameClass = "semantic-scope-frame";
|
|
|
|
private const string OwnerThemePath =
|
|
"src/AtomUI.Desktop.Controls/ButtonSpinner/Themes/ButtonSpinnerTheme.axaml";
|
|
private const string FrameThemePath =
|
|
"src/AtomUI.Desktop.Controls/ButtonSpinner/Themes/ButtonSpinnerDecoratedBoxTheme.axaml";
|
|
private const string HandleThemePath =
|
|
"src/AtomUI.Desktop.Controls/ButtonSpinner/Themes/ButtonSpinnerHandleTheme.axaml";
|
|
|
|
static ButtonSpinnerSemanticPartTests()
|
|
{
|
|
AvaloniaTestApp.EnsureInitialized();
|
|
}
|
|
|
|
[Fact]
|
|
public void Registered_Descriptor_Exposes_Only_The_Approved_ButtonSpinner_Parts()
|
|
{
|
|
var registry = Application.Current.ShouldNotBeNull()
|
|
.GetThemeManager().ShouldNotBeNull()
|
|
.SemanticParts;
|
|
|
|
registry.TryGetControl(typeof(AtomUIButtonSpinner), out var descriptor).ShouldBeTrue();
|
|
descriptor.ShouldNotBeNull();
|
|
descriptor.Parts.Select(static part => part.Name)
|
|
.ShouldBe(["root", "actions", "content", "decreaseButton", "increaseButton",
|
|
"innerLeftContent", "innerRightContent"]);
|
|
|
|
AssertRoot(descriptor);
|
|
|
|
AssertPart(descriptor, "actions", ActionsClass, typeof(TemplatedControl),
|
|
"/template/ .semantic-scope-frame >> .semantic-actions");
|
|
AssertPart(descriptor, "content", ContentClass, typeof(ContentPresenter),
|
|
"/template/ .semantic-scope-frame /template/ .semantic-content");
|
|
AssertPart(descriptor, "decreaseButton", DecreaseButtonClass, typeof(IconButton),
|
|
">> .semantic-actions /template/ .semantic-decrease-button");
|
|
AssertPart(descriptor, "increaseButton", IncreaseButtonClass, typeof(IconButton),
|
|
">> .semantic-actions /template/ .semantic-increase-button");
|
|
AssertPart(descriptor, "innerLeftContent", InnerLeftContentClass, typeof(ContentPresenter),
|
|
"/template/ .semantic-scope-frame /template/ .semantic-inner-left-content");
|
|
AssertPart(descriptor, "innerRightContent", InnerRightContentClass, typeof(ContentPresenter),
|
|
"/template/ .semantic-scope-frame /template/ .semantic-inner-right-content");
|
|
}
|
|
|
|
/// <summary>
|
|
/// The frame slots are shared with NumericUpDown, whose <c>prefix</c> / <c>suffix</c> routes are
|
|
/// broad descendants (<c>/template/ .semantic-scope-spinner >> .semantic-prefix</c>) that reach
|
|
/// into the decorated box subtree. Reusing those class names here would make NumericUpDown's
|
|
/// route resolve two nodes and break its published <c>Single</c> cardinality.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Frame_Slots_Do_Not_Reuse_The_NumericUpDown_Prefix_Or_Suffix_Classes()
|
|
{
|
|
var registry = Application.Current.ShouldNotBeNull()
|
|
.GetThemeManager().ShouldNotBeNull()
|
|
.SemanticParts;
|
|
|
|
registry.TryGetControl(typeof(AtomUIButtonSpinner), out var descriptor).ShouldBeTrue();
|
|
descriptor.ShouldNotBeNull();
|
|
|
|
descriptor.Parts.Select(static part => part.SelectorClass)
|
|
.ShouldNotContain("semantic-prefix");
|
|
descriptor.Parts.Select(static part => part.SelectorClass)
|
|
.ShouldNotContain("semantic-suffix");
|
|
|
|
var frameTheme = XDocument.Load(GetRepoFile(FrameThemePath), LoadOptions.SetLineInfo);
|
|
frameTheme.Descendants().Any(static element => HasMarker(element, "semantic-prefix")).ShouldBeFalse();
|
|
frameTheme.Descendants().Any(static element => HasMarker(element, "semantic-suffix")).ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void Owner_Template_Declares_The_Scope_Anchor_And_The_Actions_Marker()
|
|
{
|
|
var template = SingleTemplate(OwnerThemePath);
|
|
|
|
var scopeFrames = FindMarkedElements(template, ScopeFrameClass);
|
|
scopeFrames.Count.ShouldBe(1);
|
|
scopeFrames[0].Name.LocalName.ShouldBe("ButtonSpinnerDecoratedBox");
|
|
|
|
var actions = FindMarkedElements(template, ActionsClass);
|
|
actions.Count.ShouldBe(1);
|
|
actions[0].Name.LocalName.ShouldBe(nameof(ButtonSpinnerHandle));
|
|
|
|
template.Descendants().Any(static element => HasMarker(element, "semantic-root")).ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void Frame_Template_Declares_Content_And_Inner_Content_Slots()
|
|
{
|
|
var template = SingleTemplate(FrameThemePath);
|
|
|
|
var contents = FindMarkedElements(template, ContentClass);
|
|
contents.Count.ShouldBe(1);
|
|
contents[0].Name.LocalName.ShouldBe(nameof(ContentPresenter));
|
|
|
|
var innerLeft = FindMarkedElements(template, InnerLeftContentClass);
|
|
innerLeft.Count.ShouldBe(1);
|
|
innerLeft[0].Name.LocalName.ShouldBe("AddOnContentPresenter");
|
|
|
|
var innerRight = FindMarkedElements(template, InnerRightContentClass);
|
|
innerRight.Count.ShouldBe(1);
|
|
innerRight[0].Name.LocalName.ShouldBe("AddOnContentPresenter");
|
|
|
|
innerLeft[0].ShouldNotBeSameAs(innerRight[0]);
|
|
template.Descendants().Any(static element => HasMarker(element, "semantic-root")).ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void Handle_Template_Declares_Both_Step_Button_Markers()
|
|
{
|
|
var template = SingleTemplate(HandleThemePath);
|
|
|
|
var increase = FindMarkedElements(template, IncreaseButtonClass);
|
|
increase.Count.ShouldBe(1);
|
|
increase[0].Name.LocalName.ShouldBe(nameof(IconButton));
|
|
|
|
var decrease = FindMarkedElements(template, DecreaseButtonClass);
|
|
decrease.Count.ShouldBe(1);
|
|
decrease[0].Name.LocalName.ShouldBe(nameof(IconButton));
|
|
|
|
increase[0].ShouldNotBeSameAs(decrease[0]);
|
|
template.Descendants().Any(static element => HasMarker(element, "semantic-root")).ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void Every_Theme_Uses_Static_Class_Property_Markers_Only()
|
|
{
|
|
foreach (var path in new[] { OwnerThemePath, FrameThemePath, HandleThemePath })
|
|
{
|
|
var document = XDocument.Load(GetRepoFile(path), LoadOptions.SetLineInfo);
|
|
|
|
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)))
|
|
.ShouldBeEmpty();
|
|
|
|
document.Descendants()
|
|
.Attributes()
|
|
.Where(static attribute => attribute.Name.LocalName.StartsWith(
|
|
"Classes.semantic-",
|
|
StringComparison.Ordinal))
|
|
.ShouldAllBe(static attribute =>
|
|
string.Equals(attribute.Value, "true", StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Generated_Semantic_Styles_Apply_To_The_Template_Targets()
|
|
{
|
|
var registry = Application.Current.ShouldNotBeNull()
|
|
.GetThemeManager().ShouldNotBeNull()
|
|
.SemanticParts;
|
|
registry.TryGetControl(typeof(AtomUIButtonSpinner), out var descriptor).ShouldBeTrue();
|
|
descriptor.ShouldNotBeNull();
|
|
|
|
var spinner = CreateSpinner();
|
|
spinner.Classes.Add("semantic-owner");
|
|
var ownerStyle = new Style(selector => selector.OfType<AtomUIButtonSpinner>().Class("semantic-owner"));
|
|
ownerStyle.Setters.Add(new Setter(Control.TagProperty, "root"));
|
|
foreach (var part in descriptor.Parts.Where(static part => part.Name != "root"))
|
|
{
|
|
var partStyle = (Style)Activator.CreateInstance(part.StyleType.ShouldNotBeNull()).ShouldNotBeNull();
|
|
partStyle.Setters.Add(new Setter(Control.TagProperty, part.Name));
|
|
ownerStyle.Children.Add(partStyle);
|
|
}
|
|
spinner.Styles.Add(ownerStyle);
|
|
|
|
var window = Show(spinner);
|
|
try
|
|
{
|
|
spinner.Tag.ShouldBe("root");
|
|
FindSemanticControl<TemplatedControl>(spinner, ActionsClass).Tag.ShouldBe("actions");
|
|
FindSemanticControl<ContentPresenter>(spinner, ContentClass).Tag.ShouldBe("content");
|
|
FindSemanticControl<IconButton>(spinner, DecreaseButtonClass).Tag.ShouldBe("decreaseButton");
|
|
FindSemanticControl<IconButton>(spinner, IncreaseButtonClass).Tag.ShouldBe("increaseButton");
|
|
FindSemanticControl<ContentPresenter>(spinner, InnerLeftContentClass).Tag.ShouldBe("innerLeftContent");
|
|
FindSemanticControl<ContentPresenter>(spinner, InnerRightContentClass).Tag.ShouldBe("innerRightContent");
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Markers_Remain_Stable_When_Handle_State_Changes()
|
|
{
|
|
var spinner = CreateSpinner();
|
|
|
|
var window = Show(spinner);
|
|
try
|
|
{
|
|
var content = FindSemanticControl<ContentPresenter>(spinner, ContentClass);
|
|
var innerLeft = FindSemanticControl<ContentPresenter>(spinner, InnerLeftContentClass);
|
|
var innerRight = FindSemanticControl<ContentPresenter>(spinner, InnerRightContentClass);
|
|
var actions = FindSemanticControl<TemplatedControl>(spinner, ActionsClass);
|
|
var increase = FindSemanticControl<IconButton>(spinner, IncreaseButtonClass);
|
|
var decrease = FindSemanticControl<IconButton>(spinner, DecreaseButtonClass);
|
|
|
|
spinner.IsButtonSpinnerFloatable = true;
|
|
spinner.IsButtonSpinnerVisible = false;
|
|
spinner.ButtonSpinnerLocation = ButtonSpinnerLocation.Left;
|
|
spinner.InnerLeftContent = null;
|
|
spinner.InnerRightContent = null;
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
FindSemanticControl<ContentPresenter>(spinner, ContentClass).ShouldBeSameAs(content);
|
|
FindSemanticControl<ContentPresenter>(spinner, InnerLeftContentClass).ShouldBeSameAs(innerLeft);
|
|
FindSemanticControl<ContentPresenter>(spinner, InnerRightContentClass).ShouldBeSameAs(innerRight);
|
|
FindSemanticControl<TemplatedControl>(spinner, ActionsClass).ShouldBeSameAs(actions);
|
|
FindSemanticControl<IconButton>(spinner, IncreaseButtonClass).ShouldBeSameAs(increase);
|
|
FindSemanticControl<IconButton>(spinner, DecreaseButtonClass).ShouldBeSameAs(decrease);
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Default_Theme_Does_Not_Consume_Semantic_Selectors()
|
|
{
|
|
foreach (var path in new[] { OwnerThemePath, FrameThemePath, HandleThemePath })
|
|
{
|
|
var document = XDocument.Load(GetRepoFile(path), LoadOptions.SetLineInfo);
|
|
document.Descendants()
|
|
.Where(static element => element.Name.LocalName == "Style")
|
|
.Attributes("Selector")
|
|
.Select(static attribute => attribute.Value)
|
|
.ShouldAllBe(static selector => !selector.Contains("semantic-", StringComparison.Ordinal));
|
|
}
|
|
}
|
|
|
|
private static AtomUIButtonSpinner CreateSpinner()
|
|
{
|
|
return new AtomUIButtonSpinner
|
|
{
|
|
Content = "Semantic Spinner",
|
|
InnerLeftContent = "$",
|
|
InnerRightContent = "RMB"
|
|
};
|
|
}
|
|
|
|
private static void AssertRoot(ControlSemanticDescriptor descriptor)
|
|
{
|
|
var root = descriptor.Parts.Single(static part => part.Name == "root");
|
|
root.Path.ShouldBe("root");
|
|
root.SelectorClass.ShouldBeNull();
|
|
root.SelectorRoute.ShouldBeNull();
|
|
root.ContractType.ShouldBe(typeof(AtomUIButtonSpinner));
|
|
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)
|
|
{
|
|
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(SemanticPartCardinality.Single);
|
|
part.Customization.ShouldBe(SemanticPartCustomization.Selector);
|
|
part.CrossVisualRoot.ShouldBeFalse();
|
|
part.RuntimeCreated.ShouldBeFalse();
|
|
part.Since.ShouldBe("6.2.0");
|
|
part.StyleType.ShouldNotBeNull();
|
|
}
|
|
|
|
private static T FindSemanticControl<T>(Control owner, string marker)
|
|
where T : Control
|
|
{
|
|
return owner.GetVisualDescendants()
|
|
.OfType<T>()
|
|
.Single(control => control.Classes.Contains(marker));
|
|
}
|
|
|
|
private static XElement SingleTemplate(string relativePath)
|
|
{
|
|
var document = XDocument.Load(GetRepoFile(relativePath), LoadOptions.SetLineInfo);
|
|
var templates = document.Descendants()
|
|
.Where(static element => element.Name.LocalName == "ControlTemplate")
|
|
.ToArray();
|
|
templates.Length.ShouldBe(1);
|
|
return templates[0];
|
|
}
|
|
|
|
private static List<XElement> FindMarkedElements(XElement template, string marker)
|
|
{
|
|
return template.Descendants()
|
|
.Where(element => HasMarker(element, marker))
|
|
.ToList();
|
|
}
|
|
|
|
private static bool HasMarker(XElement element, string marker)
|
|
{
|
|
if (((string?)element.Attribute("Classes"))
|
|
?.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)
|
|
.Contains(marker, StringComparer.Ordinal) == true)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
var classProperty = element.Attribute($"Classes.{marker}");
|
|
return classProperty is not null &&
|
|
bool.TryParse(classProperty.Value, out var isEnabled) &&
|
|
isEnabled;
|
|
}
|
|
|
|
private static AvaloniaWindow Show(Control content)
|
|
{
|
|
var window = new AvaloniaWindow
|
|
{
|
|
Width = 480,
|
|
Height = 160,
|
|
Content = content
|
|
};
|
|
window.Show();
|
|
content.ApplyTemplate();
|
|
window.UpdateLayout();
|
|
Dispatcher.UIThread.RunJobs();
|
|
return window;
|
|
}
|
|
|
|
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}'.");
|
|
}
|
|
}
|