mirror of
https://github.com/AtomUI/AtomUI.git
synced 2026-10-26 11:10:34 +08:00
441 lines
16 KiB
C#
441 lines
16 KiB
C#
using System.Text;
|
|
using AtomUI.Theme;
|
|
using AtomUI.Theme.Definitions;
|
|
using AtomUI.Theme.DesignTokens;
|
|
using Avalonia;
|
|
using Avalonia.Media;
|
|
using Shouldly;
|
|
using Xunit;
|
|
|
|
namespace AtomUI.Core.Tests.Theme;
|
|
|
|
[Collection(ThemeConfigProviderTestCollection.Name)]
|
|
public class CompiledThemeCatalogResolverTests
|
|
{
|
|
[Fact]
|
|
public async Task Manager_Merges_Resolvers_In_Registration_And_Source_Order()
|
|
{
|
|
await HeadlessTestApp.RunAsync(async () =>
|
|
{
|
|
var green = new MemorySource(
|
|
"memory://green",
|
|
"7",
|
|
ThemeXml("PolarGreen", "Polar Green", "#52C41A"));
|
|
var orange = new MemorySource(
|
|
"memory://orange",
|
|
"3",
|
|
ThemeXml("SunsetOrange", "Sunset Orange", "#FA8C16"));
|
|
var builder = new ThemeManagerBuilder(Application.Current!);
|
|
builder.AddThemeDefinitionResolver(new MemoryResolver("Gallery", green, orange));
|
|
var manager = builder.Build();
|
|
|
|
manager.InitializeApplication(Application.Current!);
|
|
|
|
manager.AvailableThemes.Select(static theme => theme.Id).ShouldBe([
|
|
IThemeManager.DEFAULT_THEME_ID,
|
|
"PolarGreen",
|
|
"SunsetOrange"
|
|
]);
|
|
manager.AvailableThemes
|
|
.Single(static theme => theme.Id == "PolarGreen")
|
|
.AccentColor.ShouldBe(Color.Parse("#52C41A"));
|
|
green.OpenCount.ShouldBe(1);
|
|
orange.OpenCount.ShouldBe(1);
|
|
manager.CompiledThemeCatalog.Get("PolarGreen").Revision.ShouldBe(
|
|
new ThemeDefinitionRevision("memory://green", "7", green.ContentDigest));
|
|
|
|
var result = await manager.ApplyThemeAsync(
|
|
new ThemeRequest("PolarGreen", null, ThemeTransitionReason.UserRequest));
|
|
|
|
result.Status.ShouldBe(ThemeTransitionStatus.Committed);
|
|
manager.CurrentTheme!.ThemeId.ShouldBe("PolarGreen");
|
|
manager.CurrentSnapshot!.Global<Color>(nameof(DesignToken.ColorPrimary))
|
|
.ShouldBe(Color.Parse("#52C41A"));
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Reload_Reuses_A_Successfully_Read_Definition_Revision()
|
|
{
|
|
await HeadlessTestApp.RunAsync(async () =>
|
|
{
|
|
var source = new MemorySource(
|
|
"memory://reload-cache",
|
|
"1",
|
|
ThemeXml("ReloadCache", "Reload Cache", "#52C41A"));
|
|
var builder = new ThemeManagerBuilder(Application.Current!);
|
|
builder.AddThemeDefinitionResolver(new ReloadableMemoryResolver(source));
|
|
var manager = builder.Build();
|
|
manager.InitializeApplication(Application.Current!);
|
|
|
|
source.OpenCount.ShouldBe(1);
|
|
var result = await manager.ReloadThemesAsync(TestContext.Current.CancellationToken);
|
|
|
|
result.Status.ShouldBe(ThemeCatalogReloadStatus.NoOp);
|
|
source.OpenCount.ShouldBe(2);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Definition_Cache_Reuses_Reads_And_Keys_Bindings_By_Registry_Revision()
|
|
{
|
|
var source = new MemorySource(
|
|
"memory://registry-cache",
|
|
"1",
|
|
ThemeXml("RegistryCache", "Registry Cache", "#52C41A", isDefault: true));
|
|
var resolver = new MemoryResolver("RegistryCache", source);
|
|
var context = new ThemeDefinitionResolveContext("Tests", string.Empty, false, 0);
|
|
var firstRegistry = TypedThemeSnapshotCacheTests.CreateRegistry();
|
|
var secondRegistry = TypedThemeSnapshotCacheTests.CreateRegistry(
|
|
[ThemeCompilerTests.CreateCompilerButtonDescriptor()]);
|
|
using var cache = new ThemeDefinitionLoadCache();
|
|
|
|
var first = CompiledThemeCatalog.LoadInitial(
|
|
firstRegistry,
|
|
[resolver],
|
|
context,
|
|
cache);
|
|
var sameRegistry = CompiledThemeCatalog.LoadInitial(
|
|
firstRegistry,
|
|
[resolver],
|
|
context,
|
|
cache);
|
|
var nextRegistry = CompiledThemeCatalog.LoadInitial(
|
|
secondRegistry,
|
|
[resolver],
|
|
context,
|
|
cache);
|
|
|
|
first.Success.ShouldBeTrue();
|
|
sameRegistry.Success.ShouldBeTrue();
|
|
nextRegistry.Success.ShouldBeTrue();
|
|
source.OpenCount.ShouldBe(3);
|
|
sameRegistry.Catalog!.Get("RegistryCache").Definition
|
|
.ShouldBeSameAs(first.Catalog!.Get("RegistryCache").Definition);
|
|
nextRegistry.Catalog!.Get("RegistryCache").Definition
|
|
.ShouldNotBeSameAs(first.Catalog.Get("RegistryCache").Definition);
|
|
}
|
|
|
|
[Fact]
|
|
public void Definition_Cache_Rebinds_When_Source_Content_Changes_With_The_Same_Revision()
|
|
{
|
|
var source = new MutableMemorySource(
|
|
"memory://mutable-cache",
|
|
"1",
|
|
ThemeXml("MutableCache", "Mutable Cache", "#52C41A", isDefault: true));
|
|
var resolver = new MutableMemoryResolver(source);
|
|
var context = new ThemeDefinitionResolveContext("Tests", string.Empty, false, 0);
|
|
var registry = TypedThemeSnapshotCacheTests.CreateRegistry();
|
|
using var cache = new ThemeDefinitionLoadCache();
|
|
|
|
var first = CompiledThemeCatalog.LoadInitial(
|
|
registry,
|
|
[resolver],
|
|
context,
|
|
cache);
|
|
source.Replace(ThemeXml("MutableCache", "Mutable Cache", "#FA8C16", isDefault: true));
|
|
var second = CompiledThemeCatalog.LoadInitial(
|
|
registry,
|
|
[resolver],
|
|
context,
|
|
cache);
|
|
|
|
first.Success.ShouldBeTrue();
|
|
second.Success.ShouldBeTrue();
|
|
source.OpenCount.ShouldBe(2);
|
|
second.Catalog!.Get("MutableCache").Revision.ContentDigest
|
|
.ShouldNotBe(first.Catalog!.Get("MutableCache").Revision.ContentDigest);
|
|
second.Catalog.Get("MutableCache").Definition
|
|
.ShouldNotBeSameAs(first.Catalog.Get("MutableCache").Definition);
|
|
second.Catalog.Get("MutableCache").Definition.Tokens
|
|
.Single(static token => token.Descriptor.Name == nameof(DesignToken.ColorPrimary))
|
|
.Value
|
|
.ShouldBe(Color.Parse("#FA8C16"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Definition_Cache_Reuses_The_Parsed_Document_When_Only_Source_Revision_Changes()
|
|
{
|
|
var source = new MutableMemorySource(
|
|
"memory://revision-only-cache",
|
|
"1",
|
|
ThemeXml("RevisionOnlyCache", "Revision Only Cache", "#52C41A", isDefault: true));
|
|
var resolver = new MutableMemoryResolver(source);
|
|
var context = new ThemeDefinitionResolveContext("Tests", string.Empty, false, 0);
|
|
var registry = TypedThemeSnapshotCacheTests.CreateRegistry();
|
|
using var cache = new ThemeDefinitionLoadCache();
|
|
|
|
var first = CompiledThemeCatalog.LoadInitial(registry, [resolver], context, cache);
|
|
var digest = first.Catalog!.Get("RevisionOnlyCache").Revision.ContentDigest;
|
|
cache.TryGetRead(
|
|
new ThemeSourceCacheKey(source.SourceIdentity, digest),
|
|
out var firstRead)
|
|
.ShouldBeTrue();
|
|
source.SetRevision("2");
|
|
|
|
var second = CompiledThemeCatalog.LoadInitial(registry, [resolver], context, cache);
|
|
|
|
second.Success.ShouldBeTrue();
|
|
source.OpenCount.ShouldBe(2);
|
|
second.Catalog!.Get("RevisionOnlyCache").Revision.SourceRevision.ShouldBe("2");
|
|
cache.TryGetRead(
|
|
new ThemeSourceCacheKey(source.SourceIdentity, digest),
|
|
out var secondRead)
|
|
.ShouldBeTrue();
|
|
secondRead.ShouldBeSameAs(firstRead);
|
|
}
|
|
|
|
[Fact]
|
|
public void Duplicate_Theme_Id_Across_Static_Resolvers_Fails_Startup()
|
|
{
|
|
HeadlessTestApp.Run(() =>
|
|
{
|
|
var builder = new ThemeManagerBuilder(Application.Current!);
|
|
builder.AddThemeDefinitionResolver(new MemoryResolver(
|
|
"Gallery",
|
|
new MemorySource(
|
|
"memory://duplicate",
|
|
"1",
|
|
ThemeXml(IThemeManager.DEFAULT_THEME_ID, "Duplicate", "#52C41A"))));
|
|
var manager = builder.Build();
|
|
|
|
var exception = Should.Throw<ThemeLoadException>(() =>
|
|
manager.InitializeApplication(Application.Current!));
|
|
|
|
exception.Message.ShouldContain("DaybreakBlue");
|
|
exception.Message.ToLowerInvariant().ShouldContain("duplicate");
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Second_Default_Theme_In_A_Static_Resolver_Fails_Startup()
|
|
{
|
|
HeadlessTestApp.Run(() =>
|
|
{
|
|
var builder = new ThemeManagerBuilder(Application.Current!);
|
|
builder.AddThemeDefinitionResolver(new MemoryResolver(
|
|
"Gallery",
|
|
new MemorySource(
|
|
"memory://second-default",
|
|
"1",
|
|
ThemeXml("OtherDefault", "Other Default", "#52C41A", isDefault: true))));
|
|
var manager = builder.Build();
|
|
|
|
var exception = Should.Throw<ThemeLoadException>(() =>
|
|
manager.InitializeApplication(Application.Current!));
|
|
|
|
exception.Message.ToLowerInvariant().ShouldContain("default");
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Invalid_Static_Source_Fails_Startup_Without_Opening_Other_Sources_Twice()
|
|
{
|
|
HeadlessTestApp.Run(() =>
|
|
{
|
|
var valid = new MemorySource(
|
|
"memory://valid",
|
|
"1",
|
|
ThemeXml("Valid", "Valid", "#52C41A"));
|
|
var invalid = new MemorySource("memory://invalid", "1", "<Theme>");
|
|
var builder = new ThemeManagerBuilder(Application.Current!);
|
|
builder.AddThemeDefinitionResolver(new MemoryResolver("Gallery", valid, invalid));
|
|
var manager = builder.Build();
|
|
|
|
Should.Throw<ThemeLoadException>(() =>
|
|
manager.InitializeApplication(Application.Current!));
|
|
|
|
valid.OpenCount.ShouldBe(1);
|
|
invalid.OpenCount.ShouldBe(1);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Successful_Resolver_Warnings_Are_Preserved_In_Catalog_Diagnostics()
|
|
{
|
|
HeadlessTestApp.Run(() =>
|
|
{
|
|
var builder = new ThemeManagerBuilder(Application.Current!);
|
|
builder.AddThemeDefinitionResolver(new WarningResolver(
|
|
new MemorySource(
|
|
"memory://warning-theme",
|
|
"1",
|
|
ThemeXml("WarningTheme", "Warning Theme", "#52C41A"))));
|
|
var manager = builder.Build();
|
|
|
|
manager.InitializeApplication(Application.Current!);
|
|
|
|
manager.ThemeCatalogDiagnostics.ShouldContain(static diagnostic =>
|
|
diagnostic.Code == "TEST-WARNING" &&
|
|
diagnostic.Severity == ThemeDiagnosticSeverity.Warning);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Theme_Without_Explicit_ColorPrimary_Has_No_Accent_Color()
|
|
{
|
|
HeadlessTestApp.Run(() =>
|
|
{
|
|
var builder = new ThemeManagerBuilder(Application.Current!);
|
|
builder.AddThemeDefinitionResolver(new MemoryResolver(
|
|
"NoAccent",
|
|
new MemorySource(
|
|
"memory://no-accent",
|
|
"1",
|
|
ThemeXmlWithoutTokens("NoAccent", "No Accent"))));
|
|
var manager = builder.Build();
|
|
|
|
manager.InitializeApplication(Application.Current!);
|
|
|
|
manager.AvailableThemes
|
|
.Single(static theme => theme.Id == "NoAccent")
|
|
.AccentColor.ShouldBeNull();
|
|
});
|
|
}
|
|
|
|
private static string ThemeXml(
|
|
string id,
|
|
string name,
|
|
string colorPrimary,
|
|
bool isDefault = false)
|
|
{
|
|
var defaultAttribute = isDefault ? " IsDefault=\"true\"" : string.Empty;
|
|
return $$"""
|
|
<Theme xmlns="https://atomui.net/schemas/theme/v1"
|
|
Id="{{id}}"
|
|
Name="{{name}}"
|
|
Appearance="Light"{{defaultAttribute}}>
|
|
<Algorithms><Algorithm Id="Default" /></Algorithms>
|
|
<Tokens><Token Name="ColorPrimary" Value="{{colorPrimary}}" /></Tokens>
|
|
</Theme>
|
|
""";
|
|
}
|
|
|
|
private static string ThemeXmlWithoutTokens(string id, string name)
|
|
{
|
|
return $$"""
|
|
<Theme xmlns="https://atomui.net/schemas/theme/v1"
|
|
Id="{{id}}"
|
|
Name="{{name}}"
|
|
Appearance="Light">
|
|
<Algorithms><Algorithm Id="Default" /></Algorithms>
|
|
</Theme>
|
|
""";
|
|
}
|
|
|
|
private sealed class MemoryResolver(
|
|
string id,
|
|
params MemorySource[] sources) : IThemeDefinitionResolver
|
|
{
|
|
public string Id { get; } = id;
|
|
public bool SupportsReload => false;
|
|
|
|
public ThemeDefinitionResolveResult Resolve(ThemeDefinitionResolveContext context)
|
|
{
|
|
return new ThemeDefinitionResolveResult(sources, []);
|
|
}
|
|
}
|
|
|
|
private sealed class WarningResolver(MemorySource source) : IThemeDefinitionResolver
|
|
{
|
|
public string Id => "WarningResolver";
|
|
public bool SupportsReload => false;
|
|
|
|
public ThemeDefinitionResolveResult Resolve(ThemeDefinitionResolveContext context)
|
|
{
|
|
return new ThemeDefinitionResolveResult(
|
|
[source],
|
|
[new ThemeDiagnostic(
|
|
"TEST-WARNING",
|
|
ThemeDiagnosticSeverity.Warning,
|
|
Id,
|
|
"$",
|
|
"warning")]);
|
|
}
|
|
}
|
|
|
|
private sealed class ReloadableMemoryResolver(MemorySource source) : IThemeDefinitionResolver
|
|
{
|
|
public string Id => "ReloadableMemory";
|
|
public bool SupportsReload => true;
|
|
|
|
public ThemeDefinitionResolveResult Resolve(ThemeDefinitionResolveContext context)
|
|
{
|
|
return new ThemeDefinitionResolveResult([source], []);
|
|
}
|
|
}
|
|
|
|
private sealed class MutableMemoryResolver(MutableMemorySource source) : IThemeDefinitionResolver
|
|
{
|
|
public string Id => "MutableMemory";
|
|
public bool SupportsReload => false;
|
|
|
|
public ThemeDefinitionResolveResult Resolve(ThemeDefinitionResolveContext context)
|
|
{
|
|
return new ThemeDefinitionResolveResult([source], []);
|
|
}
|
|
}
|
|
|
|
private sealed class MemorySource : IThemeDefinitionSource
|
|
{
|
|
private readonly byte[] _bytes;
|
|
|
|
internal MemorySource(
|
|
string identity,
|
|
string revision,
|
|
string xml)
|
|
{
|
|
SourceIdentity = identity;
|
|
SourceRevision = revision;
|
|
_bytes = Encoding.UTF8.GetBytes(xml);
|
|
ContentDigest = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(_bytes));
|
|
}
|
|
|
|
public string SourceIdentity { get; }
|
|
public string SourceRevision { get; }
|
|
internal string ContentDigest { get; }
|
|
internal int OpenCount { get; private set; }
|
|
|
|
public Stream OpenRead()
|
|
{
|
|
OpenCount++;
|
|
return new MemoryStream(_bytes, writable: false);
|
|
}
|
|
}
|
|
|
|
private sealed class MutableMemorySource : IThemeDefinitionSource
|
|
{
|
|
private byte[] _bytes = [];
|
|
|
|
internal MutableMemorySource(
|
|
string identity,
|
|
string revision,
|
|
string xml)
|
|
{
|
|
SourceIdentity = identity;
|
|
SourceRevision = revision;
|
|
Replace(xml);
|
|
}
|
|
|
|
public string SourceIdentity { get; }
|
|
public string SourceRevision { get; private set; }
|
|
internal int OpenCount { get; private set; }
|
|
|
|
internal void Replace(string xml)
|
|
{
|
|
_bytes = Encoding.UTF8.GetBytes(xml);
|
|
}
|
|
|
|
internal void SetRevision(string revision)
|
|
{
|
|
SourceRevision = revision;
|
|
}
|
|
|
|
public Stream OpenRead()
|
|
{
|
|
OpenCount++;
|
|
return new MemoryStream(_bytes, writable: false);
|
|
}
|
|
}
|
|
}
|