Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions Darling/Darling.Tests/ViewerXamlAccessibleNameTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/*
* Copyright (c) 2026 Erik Darling, Darling Data LLC
*
* This file is part of the SQL Server Performance Monitor.
*
* Licensed under the MIT License. See LICENSE file in the project root for full license information.
*/

using System.Collections.Generic;
using Xunit;

namespace Darling.Tests;

/// <summary>
/// Every <c>Button</c>, <c>ToggleButton</c>, <c>MenuItem</c> and <c>TabItem</c> in the Darling Viewer's XAML (and
/// in the <c>PerformanceMonitor.Ui</c> controls it hosts) has a UI Automation name source: an
/// <c>AutomationProperties.Name</c> / <c>LabeledBy</c>, or a <c>Content</c> / <c>Header</c> that is text. The
/// Viewer twin of Lite.Tests' <c>XamlAccessibleNameTests</c>; the definition of "named" is the shared
/// <see cref="XamlAccessibleNames"/>.
///
/// <para>Found by the source accessibility audit that followed #4684: the 600-odd <c>ColumnFilterButtonStyle</c>
/// column-header buttons had only a private-use glyph for content, and every button or tab whose content is an
/// element tree fell back to <c>ToString()</c>. The Viewer's <c>Themes/*.xaml</c> are scanned too; their
/// buttons live inside <c>ControlTemplate</c>s, which are template parts, not instances.</para>
///
/// <para>The allowlist is what the audit left: every entry names the issue that owns it, and the comparison is
/// exact in both directions, so a fixed control forces its entry out.</para>
/// </summary>
public sealed class ViewerXamlAccessibleNameTests
{
/// <summary>Remaining exceptions. Each carries the issue that tracks it.</summary>
private static readonly (string File, string Type, string Key, string Issue)[] Allowed =
[
/* PlanViewerControl.xaml is the plan-viewer lane's file (StandalonePlanViewerController.cs and PlanViewerControl*.cs
are held open for #4684 follow-ups), so the audit reports these five glyph-only buttons (zoom in, zoom out and
three close X buttons) instead of naming them. #4692 owns them. */
("PerformanceMonitor.Ui/PlanViewerControl.xaml", "Button", "ZoomIn_Click", "#4692"),
("PerformanceMonitor.Ui/PlanViewerControl.xaml", "Button", "ZoomOut_Click", "#4692"),
("PerformanceMonitor.Ui/PlanViewerControl.xaml", "Button", "CloseStatements_Click", "#4692"),
("PerformanceMonitor.Ui/PlanViewerControl.xaml", "Button", "MinimapClose_Click", "#4692"),
("PerformanceMonitor.Ui/PlanViewerControl.xaml", "Button", "CloseProperties_Click", "#4692"),
];

[Fact]
public void EveryButtonToggleMenuItemAndTab_HasAUiAutomationNameSource()
{
var result = XamlAccessibleNames.Run(
XamlAccessibleNames.RepoRoot(), "Darling/PerformanceMonitor.Darling.Viewer", "PerformanceMonitor.Ui");

/* Floors: an "all of them are named" assertion is vacuously true over a walk that found nothing. */
Assert.True(result.FileCount >= 40, $"only {result.FileCount} XAML files found; the walk is not reading the tree");
Assert.True(result.CheckedByType.GetValueOrDefault("Button") >= 600, "Button count fell below the floor; the scan stopped descending");
Assert.True(result.CheckedByType.GetValueOrDefault("TabItem") >= 60, "TabItem count fell below the floor; the scan stopped descending");
Assert.True(result.CheckedByType.GetValueOrDefault("MenuItem") >= 80, "MenuItem count fell below the floor; the scan stopped descending");

var diff = XamlAccessibleNames.Diff(result.Unnamed, Allowed);
Assert.True(diff is null, "UI Automation name sources are out of step with the allowlist:" + System.Environment.NewLine + diff);
}
}
241 changes: 241 additions & 0 deletions Darling/Darling.Tests/XamlAccessibleNames.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
/*
* Copyright (c) 2026 Erik Darling, Darling Data LLC
*
* This file is part of the SQL Server Performance Monitor.
*
* Licensed under the MIT License. See LICENSE file in the project root for full license information.
*/

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Xml;
using System.Xml.Linq;

namespace Darling.Tests;

