Browse Source
- 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
13 changed files with 440 additions and 0 deletions
@ -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> |
|||
@ -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)); |
|||
} |
|||
} |
|||
@ -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!, |
|||
}; |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
} |
|||
@ -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 |
|||
}); |
|||
} |
|||
@ -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…
Reference in new issue