mirror of
https://github.com/AtomUI/AtomUI.git
synced 2026-10-28 23:08:36 +08:00
494 lines
18 KiB
C#
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);
|
|
}
|