/// <summary>
/// The scan behind the "every Button, ToggleButton, MenuItem and TabItem has a UI Automation name source"
/// pins (<c>ViewerXamlAccessibleNameTests</c> here, <c>XamlAccessibleNameTests</c> in Lite.Tests, which compiles
/// this file by link the way it compiles <c>CSharpSourceWalker</c>). One implementation, so the two apps
/// cannot drift into different definitions of "named".
///
/// <para><b>What a name source is.</b> UI Automation reads a control's Name from
/// <c>AutomationProperties.Name</c>, then <c>LabeledBy</c>, then the control's own text. A <c>Content</c> or
/// <c>Header</c> that is a STRING (or a binding or resource that resolves to one) supplies that text. Content
/// that is an element tree (a <c>StackPanel</c> holding a glyph and a label, a <c>Path</c>) does not: UIA
/// falls back to <c>ToString()</c>, which is how the plan sub-tabs came to read
/// "System.Windows.Controls.TabItem Header:..." in the UIA pass behind #4684. A string with no letter or digit
/// (a Segoe MDL2 private-use glyph, a "+", an "X", a chevron) is a name UIA can read out but no person can
/// hear, so it does not count either. Both need an explicit <c>AutomationProperties.Name</c>.</para>
///
/// <para><b>Instances only.</b> An element inside a <c>ControlTemplate</c> is a template part (the toggle
/// inside a ComboBox chrome), not a control a user meets, so the walk skips it. Buttons and menu items in a
/// <c>DataTemplate</c>, a <c>ContextMenu</c> or a <c>Style</c> setter's value are real and are checked.</para>
/// </summary>
internal static class XamlAccessibleNames
{
private static readonly XNamespace X = "http://schemas.microsoft.com/winfx/2006/xaml";

private static readonly HashSet<string> Governed = new(StringComparer.Ordinal)
{
"Button", "ToggleButton", "MenuItem", "TabItem",
};

/// <summary>One governed element that carries no name source.</summary>
internal sealed record Unnamed(string File, int Line, string Type, string Key)
{
/// <summary>The allowlist identity: file, element type and the handler or name that tells two
/// elements in one file apart. No line number, so an unrelated edit above it does not stale the entry.</summary>
public string Identity => $"{File}|{Type}|{Key}";

public override string ToString() => $"{File}:{Line} <{Type}> [{Key}]";
}

internal sealed record ScanResult(
int FileCount,
IReadOnlyDictionary<string, int> CheckedByType,
IReadOnlyList<Unnamed> Unnamed);

internal static ScanResult Run(string repoRoot, params string[] relativeRoots)
{
var checkedByType = new Dictionary<string, int>(StringComparer.Ordinal);
var unnamed = new List<Unnamed>();
var fileCount = 0;

foreach (var relativeRoot in relativeRoots)
{
var directory = Path.Combine(repoRoot, relativeRoot.Replace('/', Path.DirectorySeparatorChar));
var files = Directory.EnumerateFiles(directory, "*.xaml", SearchOption.AllDirectories)
.Where(f => !IsBuildOutput(repoRoot, f))
.OrderBy(f => f, StringComparer.Ordinal);

foreach (var file in files)
{
fileCount++;
var document = XDocument.Load(file, LoadOptions.SetLineInfo);
var relative = Path.GetRelativePath(repoRoot, file).Replace('\\', '/');

foreach (var element in document.Descendants())
{
var type = element.Name.LocalName;
if (!Governed.Contains(type))
{
continue;
}

if (element.Ancestors().Any(a => a.Name.LocalName == "ControlTemplate"))
{
continue;
}

checkedByType[type] = checkedByType.GetValueOrDefault(type) + 1;

if (!HasNameSource(element, type))
{
unnamed.Add(new Unnamed(relative, ((IXmlLineInfo)element).LineNumber, type, KeyOf(element)));
}
}
}
}

return new ScanResult(fileCount, checkedByType, unnamed);
}

private static bool HasNameSource(XElement element, string type)
{
var explicitName = element.Attribute("AutomationProperties.Name");
if (explicitName is not null && explicitName.Value.Trim().Length > 0)
{
return true;
}

if (element.Attribute("AutomationProperties.LabeledBy") is not null)
{
return true;
}

var property = type is "MenuItem" or "TabItem" ? "Header" : "Content";

var attribute = element.Attribute(property);
if (attribute is not null)
{
return IsNameText(attribute.Value);
}

var propertyElement = element.Elements().FirstOrDefault(c => c.Name.LocalName == type + "." + property);
if (propertyElement is not null)
{
return !propertyElement.HasElements && IsNameText(propertyElement.Value);
}

/* Plain text between the tags is the Content of a button; a MenuItem or TabItem body is not its name. */
if (property == "Content" && !element.Elements().Any(c => !c.Name.LocalName.Contains('.')))
{
return IsNameText(string.Concat(element.Nodes().OfType<XText>().Select(t => t.Value)));
}

return false;
}

private static bool IsNameText(string value)
{
var text = value.Trim();
if (text.Length == 0)
{
return false;
}

if (text.StartsWith("{}", StringComparison.Ordinal))
{
text = text[2..];
}
else if (text.StartsWith('{'))
{
/* A binding, StaticResource, DynamicResource or x:Static resolves to text at run time; only an
explicit null is not a name. */
return !text.StartsWith("{x:Null", StringComparison.OrdinalIgnoreCase);
}

return text.Any(char.IsLetterOrDigit);
}

private static string KeyOf(XElement element)
{
foreach (var candidate in new[] { element.Attribute("Click")?.Value, element.Attribute("Command")?.Value })
{
if (!string.IsNullOrEmpty(candidate))
{
return candidate;
}
}

return element.Attribute(X + "Name")?.Value ?? element.Attribute("Name")?.Value ?? "(anonymous)";
}

private static bool IsBuildOutput(string root, string file) =>
Path.GetRelativePath(root, file)
.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
.Any(segment => string.Equals(segment, "bin", StringComparison.OrdinalIgnoreCase)
|| string.Equals(segment, "obj", StringComparison.OrdinalIgnoreCase));

/// <summary>Walk up from the test binary to the folder holding <c>PerformanceMonitor.sln</c>, failing
/// rather than skipping: a scan that cannot find the tree enforces nothing.</summary>
internal static string RepoRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
for (var i = 0; i < 10 && directory is not null; i++)
{
if (File.Exists(Path.Combine(directory.FullName, "PerformanceMonitor.sln")))
{
return directory.FullName;
}

directory = directory.Parent;
}

throw new DirectoryNotFoundException(
"Could not locate the repository root (walked up from the test binary looking for "
+ "PerformanceMonitor.sln). This test scans the source tree, so it cannot run without it.");
}

