A cross-platform UI framework for .NET
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

197 lines
7.3 KiB

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));
}
[AvaloniaFact]
public void Window_scoped_dark_variant_resolves_same_values_as_theme_dark()
{
// Regression guard: brushes defined outside ThemeDictionaries that wrap
// variant-scoped colors anchor to the app variant instead of the consumer's
// scope. Every key must resolve identically whether dark is requested at
// theme level or via a window-scoped RequestedThemeVariant override.
var theme = new Fluent2Theme();
var app = Application.Current!;
app.Styles.Add(theme);
var window = new Window
{
RequestedThemeVariant = ThemeVariant.Dark,
Content = new ContentControl(),
};
window.Show();
try
{
var control = (ContentControl)window.Content!;
var problems = new List<string>();
foreach (var key in ThemeResourceWalker.CollectKeys(theme))
{
if (!((IResourceNode)theme).TryGetResource(key, ThemeVariant.Dark, out var expected))
continue;
if (expected is not (IBrush or Color))
continue;
control.TryFindResource(key, control.ActualThemeVariant, out var actual);
var expectedText = Describe(expected);
var actualText = Describe(actual);
if (expectedText != actualText)
problems.Add($"{key}: theme-dark={expectedText} window-dark={actualText}");
}
Assert.True(problems.Count == 0,
$"{problems.Count} keys resolve differently under window-scoped dark:\n" +
string.Join("\n", problems.Take(30)));
}
finally
{
window.Close();
app.Styles.Remove(theme);
}
}
private static string Describe(object? value) => value switch
{
ISolidColorBrush b => $"solid:{b.Color}",
Color c => $"color:{c}",
IBrush b => $"{b.GetType().Name}:{b.GetHashCode()}",
null => "null",
_ => value.GetType().Name,
};
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!,
};
}