From af7383935169f25ec54fa602248c1e26453fbf86 Mon Sep 17 00:00:00 2001 From: Mike James Date: Mon, 13 Jul 2026 11:10:08 +0200 Subject: [PATCH] Fluent2: add compatibility audit tests and ControlCatalog integration - tests/Avalonia.Themes.Fluent2.UnitTests: the drop-in contract as tests. The live v1 theme is the baseline: every enumerated v1 resource key must resolve in Fluent2Theme in both variants with a compatible runtime type; all implicit ControlThemes present with matching TargetType; palette accent flows into WinUI accent tokens; compact density key set preserved; every themed control instantiates, lays out and renders headlessly in both variants - ControlCatalog: Fluent2 entry in the theme switcher for v1/v2 A/B testing - nuke RunCoreLibsTests runs the new test project Co-Authored-By: Claude Fable 5 --- Avalonia.slnx | 1 + nukebuild/Build.cs | 1 + samples/ControlCatalog/App.xaml | 1 + samples/ControlCatalog/App.xaml.cs | 7 + samples/ControlCatalog/ControlCatalog.csproj | 1 + samples/ControlCatalog/MainView.xaml | 1 + samples/ControlCatalog/Models/CatalogTheme.cs | 1 + .../Avalonia.Themes.Fluent2.UnitTests.csproj | 21 +++ .../InstantiationSmokeTests.cs | 79 ++++++++++ .../KeyParityTests.cs | 144 ++++++++++++++++++ .../PaletteTests.cs | 87 +++++++++++ .../TestApplication.cs | 20 +++ .../ThemeResourceWalker.cs | 76 +++++++++ 13 files changed, 440 insertions(+) create mode 100644 tests/Avalonia.Themes.Fluent2.UnitTests/Avalonia.Themes.Fluent2.UnitTests.csproj create mode 100644 tests/Avalonia.Themes.Fluent2.UnitTests/InstantiationSmokeTests.cs create mode 100644 tests/Avalonia.Themes.Fluent2.UnitTests/KeyParityTests.cs create mode 100644 tests/Avalonia.Themes.Fluent2.UnitTests/PaletteTests.cs create mode 100644 tests/Avalonia.Themes.Fluent2.UnitTests/TestApplication.cs create mode 100644 tests/Avalonia.Themes.Fluent2.UnitTests/ThemeResourceWalker.cs diff --git a/Avalonia.slnx b/Avalonia.slnx index b702b0464d..b86ca527e1 100644 --- a/Avalonia.slnx +++ b/Avalonia.slnx @@ -147,6 +147,7 @@ + diff --git a/nukebuild/Build.cs b/nukebuild/Build.cs index ad9917f84c..e09204485f 100644 --- a/nukebuild/Build.cs +++ b/nukebuild/Build.cs @@ -279,6 +279,7 @@ partial class Build : NukeBuild RunCoreTest("Avalonia.Headless.NUnit.PerTest.UnitTests"); RunCoreTest("Avalonia.Headless.XUnit.PerAssembly.UnitTests"); RunCoreTest("Avalonia.Headless.XUnit.PerTest.UnitTests"); + RunCoreTest("Avalonia.Themes.Fluent2.UnitTests"); }); Target RunRenderTests => _ => _ diff --git a/samples/ControlCatalog/App.xaml b/samples/ControlCatalog/App.xaml index 6ab30cc180..84fbadf525 100644 --- a/samples/ControlCatalog/App.xaml +++ b/samples/ControlCatalog/App.xaml @@ -30,6 +30,7 @@ + diff --git a/samples/ControlCatalog/App.xaml.cs b/samples/ControlCatalog/App.xaml.cs index 71e4113a3a..82dc4faa42 100644 --- a/samples/ControlCatalog/App.xaml.cs +++ b/samples/ControlCatalog/App.xaml.cs @@ -16,6 +16,7 @@ namespace ControlCatalog { private readonly Styles _themeStylesContainer = new(); private FluentTheme? _fluentTheme; + private Avalonia.Themes.Fluent2.Fluent2Theme? _fluent2Theme; private SimpleTheme? _simpleTheme; private IStyle? _colorPickerFluent, _colorPickerSimple; @@ -31,6 +32,7 @@ namespace ControlCatalog AvaloniaXamlLoader.Load(this); _fluentTheme = (FluentTheme)Resources["FluentTheme"]!; + _fluent2Theme = (Avalonia.Themes.Fluent2.Fluent2Theme)Resources["Fluent2Theme"]!; _simpleTheme = (SimpleTheme)Resources["SimpleTheme"]!; _colorPickerFluent = (IStyle)Resources["ColorPickerFluent"]!; _colorPickerSimple = (IStyle)Resources["ColorPickerSimple"]!; @@ -125,6 +127,11 @@ namespace ControlCatalog app._themeStylesContainer[0] = app._fluentTheme!; app._themeStylesContainer[1] = app._colorPickerFluent!; } + else if (theme == CatalogTheme.Fluent2) + { + app._themeStylesContainer[0] = app._fluent2Theme!; + app._themeStylesContainer[1] = app._colorPickerFluent!; + } else if (theme == CatalogTheme.Simple) { app._themeStylesContainer[0] = app._simpleTheme!; diff --git a/samples/ControlCatalog/ControlCatalog.csproj b/samples/ControlCatalog/ControlCatalog.csproj index 800793ca0b..c8dc7fc5d6 100644 --- a/samples/ControlCatalog/ControlCatalog.csproj +++ b/samples/ControlCatalog/ControlCatalog.csproj @@ -36,6 +36,7 @@ + diff --git a/samples/ControlCatalog/MainView.xaml b/samples/ControlCatalog/MainView.xaml index f7b2658b0f..94f6376896 100644 --- a/samples/ControlCatalog/MainView.xaml +++ b/samples/ControlCatalog/MainView.xaml @@ -124,6 +124,7 @@ ToolTip.Tip="Catalog Theme"> Fluent + Fluent2 Simple diff --git a/samples/ControlCatalog/Models/CatalogTheme.cs b/samples/ControlCatalog/Models/CatalogTheme.cs index 79b3182d20..7cd67c53f8 100644 --- a/samples/ControlCatalog/Models/CatalogTheme.cs +++ b/samples/ControlCatalog/Models/CatalogTheme.cs @@ -3,6 +3,7 @@ public enum CatalogTheme { Fluent, + Fluent2, Simple } } diff --git a/tests/Avalonia.Themes.Fluent2.UnitTests/Avalonia.Themes.Fluent2.UnitTests.csproj b/tests/Avalonia.Themes.Fluent2.UnitTests/Avalonia.Themes.Fluent2.UnitTests.csproj new file mode 100644 index 0000000000..94c8d120c3 --- /dev/null +++ b/tests/Avalonia.Themes.Fluent2.UnitTests/Avalonia.Themes.Fluent2.UnitTests.csproj @@ -0,0 +1,21 @@ + + + $(AvsCurrentTargetFramework) + Exe + true + $(DefineConstants);XUNIT + + + + + + + + + + + + + + + diff --git a/tests/Avalonia.Themes.Fluent2.UnitTests/InstantiationSmokeTests.cs b/tests/Avalonia.Themes.Fluent2.UnitTests/InstantiationSmokeTests.cs new file mode 100644 index 0000000000..6279522eb3 --- /dev/null +++ b/tests/Avalonia.Themes.Fluent2.UnitTests/InstantiationSmokeTests.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia.Controls; +using Avalonia.Headless; +using Avalonia.Styling; +using Avalonia.Threading; + +namespace Avalonia.Themes.Fluent2.UnitTests; + +/// +/// Constructs every control that has an implicit Fluent2 theme inside a headless +/// window, runs layout and renders a frame. Catches template binding errors and +/// dangling resource references that key parity alone cannot see. +/// +public class InstantiationSmokeTests +{ + [AvaloniaFact] + public void Every_themed_control_renders_under_fluent2() + { + var v1 = new Avalonia.Themes.Fluent.FluentTheme(); + var types = ThemeResourceWalker.CollectKeys(v1).OfType() + .Where(t => typeof(Control).IsAssignableFrom(t) + && !typeof(TopLevel).IsAssignableFrom(t) + && !t.IsAbstract + && t.IsPublic + && t.GetConstructor(Type.EmptyTypes) is not null) + .OrderBy(t => t.Name) + .ToList(); + + Assert.True(types.Count > 60, $"Only {types.Count} instantiable control types found."); + + var theme = new Fluent2Theme(); + var app = Application.Current!; + app.Styles.Add(theme); + var problems = new List(); + try + { + foreach (var variant in new[] { ThemeVariant.Light, ThemeVariant.Dark }) + { + var window = new Window + { + Width = 400, + Height = 300, + RequestedThemeVariant = variant, + }; + window.Show(); + try + { + foreach (var type in types) + { + try + { + var control = (Control)Activator.CreateInstance(type)!; + window.Content = control; + Dispatcher.UIThread.RunJobs(); + window.CaptureRenderedFrame()?.Dispose(); + } + catch (Exception e) + { + problems.Add($"{type.Name} ({variant}): {e.GetType().Name}: {e.Message}"); + } + } + } + finally + { + window.Close(); + } + } + } + finally + { + app.Styles.Remove(theme); + } + + Assert.True(problems.Count == 0, + $"{problems.Count} controls failed to render:\n" + string.Join("\n", problems)); + } +} diff --git a/tests/Avalonia.Themes.Fluent2.UnitTests/KeyParityTests.cs b/tests/Avalonia.Themes.Fluent2.UnitTests/KeyParityTests.cs new file mode 100644 index 0000000000..4b3576d121 --- /dev/null +++ b/tests/Avalonia.Themes.Fluent2.UnitTests/KeyParityTests.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia.Controls; +using Avalonia.Markup.Xaml.Styling; +using Avalonia.Media; +using Avalonia.Styling; + +namespace Avalonia.Themes.Fluent2.UnitTests; + +/// +/// The drop-in compatibility contract: every resource key resolvable from +/// Avalonia.Themes.Fluent (v1) must resolve from Fluent2Theme, in both theme +/// variants, with a compatible runtime type. Values are allowed to differ — +/// they are the redesign. +/// +public class KeyParityTests +{ + private static readonly ThemeVariant[] s_variants = { ThemeVariant.Light, ThemeVariant.Dark }; + + [AvaloniaFact] + public void Every_v1_resource_key_resolves_in_fluent2_with_compatible_type() + { + var v1 = new Avalonia.Themes.Fluent.FluentTheme(); + var v2 = new Fluent2Theme(); + var keys = ThemeResourceWalker.CollectKeys(v1); + + // Guard against a silent enumeration bug: v1 has well over a thousand keys. + Assert.True(keys.Count > 1000, $"Only enumerated {keys.Count} v1 keys; walker is broken."); + + var problems = new List(); + + foreach (var key in keys) + { + foreach (var variant in s_variants) + { + object? expected = null; + try + { + if (!((IResourceNode)v1).TryGetResource(key, variant, out expected)) + continue; // not resolvable in v1 for this variant — nothing to guarantee + } + catch (Exception e) + { + problems.Add($"[v1!] {key} ({variant}): {e.Message}"); + continue; + } + + object? actual = null; + try + { + if (!((IResourceNode)v2).TryGetResource(key, variant, out actual)) + { + problems.Add($"[missing] {key} ({variant})"); + continue; + } + } + catch (Exception e) + { + problems.Add($"[throws] {key} ({variant}): {e.Message}"); + continue; + } + + var expectedCategory = Category(expected); + var actualCategory = Category(actual); + if (expectedCategory != actualCategory) + problems.Add($"[type] {key} ({variant}): v1={expectedCategory} v2={actualCategory}"); + } + } + + Assert.True(problems.Count == 0, + $"{problems.Count} compatibility problems:\n" + string.Join("\n", problems.Take(50))); + } + + [AvaloniaFact] + public void Every_v1_implicit_control_theme_exists_in_fluent2() + { + var v1 = new Avalonia.Themes.Fluent.FluentTheme(); + var v2 = new Fluent2Theme(); + var types = ThemeResourceWalker.CollectKeys(v1).OfType().ToList(); + + Assert.True(types.Count > 70, $"Only enumerated {types.Count} implicit theme types; walker is broken."); + + var problems = new List(); + foreach (var type in types) + { + if (!((IResourceNode)v2).TryGetResource(type, ThemeVariant.Light, out var value)) + { + problems.Add($"[missing] {type.Name}"); + } + else if (value is not ControlTheme theme) + { + problems.Add($"[not-a-theme] {type.Name}: {value?.GetType().Name}"); + } + else if (theme.TargetType != type) + { + problems.Add($"[target-type] {type.Name}: theme targets {theme.TargetType?.Name}"); + } + } + + Assert.True(problems.Count == 0, string.Join("\n", problems)); + } + + [AvaloniaFact] + public void Compact_density_dictionary_keeps_v1_key_set() + { + var v1Compact = GetCompactStyles(new Avalonia.Themes.Fluent.FluentTheme()); + var v2Compact = GetCompactStyles(new Fluent2Theme()); + + var missing = v1Compact.Keys.Where(k => !v2Compact.ContainsKey(k)).ToList(); + Assert.True(missing.Count == 0, + "Compact density keys missing in Fluent2: " + string.Join(", ", missing)); + } + + private static ResourceDictionary GetCompactStyles(object theme) + { + // Both themes stash the keyed CompactStyles dictionary in a private field; + // there is no public way to enumerate it, and compiled XAML for plain + // resource dictionaries cannot be loaded by URI at runtime. + var field = theme.GetType().GetField("_compactStyles", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + Assert.NotNull(field); + return Assert.IsType(field!.GetValue(theme)); + } + + private static string Category(object? value) => value switch + { + null => "null", + IBrush => "brush", + Color => "color", + Thickness => "thickness", + CornerRadius => "cornerradius", + double => "double", + bool => "bool", + FontFamily => "fontfamily", + ControlTheme ct => $"controltheme({ct.TargetType?.FullName})", + Geometry => "geometry", + ITransform => "transform", + TimeSpan => "timespan", + string => "string", + int => "int", + _ => value.GetType().FullName!, + }; +} diff --git a/tests/Avalonia.Themes.Fluent2.UnitTests/PaletteTests.cs b/tests/Avalonia.Themes.Fluent2.UnitTests/PaletteTests.cs new file mode 100644 index 0000000000..246ef19af3 --- /dev/null +++ b/tests/Avalonia.Themes.Fluent2.UnitTests/PaletteTests.cs @@ -0,0 +1,87 @@ +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Styling; + +namespace Avalonia.Themes.Fluent2.UnitTests; + +/// +/// Fluent2Theme.Palettes must keep v1 semantics: a user-supplied Accent flows +/// through SystemAccentColor and its shades into the WinUI accent token brushes +/// and the per-control accent keys. +/// +public class PaletteTests +{ + [AvaloniaFact] + public void Palette_accent_flows_into_accent_tokens_and_button_keys() + { + var theme = new Fluent2Theme(); + theme.Palettes[ThemeVariant.Dark] = new ColorPaletteResources { Accent = Colors.Crimson }; + + var app = Application.Current!; + app.Styles.Add(theme); + try + { + Assert.True(app.TryGetResource("SystemAccentColor", ThemeVariant.Dark, out var accent)); + Assert.Equal(Colors.Crimson, Assert.IsType(accent)); + + // The dark-variant default accent fill is SystemAccentColorLight2 — + // whatever shade the palette computed, the brush must match it. + Assert.True(app.TryGetResource("SystemAccentColorLight2", ThemeVariant.Dark, out var light2)); + var light2Color = Assert.IsType(light2); + Assert.NotEqual(Colors.Crimson, light2Color); + + Assert.True(app.TryGetResource("AccentFillColorDefaultBrush", ThemeVariant.Dark, out var fill)); + Assert.Equal(light2Color, Assert.IsAssignableFrom(fill).Color); + + Assert.True(app.TryGetResource("AccentButtonBackground", ThemeVariant.Dark, out var buttonFill)); + Assert.Equal(light2Color, Assert.IsAssignableFrom(buttonFill).Color); + } + finally + { + app.Styles.Remove(theme); + } + } + + [AvaloniaFact] + public void Default_accent_uses_winui_static_shades() + { + var theme = new Fluent2Theme(); + var app = Application.Current!; + app.Styles.Add(theme); + try + { + Assert.True(app.TryGetResource("SystemAccentColor", ThemeVariant.Light, out var accent)); + Assert.Equal(Color.FromRgb(0, 120, 215), Assert.IsType(accent)); + + // WinUI's static shade values (SystemResources.xaml), not HSL-computed ones. + Assert.True(app.TryGetResource("SystemAccentColorLight2", ThemeVariant.Light, out var light2)); + Assert.Equal(Color.Parse("#FF76B9ED"), Assert.IsType(light2)); + Assert.True(app.TryGetResource("SystemAccentColorDark1", ThemeVariant.Light, out var dark1)); + Assert.Equal(Color.Parse("#FF005A9E"), Assert.IsType(dark1)); + } + finally + { + app.Styles.Remove(theme); + } + } + + [AvaloniaFact] + public void Compact_density_switches_metric_resources() + { + var theme = new Fluent2Theme(); + var app = Application.Current!; + app.Styles.Add(theme); + try + { + Assert.True(app.TryGetResource("TextControlThemeMinHeight", ThemeVariant.Light, out var normal)); + theme.DensityStyle = DensityStyle.Compact; + Assert.True(app.TryGetResource("TextControlThemeMinHeight", ThemeVariant.Light, out var compact)); + Assert.True((double)compact! < (double)normal!, + $"Compact min height {compact} should be smaller than normal {normal}"); + } + finally + { + app.Styles.Remove(theme); + } + } +} diff --git a/tests/Avalonia.Themes.Fluent2.UnitTests/TestApplication.cs b/tests/Avalonia.Themes.Fluent2.UnitTests/TestApplication.cs new file mode 100644 index 0000000000..cc463b91c1 --- /dev/null +++ b/tests/Avalonia.Themes.Fluent2.UnitTests/TestApplication.cs @@ -0,0 +1,20 @@ +global using Xunit; +global using Avalonia.Headless.XUnit; +using Avalonia; +using Avalonia.Headless; +using Avalonia.Themes.Fluent2.UnitTests; + +[assembly: AvaloniaTestApplication(typeof(TestApplication))] + +namespace Avalonia.Themes.Fluent2.UnitTests; + +public class TestApplication : Application +{ + public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure() + .UseHarfBuzz() + .UseSkia() + .UseHeadless(new AvaloniaHeadlessPlatformOptions + { + UseHeadlessDrawing = false + }); +} diff --git a/tests/Avalonia.Themes.Fluent2.UnitTests/ThemeResourceWalker.cs b/tests/Avalonia.Themes.Fluent2.UnitTests/ThemeResourceWalker.cs new file mode 100644 index 0000000000..e7b0f6098d --- /dev/null +++ b/tests/Avalonia.Themes.Fluent2.UnitTests/ThemeResourceWalker.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; +using Avalonia.Controls; +using Avalonia.Markup.Xaml.Styling; +using Avalonia.Styling; + +namespace Avalonia.Themes.Fluent2.UnitTests; + +/// +/// Walks a theme's style/resource tree collecting every resource key, so the +/// live Avalonia.Themes.Fluent (v1) instance can serve as the compatibility +/// baseline for Fluent2Theme. C#-provided keys (accent colors) are not +/// discoverable by walking and are listed explicitly. +/// +internal static class ThemeResourceWalker +{ + public static readonly string[] AccentKeys = + { + "SystemAccentColor", + "SystemAccentColorDark1", "SystemAccentColorDark2", "SystemAccentColorDark3", + "SystemAccentColorLight1", "SystemAccentColorLight2", "SystemAccentColorLight3", + }; + + public static HashSet CollectKeys(IStyle style) + { + var keys = new HashSet(); + Walk(style, keys); + foreach (var key in AccentKeys) + keys.Add(key); + return keys; + } + + private static void Walk(IStyle style, HashSet keys) + { + if (style is StyleBase styleBase) + Walk(styleBase.Resources, keys); + if (style is Styles styles) + Walk(styles.Resources, keys); + + foreach (var child in style.Children) + Walk(child, keys); + } + + private static void Walk(IResourceDictionary resources, HashSet keys) + { + foreach (var key in resources.Keys) + { + // The keyed CompactStyles ResourceInclude is an implementation detail + // handled by the DensityStyle tests; its inner keys are density overrides. + if (key is string s && s == "CompactStyles") + continue; + keys.Add(key); + } + + foreach (var merged in resources.MergedDictionaries) + WalkProvider(merged, keys); + + foreach (var themed in resources.ThemeDictionaries.Values) + WalkProvider(themed, keys); + } + + private static void WalkProvider(IResourceProvider provider, HashSet keys) + { + switch (provider) + { + case ResourceDictionary dictionary: + Walk(dictionary, keys); + break; + case ResourceInclude include: + Walk(include.Loaded, keys); + break; + // C# ResourceProviders (SystemAccentColors, palette collections) are + // not enumerable; their keys are covered by AccentKeys above. + } + } +}