/// <summary>The comparison both pins make: the elements found with no name source must be EXACTLY the
/// allowlist (identity and count), so a new unnamed control fails the test and a fixed one forces its
/// allowlist entry to be deleted.</summary>
internal static string? Diff(IReadOnlyList<Unnamed> found, IEnumerable<(string File, string Type, string Key, string Issue)> allowed)
{
var allowedList = allowed.ToList();
var bad = allowedList.Where(a => !a.Issue.StartsWith('#')).Select(a => a.File + "|" + a.Type + "|" + a.Key).ToList();
if (bad.Count > 0)
{
return "allowlist entries with no issue number: " + string.Join("; ", bad);
}

var foundCounts = found.GroupBy(u => u.Identity, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.Ordinal);
var allowedCounts = allowedList.GroupBy(a => a.File + "|" + a.Type + "|" + a.Key, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.Ordinal);

var lines = new List<string>();
foreach (var (identity, count) in foundCounts)
{
var allowedCount = allowedCounts.GetValueOrDefault(identity);
if (count > allowedCount)
{
var where = string.Join(", ", found.Where(u => u.Identity == identity).Select(u => "line " + u.Line));
lines.Add($"NO NAME SOURCE ({count} found, {allowedCount} allowed): {identity} ({where})");
}
}

foreach (var (identity, count) in allowedCounts)
{
if (foundCounts.GetValueOrDefault(identity) < count)
{
lines.Add($"STALE ALLOWLIST ENTRY (now named, delete it): {identity}");
}
}

return lines.Count == 0 ? null : string.Join(Environment.NewLine, lines);
}
}
14 changes: 7 additions & 7 deletions Darling/PerformanceMonitor.Darling.Viewer/AlertsHistoryTab.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -125,55 +125,55 @@
<DataGridTextColumn Binding="{Binding TimeLocal}" Width="150" SortMemberPath="AlertTime" SortDirection="Descending">
<DataGridTextColumn.Header>
<StackPanel Orientation="Horizontal">
<Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TimeLocal" Click="FilterButton_Click" Margin="0,0,4,0"/>
<Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TimeLocal" Click="FilterButton_Click" Margin="0,0,4,0" AutomationProperties.Name="Filter Time"/>
<TextBlock Text="Time" FontWeight="Bold" VerticalAlignment="Center"/>
</StackPanel>
</DataGridTextColumn.Header>
</DataGridTextColumn>
<DataGridTextColumn Binding="{Binding ServerName}" Width="140">
<DataGridTextColumn.Header>
<StackPanel Orientation="Horizontal">
<Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ServerName" Click="FilterButton_Click" Margin="0,0,4,0"/>
<Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ServerName" Click="FilterButton_Click" Margin="0,0,4,0" AutomationProperties.Name="Filter Server"/>
<TextBlock Text="Server" FontWeight="Bold" VerticalAlignment="Center"/>
</StackPanel>
</DataGridTextColumn.Header>
</DataGridTextColumn>
<DataGridTextColumn Binding="{Binding MetricName}" Width="160">
<DataGridTextColumn.Header>
<StackPanel Orientation="Horizontal">
<Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MetricName" Click="FilterButton_Click" Margin="0,0,4,0"/>
<Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MetricName" Click="FilterButton_Click" Margin="0,0,4,0" AutomationProperties.Name="Filter Alert Type"/>
<TextBlock Text="Alert Type" FontWeight="Bold" VerticalAlignment="Center"/>
</StackPanel>
</DataGridTextColumn.Header>
</DataGridTextColumn>
<DataGridTextColumn Binding="{Binding CurrentValueDisplay}" Width="160">
<DataGridTextColumn.Header>
<StackPanel Orientation="Horizontal">
<Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="CurrentValueDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/>
<Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="CurrentValueDisplay" Click="FilterButton_Click" Margin="0,0,4,0" AutomationProperties.Name="Filter Value"/>
<TextBlock Text="Value" FontWeight="Bold" VerticalAlignment="Center"/>
</StackPanel>
</DataGridTextColumn.Header>
</DataGridTextColumn>
<DataGridTextColumn Binding="{Binding ThresholdValueDisplay}" Width="120">
<DataGridTextColumn.Header>
<StackPanel Orientation="Horizontal">
<Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ThresholdValueDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/>
<Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ThresholdValueDisplay" Click="FilterButton_Click" Margin="0,0,4,0" AutomationProperties.Name="Filter Threshold"/>
<TextBlock Text="Threshold" FontWeight="Bold" VerticalAlignment="Center"/>
</StackPanel>
</DataGridTextColumn.Header>
</DataGridTextColumn>
<DataGridTextColumn Binding="{Binding NotificationType}" Width="80">
<DataGridTextColumn.Header>
<StackPanel Orientation="Horizontal">
<Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="NotificationType" Click="FilterButton_Click" Margin="0,0,4,0"/>
<Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="NotificationType" Click="FilterButton_Click" Margin="0,0,4,0" AutomationProperties.Name="Filter Channel"/>
<TextBlock Text="Channel" FontWeight="Bold" VerticalAlignment="Center"/>
</StackPanel>
</DataGridTextColumn.Header>
</DataGridTextColumn>
<DataGridTextColumn Binding="{Binding StatusDisplay}" Width="100">
<DataGridTextColumn.Header>
<StackPanel Orientation="Horizontal">
<Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="StatusDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/>
<Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="StatusDisplay" Click="FilterButton_Click" Margin="0,0,4,0" AutomationProperties.Name="Filter Status"/>
<TextBlock Text="Status" FontWeight="Bold" VerticalAlignment="Center"/>
</StackPanel>
</DataGridTextColumn.Header>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
<CheckBox IsChecked="{Binding IsExcluded, Mode=TwoWay}"
IsEnabled="{Binding IsEnabled}"
Foreground="{Binding ForegroundBrush}"
Margin="2,3">
Margin="2,3" AutomationProperties.Name="{Binding DisplayName}">
<TextBlock Text="{Binding DisplayName}"
Foreground="{Binding ForegroundBrush}"/>
</CheckBox>
Expand Down
Loading
Loading