Files
AtomUI/tests/AtomUI.Core.Tests/Theme/ThemeConfigProviderTests.cs

494 lines
18 KiB
C#

using System.Reflection;
using System.Runtime.CompilerServices;
using AtomUI.Theme;
using AtomUI.Theme.Algorithms;
using AtomUI.Theme.Compilation;
using AtomUI.Theme.Configuration;
using AtomUI.Theme.Resources;
using AtomUI.Theme.DesignTokens;
using Avalonia;
using Avalonia.Controls;
using Avalonia.LogicalTree;
using Avalonia.Media;
using Avalonia.Styling;
using Shouldly;
using Xunit;
namespace AtomUI.Core.Tests.Theme;
[Collection(ThemeConfigProviderTestCollection.Name)]
public class ThemeConfigProviderTests
{
[Fact]
public void First_Attach_Synchronously_Publishes_Context_Resources_And_Variant()
{
var manager = CreateInitializedManager();
var provider = new ThemeConfigProvider
{
Config = ConfigWithToken(nameof(DesignToken.ColorPrimary), "#ff0000"),
Child = new Border()
};
using var root = Attach(manager, provider);
var context = provider.GetValue(ThemeScope.ContextProperty).ShouldNotBeNull();
context.Snapshot.Global<Color>(nameof(DesignToken.ColorPrimary)).ShouldBe(Color.Parse("#ff0000"));
context.ResourceProvider.ShouldBeSameAs(GetTokenResourceProvider(provider));
provider.RequestedThemeVariant.ShouldBe(ThemeVariant.Light);
}
[Fact]
public void First_Attach_Continues_Context_Publish_When_Variant_Observer_Fails()
{
var manager = CreateInitializedManager();
var provider = new ThrowingVariantThemeConfigProvider
{
Config = ConfigWithToken(nameof(DesignToken.ColorPrimary), "#ff0000"),
Child = new Border()
};
ThemeChangedEventArgs? observed = null;
provider.ThemeChanged += (_, args) => observed = args;
using var root = Attach(manager, provider);
provider.ContextPublished.ShouldBeTrue();
observed.ShouldNotBeNull();
observed!.PublishDiagnostics.ShouldContain(diagnostic =>
diagnostic.Severity == ThemeDiagnosticSeverity.Warning &&
diagnostic.Message.Contains("variant update failed", StringComparison.Ordinal));
}
[Fact]
public void Config_Replacement_Updates_The_Stable_Context_And_Resource_Provider()
{
var manager = CreateInitializedManager();
var provider = new ThemeConfigProvider
{
Config = ConfigWithToken(nameof(DesignToken.ColorPrimary), "#ff0000"),
Child = new Border()
};
using var root = Attach(manager, provider);
var context = provider.GetValue(ThemeScope.ContextProperty).ShouldNotBeNull();
var resourceProvider = GetTokenResourceProvider(provider);
provider.Config = ConfigWithToken(nameof(DesignToken.ColorPrimary), "#00b96b");
provider.GetValue(ThemeScope.ContextProperty).ShouldBeSameAs(context);
GetTokenResourceProvider(provider).ShouldBeSameAs(resourceProvider);
context.Snapshot.Global<Color>(nameof(DesignToken.ColorPrimary)).ShouldBe(Color.Parse("#00b96b"));
}
[Fact]
public void Local_ThemeChanged_Uses_A_Local_Transition_State()
{
var manager = CreateInitializedManager();
var provider = new ThemeConfigProvider
{
Config = ConfigWithToken(nameof(DesignToken.ColorPrimary), "#ff0000"),
Child = new Border()
};
using var root = Attach(manager, provider);
var rootTransitionId = manager.CurrentTheme!.TransitionId;
ThemeChangedEventArgs? observed = null;
provider.ThemeChanged += (_, args) => observed = args;
provider.Config = ConfigWithToken(nameof(DesignToken.ColorPrimary), "#00b96b");
observed.ShouldNotBeNull();
observed!.Request.Reason.ShouldBe(ThemeTransitionReason.LocalConfigChanged);
observed.State.TransitionId.ShouldBeGreaterThan(rootTransitionId);
}
[Fact]
public void Nested_Providers_Inherit_The_Parent_Effective_Config()
{
var manager = CreateInitializedManager();
var child = new ThemeConfigProvider
{
Config = ConfigWithToken(nameof(DesignToken.ColorPrimary), "#00b96b"),
Child = new Border()
};
var parent = new ThemeConfigProvider
{
Config = ConfigWithToken(nameof(DesignToken.BorderRadius), "12"),
Child = child
};
using var root = Attach(manager, parent);
var snapshot = child.GetValue(ThemeScope.ContextProperty).ShouldNotBeNull().Snapshot;
snapshot.Global<Color>(nameof(DesignToken.ColorPrimary)).ShouldBe(Color.Parse("#00b96b"));
snapshot.Global<CornerRadius>(nameof(DesignToken.BorderRadius)).ShouldBe(new CornerRadius(12));
}
[Fact]
public void Empty_Inherited_Provider_Reuses_The_Parent_Snapshot()
{
var manager = CreateInitializedManager();
typeof(ThemeManager)
.GetField("_themeSnapshotCache", BindingFlags.Instance | BindingFlags.NonPublic)!
.SetValue(manager, new ThemeSnapshotCache(new ThemeCacheOptions(SnapshotRetainedBytesLimit: 1)));
var child = new ThemeConfigProvider
{
Config = new ThemeConfigBuilder().Build(),
Child = new Border()
};
var parent = new ThemeConfigProvider
{
Config = ConfigWithToken(nameof(DesignToken.BorderRadius), "12"),
Child = child
};
using var root = Attach(manager, parent);
var parentContext = parent.GetValue(ThemeScope.ContextProperty).ShouldNotBeNull();
var childContext = child.GetValue(ThemeScope.ContextProperty).ShouldNotBeNull();
childContext.ShouldNotBeSameAs(parentContext);
childContext.ResourceProvider.ShouldNotBeSameAs(parentContext.ResourceProvider);
childContext.Snapshot.ShouldBeSameAs(parentContext.Snapshot);
}
[Fact]
public void Equivalent_Inherited_Provider_Reuses_The_Parent_Snapshot()
{
var manager = CreateInitializedManager();
typeof(ThemeManager)
.GetField("_themeSnapshotCache", BindingFlags.Instance | BindingFlags.NonPublic)!
.SetValue(manager, new ThemeSnapshotCache(new ThemeCacheOptions(SnapshotRetainedBytesLimit: 1)));
var child = new ThemeConfigProvider
{
Config = ConfigWithToken(nameof(DesignToken.BorderRadius), "12"),
Child = new Border()
};
var parent = new ThemeConfigProvider
{
Config = ConfigWithToken(nameof(DesignToken.BorderRadius), "12"),
Child = child
};
using var root = Attach(manager, parent);
var parentSnapshot = parent.GetValue(ThemeScope.ContextProperty).ShouldNotBeNull().Snapshot;
child.GetValue(ThemeScope.ContextProperty)
.ShouldNotBeNull()
.Snapshot.ShouldBeSameAs(parentSnapshot);
}
[Fact]
public async Task Root_Transition_Does_Not_Notify_A_Local_Resource_Provider_Directly()
{
await HeadlessTestApp.RunAsync(async () =>
{
var prepared = CreatePrepared();
var changedPrepared = CreatePrepared(nameof(DesignToken.ColorPrimary), "#00b96b");
var manager = new ThemeManager(
static () => true,
(request, _, _) => ValueTask.FromResult(
request.ThemeId == "Changed" ? changedPrepared : prepared));
manager.ConfigureStartup(
new ThemeRequest("Test", null, ThemeTransitionReason.Startup),
null,
null);
manager.InitializeApplication(Application.Current!);
var provider = new ThemeConfigProvider
{
Config = new ThemeConfigBuilder().Build(),
Child = new Border()
};
using var root = Attach(manager, provider);
var notifications = 0;
((Avalonia.Controls.IResourceHost)provider).ResourcesChanged += (_, _) => notifications++;
await manager.ApplyThemeAsync(
new ThemeRequest("Changed", null, ThemeTransitionReason.UserRequest),
TestContext.Current.CancellationToken);
notifications.ShouldBe(0);
});
}
[Fact]
public void Inherit_False_Uses_Library_Defaults_Instead_Of_Parent_Overrides()
{
var manager = CreateInitializedManager();
var child = new ThemeConfigProvider
{
Config = new ThemeConfigBuilder().WithInherit(false).Build(),
Child = new Border()
};
var parent = new ThemeConfigProvider
{
Config = ConfigWithToken(nameof(DesignToken.BorderRadius), "12"),
Child = child
};
using var root = Attach(manager, parent);
child.GetValue(ThemeScope.ContextProperty)
.ShouldNotBeNull()
.Snapshot.Global<CornerRadius>(nameof(DesignToken.BorderRadius))
.ShouldBe(new CornerRadius(6));
}
[Fact]
public void Inherit_False_Uses_Library_Defaults_Instead_Of_Root_Effective_Config()
{
var manager = CreateInitializedManager(
CreatePrepared(nameof(DesignToken.BorderRadius), "20"));
var provider = new ThemeConfigProvider
{
Config = new ThemeConfigBuilder().WithInherit(false).Build(),
Child = new Border()
};
using var root = Attach(manager, provider);
provider.GetValue(ThemeScope.ContextProperty)
.ShouldNotBeNull()
.Snapshot.Global<CornerRadius>(nameof(DesignToken.BorderRadius))
.ShouldBe(new CornerRadius(6));
}
[Fact]
public void Local_Explicit_Algorithm_List_Replaces_Parent_Dark_Algorithms()
{
var manager = CreateInitializedManager(CreatePreparedWithAlgorithms(ThemeAlgorithm.Default, ThemeAlgorithm.Dark));
var provider = new ThemeConfigProvider
{
Config = new ThemeConfigBuilder()
.WithAlgorithms(ThemeAlgorithm.Compact)
.Build(),
Child = new Border()
};
using var root = Attach(manager, provider);
var context = provider.GetValue(ThemeScope.ContextProperty).ShouldNotBeNull();
context.Snapshot.Appearance.ShouldBe(ThemeAppearance.Light);
context.Snapshot.EffectiveConfig.Algorithms.Select(static algorithm => algorithm.Algorithm)
.ShouldBe([ThemeAlgorithm.Compact]);
provider.RequestedThemeVariant.ShouldBe(ThemeVariant.Light);
}
[Fact]
public void Invalid_Config_Keeps_The_Published_Snapshot_And_Last_Valid_Config()
{
var manager = CreateInitializedManager();
var valid = ConfigWithToken(nameof(DesignToken.ColorPrimary), "#ff0000");
var provider = new ThemeConfigProvider
{
Config = valid,
Child = new Border()
};
using var root = Attach(manager, provider);
var context = provider.GetValue(ThemeScope.ContextProperty).ShouldNotBeNull();
var snapshot = context.Snapshot;
ThemeChangeFailedEventArgs? failure = null;
provider.ThemeChangeFailed += (_, args) => failure = args;
provider.Config = ConfigWithToken("MissingToken", "1");
failure.ShouldNotBeNull();
context.Snapshot.ShouldBeSameAs(snapshot);
manager.ScopeGraph.TryGetNode(provider, out var node).ShouldBeTrue();
node!.LastValidConfig.ShouldBeSameAs(valid);
}
[Fact]
public void Detach_And_Reattach_Uses_A_New_Registration_Without_Leaving_The_Old_Edge()
{
var manager = CreateInitializedManager();
var provider = new ThemeConfigProvider
{
Config = new ThemeConfigBuilder().Build(),
Child = new Border()
};
var firstRoot = Attach(manager, provider);
manager.ScopeGraph.TryGetNode(provider, out var firstNode).ShouldBeTrue();
var firstId = firstNode!.RegistrationId;
firstRoot.Child = null;
manager.ScopeGraph.TryGetNode(provider, out _).ShouldBeFalse();
using var secondRoot = Attach(manager, provider);
manager.ScopeGraph.TryGetNode(provider, out var secondNode).ShouldBeTrue();
secondNode!.RegistrationId.ShouldBeGreaterThan(firstId);
firstRoot.Dispose();
}
[Fact]
public void Config_Replacement_Does_Not_Retain_The_Previous_Immutable_Config()
{
var manager = CreateInitializedManager();
var provider = new ThemeConfigProvider
{
Child = new Border()
};
using var root = Attach(manager, provider);
var oldConfig = SetTemporaryConfig(provider);
provider.Config = new ThemeConfigBuilder().Build();
Collect();
oldConfig.IsAlive.ShouldBeFalse();
}
[Fact]
public void Detached_Provider_Context_And_Resource_Provider_Are_Collectible()
{
var manager = CreateInitializedManager();
var references = AttachAndDetachTemporaryProvider(manager);
Collect();
references.Provider.IsAlive.ShouldBeFalse();
references.Context.IsAlive.ShouldBeFalse();
references.ResourceProvider.IsAlive.ShouldBeFalse();
}
private static ThemeManager CreateInitializedManager()
{
return CreateInitializedManager(CreatePrepared());
}
private static ThemeManager CreateInitializedManager(ThemeTransactionPreparation prepared)
{
var manager = new ThemeManager(
static () => true,
(_, _, _) => ValueTask.FromResult(prepared));
manager.ApplyThemeAsync(
new ThemeRequest("Test", null, ThemeTransitionReason.Startup))
.GetAwaiter()
.GetResult()
.Status.ShouldBe(ThemeTransitionStatus.Committed);
return manager;
}
private static ThemeTransactionPreparation CreatePrepared()
{
var registry = TypedThemeSnapshotCacheTests.CreateRegistry();
var input = TypedThemeSnapshotCacheTests.CreateInput(registry);
var snapshot = new ThemeCompiler().Compile(input).Snapshot!;
return ThemeTransactionPreparation.Succeeded(snapshot, ThemeSnapshotCacheKey.Create(input));
}
private static ThemeTransactionPreparation CreatePrepared(string tokenName, string tokenValue)
{
var registry = TypedThemeSnapshotCacheTests.CreateRegistry();
registry.TryGetGlobalToken(tokenName, out var descriptor).ShouldBeTrue();
var parsed = descriptor!.Parse(tokenValue);
var token = new NormalizedTokenValue(descriptor, parsed, descriptor.Format(parsed));
var input = TypedThemeSnapshotCacheTests.CreateInput(registry, globalTokens: [token]);
var snapshot = new ThemeCompiler().Compile(input).Snapshot!;
return ThemeTransactionPreparation.Succeeded(snapshot, ThemeSnapshotCacheKey.Create(input));
}
private static ThemeTransactionPreparation CreatePreparedWithAlgorithms(params ThemeAlgorithm[] algorithms)
{
var registry = TypedThemeSnapshotCacheTests.CreateRegistry();
var descriptors = algorithms
.Select(algorithm => registry.Algorithms.Single(descriptor => descriptor.Algorithm == algorithm))
.ToArray();
var input = TypedThemeSnapshotCacheTests.CreateInput(registry, descriptors);
var snapshot = new ThemeCompiler().Compile(input).Snapshot!;
return ThemeTransactionPreparation.Succeeded(snapshot, ThemeSnapshotCacheKey.Create(input));
}
private static ThemeConfig ConfigWithToken(string name, string value)
{
return new ThemeConfigBuilder().WithToken(name, value).Build();
}
private static ThemeTokenResourceProvider GetTokenResourceProvider(ThemeConfigProvider provider)
{
return provider.Resources.MergedDictionaries
.OfType<ThemeTokenResourceProvider>()
.ShouldHaveSingleItem();
}
private static TestRoot Attach(ThemeManager manager, Control child)
{
var root = new TestRoot();
root.SetValue(ThemeScope.ContextProperty, manager.RootContext);
root.Child = child;
return root;
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static WeakReference SetTemporaryConfig(ThemeConfigProvider provider)
{
var config = ConfigWithToken(nameof(DesignToken.ColorPrimary), "#ff0000");
provider.Config = config;
return new WeakReference(config);
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static CollectibleReferences AttachAndDetachTemporaryProvider(ThemeManager manager)
{
var root = new TestRoot();
root.SetValue(ThemeScope.ContextProperty, manager.RootContext);
var provider = new ThemeConfigProvider
{
Config = new ThemeConfigBuilder().Build(),
Child = new Border()
};
root.Child = provider;
var context = provider.GetValue(ThemeScope.ContextProperty).ShouldNotBeNull();
var resourceProvider = context.ResourceProvider;
root.Child = null;
provider.Child = null;
return new CollectibleReferences(
new WeakReference(provider),
new WeakReference(context),
new WeakReference(resourceProvider));
}
private static void Collect()
{
for (var attempt = 0; attempt < 3; attempt++)
{
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
}
}
private sealed class TestRoot : Decorator, ILogicalRoot, IDisposable
{
public void Dispose()
{
Child = null;
ClearValue(ThemeScope.ContextProperty);
}
}
private sealed class ThrowingVariantThemeConfigProvider : ThemeConfigProvider
{
private bool _throwOnNextVariantChange;
internal bool ContextPublished { get; private set; }
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == ThemeScope.ContextProperty &&
change.GetNewValue<ThemeContext?>() is { } context)
{
context.Published += (_, _) => ContextPublished = true;
_throwOnNextVariantChange = true;
}
else if (change.Property == RequestedThemeVariantProperty &&
_throwOnNextVariantChange)
{
_throwOnNextVariantChange = false;
throw new InvalidOperationException("variant update failed");
}
}
}
private sealed record CollectibleReferences(
WeakReference Provider,
WeakReference Context,
WeakReference ResourceProvider);
}