From bf7312987663d3638b7c021f1e1508352dc6ea71 Mon Sep 17 00:00:00 2001 From: chinboy Date: Sat, 23 May 2026 09:24:49 +0800 Subject: [PATCH] perf(Splitter): reduce collapse icon and drag preview overhead --- docs/performances/README.md | 3 +- docs/performances/Splitter/README.md | 134 ++++++++++++++++++ .../desktop-controls-optimization-roadmap.md | 8 +- .../Splitter/SplitterHandle.cs | 39 +++-- .../Splitter/SplitterPanel.cs | 19 ++- .../Splitter/SplitterStateVerification.cs | 40 ++++-- 6 files changed, 212 insertions(+), 31 deletions(-) create mode 100644 docs/performances/Splitter/README.md diff --git a/docs/performances/README.md b/docs/performances/README.md index db7ca2c98..303442cb3 100644 --- a/docs/performances/README.md +++ b/docs/performances/README.md @@ -90,6 +90,7 @@ Avalonia 12 控件库研发的成本模型与高频踩雷点(含 `path:line` | Result | 本轮已完成 | [Result](Result/README.md) | 普通状态/错误码视觉按需创建;`ResultShowCase` repeated mean `74.01ms -> 67.19ms`,Svg `8 -> 3`,visuals `265 -> 257` | | ScrollViewer | 本轮已完成基线与低风险正确性修复 | [ScrollViewer](ScrollViewer/README.md) | 修复 overlay host selector 与 motion owner;`FloatButtonShowCase` 页面级 timing 基本中性,后续优化需更强收益证明 | | Slider | 本轮已完成 | [Slider](Slider/README.md) | 普通 Slider 不再预创建 `PART_EndThumb`;`SliderShowCase` repeated mean `46.31ms -> 33.78ms`,visuals `111 -> 105` | +| Splitter | 本轮已完成 Gallery 实测 | [Splitter](Splitter/README.md) | hidden collapse icon 不再构造/保留 `PathIcon`,lazy preview transform 复用;`SplitterShowCase` repeated mean `36.91ms -> 34.66ms`,P95 `51.79ms -> 40.43ms`,alloc `5770.31KB -> 5659.40KB` | | Statistic | 本轮已完成正确性与生命周期优化 | [Statistic](Statistic/README.md) | generated `Content` ownership、`StatisticCountUp.DataContext` 清理、`TimerStatistic` attach-gated timer 释放;`StatisticShowCase` repeated alloc `7471.87KB -> 7228.74KB`,visuals `387 -> 384` | | Timeline | 本轮已完成结构与正确性修复 | [Timeline](Timeline/README.md) | `TimelineIndicator.Render` Pen 缓存;修复 pending item 清空和默认/custom icon 显示;页面级 timing 未证明收益,pending spinner 正确显示带来 `+2` visuals | | TextBlock | 本轮已完成 | [TextBlock](TextBlock/README.md) | `HighlightableTextBlock` 段级 Run 重写:Match.Medium `3.306ms→0.426ms (-87%)`、`355.8KB→44.0KB (-88%)`、`logical 56→4`;`SelectableTextBlock` token binding+Cursor 移到 Theme Setter | @@ -121,7 +122,7 @@ Avalonia 12 控件库研发的成本模型与高频踩雷点(含 `path:line` | Layout | FlexPanel | Pending | 待建立基线 | | Layout | Grid | Pending | 待建立基线 | | Layout | Space | Done | 已完成 Phase 0-7;`CompactSpaceFiller` 不再承担 wrapper,repeated timing 小幅改善,cold 仍需后续针对子控件/Gallery 拆解 | -| Layout | Splitter | Pending | 待建立基线 | +| Layout | Splitter | Done | [Splitter](Splitter/README.md);hidden collapse icon 不再构造/保留 `PathIcon`,lazy preview transform 复用,`SplitterShowCase` repeated mean 提升约 `6.10%`,P95 提升约 `21.93%` | | Navigation | Breadcrumb | Done (correctness + baseline) | [Breadcrumb](Breadcrumb/README.md);修复父级 separator 传播到 direct/generated inherited items;Gallery cold / P95 / alloc 有收益,repeated median 基本持平 | | Navigation | ComboBox | Done | [ComboBox](ComboBox/README.md);默认 host、popup content、handle `IconButton` 成本已按需/轻量化,Gallery repeated mean 提升约 `16.72%` | | Navigation | DropdownButton | Pending | 已做 Button 联动 smoke;仍待独立基线 | diff --git a/docs/performances/Splitter/README.md b/docs/performances/Splitter/README.md new file mode 100644 index 000000000..6c8c80632 --- /dev/null +++ b/docs/performances/Splitter/README.md @@ -0,0 +1,134 @@ +# Splitter 性能优化 + +> 路线图位置:[`../desktop-controls-optimization-roadmap.md`](../desktop-controls-optimization-roadmap.md) Tier 2 #6 +> 状态:本轮已完成控件级与 Gallery 长样本实测。 + +--- + +## 0. 结论 + +本轮优化有效,但收益主要来自创建/布局路径的轻量化,不改变 Splitter 运行时视觉结构: + +- collapse `IconButton` 保留在 ControlTheme 中,符合 Theme Static Rule;隐藏或不可折叠时不再构造和保留 `PathIcon`。 +- lazy 拖拽预览复用已有 `TranslateTransform`,避免每个 drag delta 重新分配 transform。 +- `SplitterHandle` 转发拖拽事件时复用原始 `VectorEventArgs`,不再为每个 started/delta/completed 事件复制参数对象。 + +Gallery 首轮 `warmup=5 / iterations=30` 出现 repeated mean 噪声回退,但同一轮 alloc 已下降。用临时 worktree 对修改前代码补跑 `warmup=15 / iterations=50` 后,当前代码在 cold、repeated、P95、alloc 上均为正向;最终结论采用长样本口径。 + +相关 Avalonia 成本依据: + +- `IsVisible=False` 会短路 measure / arrange / render:`.referenceprojects/Avalonia/src/Avalonia.Base/Layout/Layoutable.cs:546`、`:671`;`.referenceprojects/Avalonia/src/Avalonia.Base/Rendering/ImmediateRenderer.cs:34` +- `RenderTransform` 属于 compositor 侧变换路径:`.referenceprojects/Avalonia/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual/ServerCompositionVisual.DirtyInputs.cs:89-118` + +--- + +## 1. 控件级基线 + +baseline 命令: + +```bash +dotnet run --project tools/performances/AtomUI.Performance/AtomUI.Performance.csproj \ + -c Debug --framework net10.0 --no-build -- \ + --suite splitter --count 60 \ + --markdown /tmp/splitter-control-baseline.md +``` + +current 复测命令: + +```bash +dotnet run --project tools/performances/AtomUI.Performance/AtomUI.Performance.csproj \ + -c Debug --framework net10.0 --no-build -- \ + --suite splitter --count 60 \ + --markdown /tmp/splitter-control-current.md +``` + +第二次 current 复测同命令,输出到 `/tmp/splitter-control-current-rerun.md`。控件级 headless timing 单次噪声较明显,所以这里同时列出两次 current 和平均改善;alloc 取两次一致值或均值。 + +| Scenario | baseline ms/item | current ms/item run1 | current ms/item run2 | avg timing 改善 | baseline KB/item | current KB/item | alloc 改善 | 判断 | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- | +| `Splitter.Basic.Vertical2` | 1.129 | 1.089 | 1.042 | 5.62% | 200.1 | 193.6 | 3.25% | 有效 | +| `Splitter.Basic.Horizontal2` | 1.154 | 1.060 | 0.999 | 10.79% | 201.9 | 195.3 | 3.27% | 有效 | +| `Splitter.Multi.Vertical4` | 4.203 | 4.443 | 3.630 | 3.96% | 565.3 | 545.9 | 3.43% | timing 抖动,alloc 有效 | +| `Splitter.Collapsible.Always3` | 6.182 | 4.295 | 5.815 | 18.23% | 922.0 | 922.0 | 0.00% | timing 有效,alloc 持平 | +| `Splitter.Collapsible.Hidden3` | 2.642 | 1.399 | 2.126 | 33.29% | 371.5 | 358.7 | 3.45% | 有效 | +| `Splitter.Lazy.Pair` | 2.733 | 1.412 | 2.412 | 30.04% | 408.1 | 395.0 | 3.21% | 有效 | +| `Splitter.GalleryShape` | 13.570 | 9.655 | 14.524 | 10.91% | 3130.7 | 3061.2 | 2.22% | timing 噪声,alloc 有效 | + +控件级结论:分配下降是稳定信号;`Hidden3`、`Lazy.Pair` 和 collapse always 路径的 timing 两次复测均为正向。`Multi.Vertical4`、`GalleryShape` 的控件级 timing 有单次反向,不能单独作为收益证明;最终有效性以 Gallery 长样本为主。 + +--- + +## 2. Gallery 真实场景 + +最终口径命令: + +```bash +dotnet run --project tools/performances/AtomUI.GalleryPerformance/AtomUI.GalleryPerformance.csproj \ + -c Debug --framework net10.0 --no-build -- \ + --showcase splitter --label splitter-current-long \ + --cold-iterations 10 --iterations 50 --warmup 15 --timeout-ms 30000 \ + --markdown /tmp/splitter-gallery-current-long.md +``` + +baseline 取自临时 worktree `/tmp/AtomUIV6-splitter-baseline-codex`,代码为本轮修改前的 `066d915e9`。 + +| 指标 | baseline | current | 改善 | 结论 | +| --- | ---: | ---: | ---: | --- | +| Cold first navigation mean | 177.55 ms | 154.96 ms | 12.72% | 有效 | +| Cold first navigation median | 162.21 ms | 152.06 ms | 6.26% | 有效 | +| Cold first navigation P95 | 253.34 ms | 175.33 ms | 30.80% | 有效 | +| Cold alloc mean | 6380.36 KB | 6256.48 KB | 1.94% | 小幅有效 | +| Repeated navigation mean | 36.91 ms | 34.66 ms | 6.10% | 有效 | +| Repeated navigation median | 33.12 ms | 32.62 ms | 1.51% | 小幅有效 | +| Repeated navigation P95 | 51.79 ms | 40.43 ms | 21.93% | 有效 | +| Repeated alloc mean | 5770.31 KB | 5659.40 KB | 1.92% | 小幅有效 | +| Runtime visuals | 319 | 319 | 0 | 结构不变 | +| Runtime logical | 31 | 31 | 0 | 结构不变 | + +补充:首轮同参数 `warmup=5 / iterations=30` 的 current repeated mean 为 `50.94 ms`,高于 baseline `49.15 ms`,但样本前段存在明显预热坡度;长样本复测未复现该退化。 + +--- + +## 3. 改动 + +| 路径 | 改动 | +| --- | --- | +| `src/AtomUI.Desktop.Controls/Splitter/SplitterHandle.cs` | 隐藏 collapse button 时清空 `Icon`;仅 visible 时创建 collapse icon;拖拽事件转发复用原始 `VectorEventArgs` | +| `src/AtomUI.Desktop.Controls/Splitter/SplitterPanel.cs` | lazy preview 复用同一个 `TranslateTransform` 并只更新 `X/Y` | +| `tools/performances/AtomUI.Performance/Suites/Splitter/SplitterStateVerification.cs` | 状态验证按 Theme Static Rule 调整为“模板按钮保留,但隐藏态不持有 icon”;新增 transform reuse 验证 | + +--- + +## 4. 验证 + +```bash +dotnet build tools/performances/AtomUI.Performance/AtomUI.Performance.csproj -c Debug --framework net10.0 +``` + +结果:0 Warning(s), 0 Error(s)。 + +```bash +dotnet run --project tools/performances/AtomUI.Performance/AtomUI.Performance.csproj \ + -c Debug --framework net10.0 --no-build -- \ + --verify-splitter-states +``` + +结果:`Splitter state verification passed.` + +```bash +dotnet build tools/performances/AtomUI.GalleryPerformance/AtomUI.GalleryPerformance.csproj -c Debug --framework net10.0 +``` + +结果:构建通过;保留既有 warning `DataGridColumn._clipboardContentBinding is never used`,非本轮新增。 + +--- + +## 5. 复杂度自评 + +| 指标 | 数值 | +| --- | --- | +| 新增 `Ensure*/Clear*/Sync*` 方法 | 0 | +| 新增 `_ignoreXxx` 标志位 | 0 | +| 新增 disposable / event / timer | 0 | +| axaml 节点搬到 C# 动态创建 | 否 | +| 生产文件范围 | 2 个文件 | diff --git a/docs/performances/desktop-controls-optimization-roadmap.md b/docs/performances/desktop-controls-optimization-roadmap.md index 81ef18976..80b1ae508 100644 --- a/docs/performances/desktop-controls-optimization-roadmap.md +++ b/docs/performances/desktop-controls-optimization-roadmap.md @@ -16,7 +16,7 @@ | 控件清单 | 71 个 Desktop 控件目录 + ColorPicker + DataGrid | `find src/AtomUI.Desktop.Controls -maxdepth 1 -type d` | | 主题依赖 | 137 条目录间边 | 在 `*.axaml` 中 grep `atom:Xxx`,去掉 token / lang / constants / converters 后映射到拥有该类型的目录 | | 代码依赖 | 277 条目录间边(粗筛后) | 在 `*.cs` 中匹配类名 token 与全局类→目录映射的交集;含一定假阳性(常见单词 `Empty` / `Form` / `Message` 等会误判,已通过同时观察主题边校正主要结论) | -| 已完成基线 | 51 项 | `docs/performances/README.md` "当前文档" / "总列表" 的 Done 行 | +| 已完成基线 | 52 项 | `docs/performances/README.md` "当前文档" / "总列表" 的 Done 行 | **结论权重**:主题依赖是优化排序的主依据;代码依赖只用于补充识别"程序化使用"的隐式耦合(例如 ContextMenu、Window、Message 等无主题模板嵌入但代码里直接 new 的依赖)。 @@ -90,7 +90,7 @@ | Space | Done | 11 | 0 | Done | | Spin | Skipped (sub-noise) | 8 | 0 | `BuildIndicatorAnimation` lazy-build 改动 KB/item 落在变异范围内;Render 路径 (4 × DrawEllipse/frame) 才是真热点,需 Gallery 级实测 | | SplitView | Pending | 0 | 0 | 简单容器 | -| Splitter | **Pending** | 0 | 1 | 完整鼠标拖拽链路 | +| Splitter | Done | 0 | 1 | hidden collapse icon 不再构造/保留 PathIcon,lazy preview transform 复用;详见 [Splitter](Splitter/README.md) | | Statistic | Done | 0 | 1 | generated content ownership、CountUp DataContext 清理、TimerStatistic attach-gated timer 生命周期;详见 [Statistic](Statistic/README.md) | | Steps | Done (structural-only) | 1 | 1 | 修复 `ConfigureItemsPanel` 未清理 `ColumnDefinitions`;详见 [Steps](Steps/README.md) | | Switch (ToggleSwitch) | **Pending** | 0 | 0 | 单一控件,可独立基线 | @@ -217,7 +217,7 @@ DataGrid 是最重的复合控件,独立子表才是合理的优化粒度。 | T2.3 | Timeline | 3 | Done (structural + correctness) | `TimelineIndicator.Render` Pen 缓存;pending item 生命周期/default icon 修复,页面级 timing 未证明收益;详见 [Timeline](Timeline/README.md) | | T2.4 | Steps | 2 | Done (structural-only) | 修复 Grid definitions 清理错误;页面级 timing 未证明稳定收益 | | T2.5 | Breadcrumb | 2 | Done (correctness + baseline) | 父级 `Separator` 变化现在同步到 direct/generated inherited items;同参数复测下 cold / P95 / alloc 有收益,repeated median 基本持平 | -| T2.6 | Splitter | 3 | Pending | 拖拽全局事件订阅 | +| T2.6 | Splitter | 3 | **Done** | hidden collapse icon 不再构造/保留 `PathIcon`,lazy preview transform 复用;`SplitterShowCase` repeated mean `36.91ms -> 34.66ms`,alloc `5770.31KB -> 5659.40KB`;详见 [Splitter](Splitter/README.md) | | T2.7 | Switch (ToggleSwitch) | 0 | Pending | RectTransition + WaveSpiritDecorator | | T2.8 | WindowTitleBar | 3 | Pending | 与 Window (T0.3) 同周期 | | T2.9 | AdornerLayer | 0 | Pending | 与 Drawer/Dialog/Tooltip overlay 协同 | @@ -271,7 +271,7 @@ Phase C (Tier 1 复合) ────────────────── T1.7 TabControl 独立 Phase D (Tier 2 单功能) ────────────────────────────────────── - T2.2 Statistic done → T2.3 Timeline / T2.5 Breadcrumb / T2.6 Splitter + T2.2 Statistic done → T2.3 Timeline / T2.5 Breadcrumb / T2.6 Splitter done T2.7 Switch / T2.10 SplitView Phase E (Tier 3 ColorPicker) ───────────────────────────────── diff --git a/src/AtomUI.Desktop.Controls/Splitter/SplitterHandle.cs b/src/AtomUI.Desktop.Controls/Splitter/SplitterHandle.cs index 8068f23f1..ed1625b2a 100644 --- a/src/AtomUI.Desktop.Controls/Splitter/SplitterHandle.cs +++ b/src/AtomUI.Desktop.Controls/Splitter/SplitterHandle.cs @@ -157,16 +157,20 @@ internal class SplitterHandle : TemplatedControl if (_collapsePrevButton != null) { - ApplyCustomIconBackground(_collapsePrevButton, PreviousIconTemplate != null); - _collapsePrevButton.IsVisible = hasPrevious && ShouldShowIcon(PreviousShowMode, true, onlyPrevious); - _collapsePrevButton.Icon = CreatePrevIcon(); + UpdateCollapseButton( + _collapsePrevButton, + hasPrevious && ShouldShowIcon(PreviousShowMode, true, onlyPrevious), + PreviousIconTemplate != null, + isPrevious: true); } if (_collapseNextButton != null) { - ApplyCustomIconBackground(_collapseNextButton, NextIconTemplate != null); - _collapseNextButton.IsVisible = hasNext && ShouldShowIcon(NextShowMode, false, onlyNext); - _collapseNextButton.Icon = CreateNextIcon(); + UpdateCollapseButton( + _collapseNextButton, + hasNext && ShouldShowIcon(NextShowMode, false, onlyNext), + NextIconTemplate != null, + isPrevious: false); } } @@ -201,17 +205,17 @@ internal class SplitterHandle : TemplatedControl private void HandleDragBarStarted(object? sender, VectorEventArgs e) { - DragStarted?.Invoke(this, new VectorEventArgs { RoutedEvent = e.RoutedEvent, Vector = e.Vector }); + DragStarted?.Invoke(this, e); } private void HandleDragBarDelta(object? sender, VectorEventArgs e) { - DragDelta?.Invoke(this, new VectorEventArgs { RoutedEvent = e.RoutedEvent, Vector = e.Vector }); + DragDelta?.Invoke(this, e); } private void HandleDragBarCompleted(object? sender, VectorEventArgs e) { - DragCompleted?.Invoke(this, new VectorEventArgs { RoutedEvent = e.RoutedEvent, Vector = e.Vector }); + DragCompleted?.Invoke(this, e); } private static void AttachCollapseButton(IconButton? button, EventHandler handler) @@ -256,6 +260,23 @@ internal class SplitterHandle : TemplatedControl } } + private void UpdateCollapseButton(IconButton button, + bool isVisible, + bool isCustomIcon, + bool isPrevious) + { + ApplyCustomIconBackground(button, isCustomIcon); + button.IsVisible = isVisible; + if (isVisible) + { + button.Icon = isPrevious ? CreatePrevIcon() : CreateNextIcon(); + } + else if (button.Icon != null) + { + button.Icon = null; + } + } + private bool ShouldShowIcon(SplitterCollapsibleIconDisplayMode mode, bool isPrevious, bool isOnly) { return mode switch diff --git a/src/AtomUI.Desktop.Controls/Splitter/SplitterPanel.cs b/src/AtomUI.Desktop.Controls/Splitter/SplitterPanel.cs index b3ee628ea..67e6e86e2 100644 --- a/src/AtomUI.Desktop.Controls/Splitter/SplitterPanel.cs +++ b/src/AtomUI.Desktop.Controls/Splitter/SplitterPanel.cs @@ -1336,9 +1336,22 @@ internal class SplitterPanel : Panel private void ApplyHandlePreview(SplitterHandle handle, double delta) { - handle.RenderTransform = Orientation == Orientation.Vertical - ? new TranslateTransform(delta, 0) - : new TranslateTransform(0, delta); + if (handle.RenderTransform is not TranslateTransform transform) + { + transform = new TranslateTransform(); + handle.RenderTransform = transform; + } + + if (Orientation == Orientation.Vertical) + { + transform.X = delta; + transform.Y = 0; + } + else + { + transform.X = 0; + transform.Y = delta; + } } private void ClearHandlePreview(SplitterHandle handle) diff --git a/tools/performances/AtomUI.Performance/Suites/Splitter/SplitterStateVerification.cs b/tools/performances/AtomUI.Performance/Suites/Splitter/SplitterStateVerification.cs index 1a05c87de..9585953fb 100644 --- a/tools/performances/AtomUI.Performance/Suites/Splitter/SplitterStateVerification.cs +++ b/tools/performances/AtomUI.Performance/Suites/Splitter/SplitterStateVerification.cs @@ -12,7 +12,7 @@ internal static partial class Program private static bool RunSplitterStateVerification() { var failures = new List(); - VerifySplitterNonCollapsibleHandleDoesNotCreateCollapseButtons(failures); + VerifySplitterNonCollapsibleHandleKeepsCollapseButtonsInactive(failures); VerifySplitterCollapsibleButtonsLifecycle(failures); VerifySplitterLazyPreviewReusesTransform(failures); @@ -30,7 +30,7 @@ internal static partial class Program return false; } - private static void VerifySplitterNonCollapsibleHandleDoesNotCreateCollapseButtons(ICollection failures) + private static void VerifySplitterNonCollapsibleHandleKeepsCollapseButtonsInactive(ICollection failures) { var splitter = CreateBasicSplitter(Orientation.Vertical); @@ -38,8 +38,8 @@ internal static partial class Program Expect(CountSplitterHandles(splitter) == 1, $"Basic Splitter should create one handle, actual {CountSplitterHandles(splitter)}.", failures); - Expect(CountIconButtons(splitter) == 0, - $"Non-collapsible Splitter should not create collapse IconButtons, actual {CountIconButtons(splitter)}.", + ExpectCollapseButtonsInactive(splitter, + "Non-collapsible Splitter collapse IconButtons should stay hidden without icons.", failures); } @@ -50,7 +50,10 @@ internal static partial class Program using var realized = RealizeControl(splitter); var initialButtons = splitter.GetSelfAndVisualDescendants().OfType().ToList(); Expect(initialButtons.Count > 0, - "Collapsible Splitter should create collapse IconButtons.", + "Collapsible Splitter should keep template collapse IconButtons.", + failures); + Expect(initialButtons.All(button => button.IsVisible), + "Always-visible collapse IconButtons should be visible.", failures); Expect(initialButtons.All(button => button.Icon != null), "Visible collapse IconButtons should have an icon.", @@ -62,11 +65,8 @@ internal static partial class Program } RefreshLayout(realized.Window); - Expect(CountIconButtons(splitter) == 0, - $"Splitter should remove collapse IconButtons after collapsible is cleared, actual {CountIconButtons(splitter)}.", - failures); - Expect(initialButtons.All(button => button.GetVisualParent() == null), - "Removed collapse IconButtons should not keep a visual parent.", + ExpectCollapseButtonsInactive(splitter, + "Splitter should hide collapse IconButtons and clear icons after collapsible is cleared.", failures); foreach (var child in splitter.Children.OfType()) @@ -79,8 +79,8 @@ internal static partial class Program } RefreshLayout(realized.Window); - Expect(CountIconButtons(splitter) == 0, - $"Hidden collapsible icons should not materialize IconButtons, actual {CountIconButtons(splitter)}.", + ExpectCollapseButtonsInactive(splitter, + "Hidden collapsible icons should leave template IconButtons hidden without icons.", failures); } @@ -124,8 +124,20 @@ internal static partial class Program return root.GetSelfAndVisualDescendants().OfType().Count(); } - private static int CountIconButtons(Control root) + private static void ExpectCollapseButtonsInactive(Control root, string message, ICollection failures) { - return root.GetSelfAndVisualDescendants().OfType().Count(); + var buttons = root.GetSelfAndVisualDescendants().OfType().ToList(); + Expect(buttons.Count > 0, + $"{message} Actual template IconButton count: {buttons.Count}.", + failures); + Expect(buttons.All(button => !button.IsVisible && button.Icon == null), + $"{message} Actual visible/icon state: {DescribeCollapseButtons(buttons)}.", + failures); + } + + private static string DescribeCollapseButtons(IReadOnlyList buttons) + { + return string.Join(", ", + buttons.Select(button => $"{button.Name ?? ""} visible={button.IsVisible} icon={button.Icon != null}")); } }