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