Browse Source

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 <noreply@anthropic.com>
pull/21771/head
Mike James 3 months ago
parent
commit
af73839351
  1. 1
      Avalonia.slnx
  2. 1
      nukebuild/Build.cs
  3. 1
      samples/ControlCatalog/App.xaml
  4. 7
      samples/ControlCatalog/App.xaml.cs
  5. 1
      samples/ControlCatalog/ControlCatalog.csproj
  6. 1
      samples/ControlCatalog/MainView.xaml
  7. 1
      samples/ControlCatalog/Models/CatalogTheme.cs
  8. 21
      tests/Avalonia.Themes.Fluent2.UnitTests/Avalonia.Themes.Fluent2.UnitTests.csproj
  9. 79
      tests/Avalonia.Themes.Fluent2.UnitTests/InstantiationSmokeTests.cs
  10. 144
      tests/Avalonia.Themes.Fluent2.UnitTests/KeyParityTests.cs
  11. 87
      tests/Avalonia.Themes.Fluent2.UnitTests/PaletteTests.cs
  12. 20
      tests/Avalonia.Themes.Fluent2.UnitTests/TestApplication.cs
  13. 76
      tests/Avalonia.Themes.Fluent2.UnitTests/ThemeResourceWalker.cs

1
Avalonia.slnx

@ -147,6 +147,7 @@
<Project Path="tests/Avalonia.RenderTests.WpfCompare/Avalonia.RenderTests.WpfCompare.csproj" />
<Project Path="tests/Avalonia.Skia.RenderTests/Avalonia.Skia.RenderTests.csproj" />
<Project Path="tests/Avalonia.Skia.UnitTests/Avalonia.Skia.UnitTests.csproj" />
<Project Path="tests/Avalonia.Themes.Fluent2.UnitTests/Avalonia.Themes.Fluent2.UnitTests.csproj" />
<Project Path="tests/Avalonia.UnitTests/Avalonia.UnitTests.csproj" />
</Folder>
<Folder Name="/Tests/TestFiles/" />

1
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 => _ => _

1
samples/ControlCatalog/App.xaml

@ -30,6 +30,7 @@
<!-- Styles attached dynamically depending on current theme (simple or fluent) -->
<FluentTheme x:Key="FluentTheme" />
<Fluent2Theme x:Key="Fluent2Theme" />
<SimpleTheme x:Key="SimpleTheme" />
<StyleInclude x:Key="ColorPickerFluent" Source="avares://Avalonia.Controls.ColorPicker/Themes/Fluent/Fluent.xaml" />
<StyleInclude x:Key="ColorPickerSimple" Source="avares://Avalonia.Controls.ColorPicker/Themes/Simple/Simple.xaml" />

7
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!;

1
samples/ControlCatalog/ControlCatalog.csproj

@ -36,6 +36,7 @@
<ProjectReference Include="..\..\src\Avalonia.Controls.ColorPicker\Avalonia.Controls.ColorPicker.csproj" />
<ProjectReference Include="..\..\src\Avalonia.Themes.Simple\Avalonia.Themes.Simple.csproj" />
<ProjectReference Include="..\..\src\Avalonia.Themes.Fluent\Avalonia.Themes.Fluent.csproj" />
<ProjectReference Include="..\..\src\Avalonia.Themes.Fluent2\Avalonia.Themes.Fluent2.csproj" />
<ProjectReference Include="..\..\src\Avalonia.Fonts.Inter\Avalonia.Fonts.Inter.csproj" />
<ProjectReference Include="..\..\src\Skia\Avalonia.Skia\Avalonia.Skia.csproj" />
<ProjectReference Include="..\MiniMvvm\MiniMvvm.csproj" />

1
samples/ControlCatalog/MainView.xaml

@ -124,6 +124,7 @@
ToolTip.Tip="Catalog Theme">
<ComboBox.Items>
<models:CatalogTheme>Fluent</models:CatalogTheme>
<models:CatalogTheme>Fluent2</models:CatalogTheme>
<models:CatalogTheme>Simple</models:CatalogTheme>
</ComboBox.Items>
</ComboBox>

