mirror of
https://github.com/AtomUI/AtomUI.git
synced 2026-10-22 00:46:48 +08:00
8a227e61af
将 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 基于旧基线,已失效)。
221 lines
7.1 KiB
C#
221 lines
7.1 KiB
C#
using AtomUI.Theme.Resources;
|
|
using Avalonia;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Media;
|
|
using Avalonia.Threading;
|
|
using Avalonia.VisualTree;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using AtomUICascader = AtomUI.Desktop.Controls.Cascader;
|
|
using AtomUISelect = AtomUI.Desktop.Controls.Select;
|
|
using AtomUITreeSelect = AtomUI.Desktop.Controls.TreeSelect;
|
|
using AvaloniaWindow = Avalonia.Controls.Window;
|
|
|
|
namespace AtomUI.Desktop.Controls.Tests.SelectControl;
|
|
|
|
/// <summary>
|
|
/// The visible Select / TreeSelect / Cascader outline is drawn by the shared
|
|
/// <c>AddOnDecoratedBox</c> frame, whose state machine owns <c>BorderBrush</c> / <c>Background</c>.
|
|
/// These tests pin the root-level customization contract so an owner-scoped root Setter reaches
|
|
/// that frame instead of being silently ignored, matching the input-family relay
|
|
/// (<c>TextInputRootBrushRelayTests</c>).
|
|
/// </summary>
|
|
public class SelectRootSurfaceRelayTests
|
|
{
|
|
static SelectRootSurfaceRelayTests()
|
|
{
|
|
AvaloniaTestApp.EnsureInitialized();
|
|
}
|
|
|
|
public static TheoryData<string> SelectKinds()
|
|
{
|
|
return new TheoryData<string>
|
|
{
|
|
nameof(AtomUISelect),
|
|
nameof(AtomUITreeSelect),
|
|
nameof(AtomUICascader)
|
|
};
|
|
}
|
|
|
|
[Theory]
|
|
[MemberData(nameof(SelectKinds))]
|
|
public void Root_BorderBrush_Customization_Wins_Over_Hover_And_Focus_Border_States(string kind)
|
|
{
|
|
var control = CreateSelect(kind, out var window);
|
|
try
|
|
{
|
|
window.Show();
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
var frame = FindFrame(control);
|
|
var customBorder = new SolidColorBrush(Color.Parse("#f759ab"));
|
|
|
|
control.BorderBrush = customBorder;
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
GetSolidBrushColor(frame.BorderBrush).ShouldBe(customBorder.Color,
|
|
"The root BorderBrush set on the select owner must reach the input frame as a local value.");
|
|
|
|
frame.IsInnerBoxHover = true;
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
GetSolidBrushColor(frame.BorderBrush).ShouldBe(customBorder.Color,
|
|
"A customized root border intentionally suppresses the hover border color.");
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
}
|
|
|
|
[Theory]
|
|
[MemberData(nameof(SelectKinds))]
|
|
public void Root_Background_Customization_Replaces_Rest_Background(string kind)
|
|
{
|
|
var control = CreateSelect(kind, out var window);
|
|
try
|
|
{
|
|
window.Show();
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
var frame = FindFrame(control);
|
|
var customBackground = new SolidColorBrush(Color.Parse("#fff1f0"));
|
|
|
|
control.Background = customBackground;
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
GetSolidBrushColor(frame.Background).ShouldBe(customBackground.Color,
|
|
"The root Background set on the select owner must reach the input frame as a local value.");
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
}
|
|
|
|
[Theory]
|
|
[MemberData(nameof(SelectKinds))]
|
|
public void Root_BorderBrush_Clear_Restores_The_Frame_State_Machine(string kind)
|
|
{
|
|
var control = CreateSelect(kind, out var window);
|
|
try
|
|
{
|
|
window.Show();
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
var frame = FindFrame(control);
|
|
var restBorder = GetThemeResource<IBrush>(SharedTokenKind.ColorBorder);
|
|
var customBorder = new SolidColorBrush(Color.Parse("#f759ab"));
|
|
|
|
control.BorderBrush = customBorder;
|
|
Dispatcher.UIThread.RunJobs();
|
|
GetSolidBrushColor(frame.BorderBrush).ShouldBe(customBorder.Color);
|
|
|
|
control.BorderBrush = null;
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
GetSolidBrushColor(frame.BorderBrush).ShouldBe(GetSolidBrushColor(restBorder),
|
|
"Clearing the root BorderBrush must fall back to the frame theme rest state.");
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
}
|
|
|
|
[Theory]
|
|
[MemberData(nameof(SelectKinds))]
|
|
public void Root_BorderBrush_Set_Before_Template_Is_Applied_Is_Still_Relayed(string kind)
|
|
{
|
|
var customBorder = new SolidColorBrush(Color.Parse("#f759ab"));
|
|
var control = CreateSelect(kind, out var window, customBorder);
|
|
try
|
|
{
|
|
window.Show();
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
GetSolidBrushColor(FindFrame(control).BorderBrush).ShouldBe(customBorder.Color,
|
|
"A root brush assigned before the template exists must be relayed once the template is applied.");
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
}
|
|
|
|
[Theory]
|
|
[MemberData(nameof(SelectKinds))]
|
|
public void Uncustomized_Selects_Keep_The_Frame_Theme_Rest_State(string kind)
|
|
{
|
|
var control = CreateSelect(kind, out var window);
|
|
try
|
|
{
|
|
window.Show();
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
var frame = FindFrame(control);
|
|
var restBorder = GetThemeResource<IBrush>(SharedTokenKind.ColorBorder);
|
|
|
|
control.BorderBrush.ShouldBeNull(
|
|
"The select themes must not set a dead owner-level BorderBrush default; the frame theme owns the rest state.");
|
|
GetSolidBrushColor(frame.BorderBrush).ShouldBe(GetSolidBrushColor(restBorder));
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
}
|
|
|
|
private static AbstractSelect CreateSelect(
|
|
string kind,
|
|
out AvaloniaWindow window,
|
|
IBrush? borderBrush = null)
|
|
{
|
|
AbstractSelect control = kind switch
|
|
{
|
|
nameof(AtomUISelect) => new AtomUISelect { Width = 200, IsMotionEnabled = false },
|
|
nameof(AtomUITreeSelect) => new AtomUITreeSelect { Width = 200, IsMotionEnabled = false },
|
|
nameof(AtomUICascader) => new AtomUICascader { Width = 200, IsMotionEnabled = false },
|
|
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
|
|
};
|
|
|
|
if (borderBrush is not null)
|
|
{
|
|
control.BorderBrush = borderBrush;
|
|
}
|
|
|
|
window = new AvaloniaWindow
|
|
{
|
|
Width = 260,
|
|
Height = 160,
|
|
Content = control
|
|
};
|
|
return control;
|
|
}
|
|
|
|
private static InputControlFrame FindFrame(Control control)
|
|
{
|
|
var frame = control.GetVisualDescendants()
|
|
.OfType<InputControlFrame>()
|
|
.FirstOrDefault();
|
|
frame.ShouldNotBeNull();
|
|
return frame!;
|
|
}
|
|
|
|
private static T GetThemeResource<T>(object key)
|
|
{
|
|
var application = Application.Current;
|
|
application.ShouldNotBeNull();
|
|
application!.TryGetResource(key, application.ActualThemeVariant, out var value).ShouldBeTrue();
|
|
value.ShouldBeAssignableTo<T>();
|
|
return (T)value!;
|
|
}
|
|
|
|
private static Color GetSolidBrushColor(IBrush? brush)
|
|
{
|
|
brush.ShouldNotBeNull();
|
|
brush.ShouldBeAssignableTo<ISolidColorBrush>();
|
|
return ((ISolidColorBrush)brush!).Color;
|
|
}
|
|
}
|