Files
chinboy 8a227e61af feat(Semantic): 增加 ButtonSpinner 语义部件并修复输入族根边框定制失效
将 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 基于旧基线,已失效)。
2026-09-17 11:13:21 +08:00

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}'.");
}
}