1
samples/ControlCatalog/Models/CatalogTheme.cs

@ -3,6 +3,7 @@
public enum CatalogTheme
{
Fluent,
Fluent2,
Simple
}
}

21
tests/Avalonia.Themes.Fluent2.UnitTests/Avalonia.Themes.Fluent2.UnitTests.csproj

@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>$(AvsCurrentTargetFramework)</TargetFramework>
<OutputType>Exe</OutputType>
<IsTestProject>true</IsTestProject>
<DefineConstants>$(DefineConstants);XUNIT</DefineConstants>
</PropertyGroup>
<Import Project="..\..\build\UnitTests.NetCore.targets" />
<Import Project="..\..\build\UnitTests.NetFX.props" />
<Import Project="..\..\build\XUnit.props" />
<Import Project="..\..\build\SharedVersion.props" />
<ItemGroup>
<ProjectReference Include="..\..\src\Avalonia.Themes.Fluent\Avalonia.Themes.Fluent.csproj" />
<ProjectReference Include="..\..\src\Avalonia.Themes.Fluent2\Avalonia.Themes.Fluent2.csproj" />
<ProjectReference Include="..\..\src\HarfBuzz\Avalonia.HarfBuzz\Avalonia.HarfBuzz.csproj" />
<ProjectReference Include="..\..\src\Headless\Avalonia.Headless.XUnit\Avalonia.Headless.XUnit.csproj" />
<ProjectReference Include="..\..\src\Skia\Avalonia.Skia\Avalonia.Skia.csproj" />
</ItemGroup>
</Project>

79
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;
/// <summary>
/// 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.
/// </summary>
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<Type>()
.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<string>();
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));
}
}

144
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;
/// <summary>
/// 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.
/// </summary>
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<string>();
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<Type>().ToList();
Assert.True(types.Count > 70, $"Only enumerated {types.Count} implicit theme types; walker is broken.");
var problems = new List<string>();
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<ResourceDictionary>(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!,
};
}

87
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;
/// <summary>
/// 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.
/// </summary>
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<Color>(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<Color>(light2);
Assert.NotEqual(Colors.Crimson, light2Color);
Assert.True(app.TryGetResource("AccentFillColorDefaultBrush", ThemeVariant.Dark, out var fill));
Assert.Equal(light2Color, Assert.IsAssignableFrom<ISolidColorBrush>(fill).Color);
Assert.True(app.TryGetResource("AccentButtonBackground", ThemeVariant.Dark, out var buttonFill));
Assert.Equal(light2Color, Assert.IsAssignableFrom<ISolidColorBrush>(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<Color>(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<Color>(light2));
Assert.True(app.TryGetResource("SystemAccentColorDark1", ThemeVariant.Light, out var dark1));
Assert.Equal(Color.Parse("#FF005A9E"), Assert.IsType<Color>(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);
}
}
}

20
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<TestApplication>()
.UseHarfBuzz()
.UseSkia()
.UseHeadless(new AvaloniaHeadlessPlatformOptions
{
UseHeadlessDrawing = false
});
}

76
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;
/// <summary>
/// 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.
/// </summary>
internal static class ThemeResourceWalker
{
public static readonly string[] AccentKeys =
{
"SystemAccentColor",
"SystemAccentColorDark1", "SystemAccentColorDark2", "SystemAccentColorDark3",
"SystemAccentColorLight1", "SystemAccentColorLight2", "SystemAccentColorLight3",
};
public static HashSet<object> CollectKeys(IStyle style)
{
var keys = new HashSet<object>();
Walk(style, keys);
foreach (var key in AccentKeys)
keys.Add(key);
return keys;
}
private static void Walk(IStyle style, HashSet<object> 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<object> 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<object> 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.
}
}
}
Loading…
Cancel
Save