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

618 lines
25 KiB
C#

using System.Reflection;
using AtomUI.Theme;
using AtomUI.Theme.Compilation;
using AtomUI.Theme.Configuration;
using AtomUI.Theme.Resources;
using AtomUI.Theme.DesignTokens;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Logging;
using Avalonia.LogicalTree;
using Avalonia.Styling;
using Shouldly;
using Xunit;
namespace AtomUI.Core.Tests.Theme;
[Collection(ThemeConfigProviderTestCollection.Name)]
public class ThemeManagerTransactionTests
{
[Fact]
public async Task Root_Commit_Publishes_One_Atomic_State()
{
var prepared = CreatePrepared("First", "#1677ff");
var manager = CreateManager((_, _, _) => ValueTask.FromResult(prepared));
ThemeChangedEventArgs? observed = null;
manager.ThemeChanged += (_, args) =>
{
observed = args;
manager.CurrentTheme.ShouldBeSameAs(args.State);
manager.CurrentSnapshot.ShouldBeSameAs(prepared.Snapshot);
};
var result = await ApplyAsync(manager, Request("First"));
result.Status.ShouldBe(ThemeTransitionStatus.Committed);
result.State.ShouldBeSameAs(manager.CurrentTheme);
result.State!.TransitionId.ShouldBe(result.TransitionId);
observed.ShouldNotBeNull();
observed!.Request.ThemeId.ShouldBe("First");
observed.State.ShouldBeSameAs(result.State);
}
[Fact]
public async Task Equivalent_Committed_Request_Is_A_NoOp_Without_Preparing_Again()
{
var prepared = CreatePrepared("First", "#1677ff");
var attempts = 0;
var manager = CreateManager((_, _, _) =>
{
attempts++;
return ValueTask.FromResult(prepared);
});
var changed = 0;
manager.ThemeChanged += (_, _) => changed++;
var first = await ApplyAsync(manager, Request("First"));
var second = await ApplyAsync(manager, Request("First"));
first.Status.ShouldBe(ThemeTransitionStatus.Committed);
second.Status.ShouldBe(ThemeTransitionStatus.NoOp);
second.State.ShouldBeSameAs(first.State);
attempts.ShouldBe(1);
changed.ShouldBe(1);
}
[Fact]
public async Task Equivalent_Concurrent_Requests_Join_One_InFlight_Transaction()
{
var prepared = CreatePrepared("First", "#1677ff");
var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var attempts = 0;
var manager = CreateManager(async (_, _, token) =>
{
token.ShouldBe(CancellationToken.None);
attempts++;
started.SetResult();
await release.Task;
return prepared;
});
var firstTask = ApplyAsync(manager, Request("First"));
await started.Task;
var secondTask = ApplyAsync(manager, Request("First"));
release.SetResult();
var first = await firstTask;
var second = await secondTask;
attempts.ShouldBe(1);
second.TransitionId.ShouldBe(first.TransitionId);
second.ShouldBeSameAs(first);
}
[Fact]
public async Task Caller_Cancellation_Does_Not_Cancel_The_Shared_Transaction()
{
var prepared = CreatePrepared("First", "#1677ff");
var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var attempts = 0;
var manager = CreateManager(async (_, _, token) =>
{
token.ShouldBe(CancellationToken.None);
attempts++;
started.SetResult();
await release.Task;
return prepared;
});
using var cancellation = new CancellationTokenSource();
var canceledWaiter = manager.ApplyThemeAsync(Request("First"), cancellation.Token);
await started.Task;
var survivingWaiter = ApplyAsync(manager, Request("First"));
cancellation.Cancel();
await Should.ThrowAsync<OperationCanceledException>(async () => await canceledWaiter);
release.SetResult();
(await survivingWaiter).Status.ShouldBe(ThemeTransitionStatus.Committed);
attempts.ShouldBe(1);
manager.CurrentTheme.ShouldNotBeNull();
}
[Fact]
public async Task Newer_Request_Supersedes_Stale_Preparation_Before_Commit()
{
var firstPrepared = CreatePrepared("First", "#1677ff");
var secondPrepared = CreatePrepared("Second", "#52c41a");
var firstStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var releaseFirst = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var manager = CreateManager(async (request, _, _) =>
{
if (request.ThemeId == "First")
{
firstStarted.SetResult();
await releaseFirst.Task;
return firstPrepared;
}
return secondPrepared;
});
var firstTask = ApplyAsync(manager, Request("First"));
await firstStarted.Task;
var secondTask = ApplyAsync(manager, Request("Second"));
releaseFirst.SetResult();
var first = await firstTask;
var second = await secondTask;
first.Status.ShouldBe(ThemeTransitionStatus.Superseded);
second.Status.ShouldBe(ThemeTransitionStatus.Committed);
manager.CurrentTheme!.ThemeId.ShouldBe("Second");
manager.CurrentSnapshot.ShouldBeSameAs(secondPrepared.Snapshot);
}
[Fact]
public async Task Stale_Preparation_Failure_Is_Superseded_Without_A_Failure_Event()
{
var secondPrepared = CreatePrepared("Second", "#52c41a");
var firstStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var releaseFirst = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var failure = ThemeTransactionPreparation.Failed(
[new ThemeDiagnostic("TEST002", ThemeDiagnosticSeverity.Error, "Test", "$", "stale failure")]);
var manager = CreateManager(async (request, _, _) =>
{
if (request.ThemeId == "First")
{
firstStarted.SetResult();
await releaseFirst.Task;
return failure;
}
return secondPrepared;
});
var failureEvents = 0;
manager.ThemeChangeFailed += (_, _) => failureEvents++;
var firstTask = ApplyAsync(manager, Request("First"));
await firstStarted.Task;
var secondTask = ApplyAsync(manager, Request("Second"));
releaseFirst.SetResult();
(await firstTask).Status.ShouldBe(ThemeTransitionStatus.Superseded);
(await secondTask).Status.ShouldBe(ThemeTransitionStatus.Committed);
failureEvents.ShouldBe(0);
}
[Fact]
public async Task Pending_Requests_Coalesce_To_The_Latest_Request()
{
var firstPrepared = CreatePrepared("First", "#1677ff");
var thirdPrepared = CreatePrepared("Third", "#722ed1");
var firstStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var releaseFirst = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var preparedThemes = new List<string>();
var manager = CreateManager(async (request, _, _) =>
{
preparedThemes.Add(request.ThemeId);
if (request.ThemeId == "First")
{
firstStarted.SetResult();
await releaseFirst.Task;
return firstPrepared;
}
return thirdPrepared;
});
var firstTask = ApplyAsync(manager, Request("First"));
await firstStarted.Task;
var secondTask = ApplyAsync(manager, Request("Second"));
var thirdTask = ApplyAsync(manager, Request("Third"));
releaseFirst.SetResult();
(await firstTask).Status.ShouldBe(ThemeTransitionStatus.Superseded);
(await secondTask).Status.ShouldBe(ThemeTransitionStatus.Superseded);
(await thirdTask).Status.ShouldBe(ThemeTransitionStatus.Committed);
preparedThemes.ShouldBe(["First", "Third"]);
}
[Fact]
public async Task Latest_Request_Replaces_Pending_Even_When_It_Equals_The_Active_Request()
{
var firstPrepared = CreatePrepared("First", "#1677ff");
var firstStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var releaseFirst = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var preparedThemes = new List<string>();
var manager = CreateManager(async (request, _, _) =>
{
preparedThemes.Add(request.ThemeId);
if (preparedThemes.Count == 1)
{
firstStarted.SetResult();
await releaseFirst.Task;
}
return firstPrepared;
});
var activeFirst = ApplyAsync(manager, Request("First"));
await firstStarted.Task;
var pendingSecond = ApplyAsync(manager, Request("Second"));
var latestFirst = ApplyAsync(manager, Request("First"));
releaseFirst.SetResult();
(await activeFirst).Status.ShouldBe(ThemeTransitionStatus.Superseded);
(await pendingSecond).Status.ShouldBe(ThemeTransitionStatus.Superseded);
(await latestFirst).Status.ShouldBe(ThemeTransitionStatus.Committed);
preparedThemes.ShouldBe(["First", "First"]);
manager.CurrentTheme!.ThemeId.ShouldBe("First");
}
[Fact]
public async Task Reentrant_Request_Is_Queued_Until_The_Current_Publish_Completes()
{
var firstPrepared = CreatePrepared("First", "#1677ff");
var secondPrepared = CreatePrepared("Second", "#52c41a");
var manager = CreateManager((request, _, _) => ValueTask.FromResult(
request.ThemeId == "First" ? firstPrepared : secondPrepared));
var events = new List<string>();
Task<ThemeTransitionResult>? reentrant = null;
manager.ThemeChanged += (_, args) =>
{
events.Add(args.State.ThemeId);
if (args.State.ThemeId == "First")
{
reentrant = ApplyAsync(manager, Request("Second"));
reentrant.IsCompleted.ShouldBeFalse();
}
};
var first = await ApplyAsync(manager, Request("First"));
var second = await reentrant!;
first.Status.ShouldBe(ThemeTransitionStatus.Committed);
second.Status.ShouldBe(ThemeTransitionStatus.Committed);
events.ShouldBe(["First", "Second"]);
}
[Fact]
public async Task Publish_Observer_Failure_Does_Not_Roll_Back_And_Later_Observers_Run()
{
var prepared = CreatePrepared("First", "#1677ff");
var manager = CreateManager((_, _, _) => ValueTask.FromResult(prepared));
var laterObserverRan = false;
var previousSink = Logger.Sink;
var sink = new RecordingLogSink();
Logger.Sink = sink;
manager.ThemeChanged += (_, _) => throw new InvalidOperationException("observer failure");
manager.ThemeChanged += (_, args) =>
{
laterObserverRan = true;
manager.CurrentTheme.ShouldBeSameAs(args.State);
manager.CurrentSnapshot.ShouldBeSameAs(prepared.Snapshot);
};
ThemeTransitionResult result;
try
{
result = await ApplyAsync(manager, Request("First"));
}
finally
{
Logger.Sink = previousSink;
}
result.Status.ShouldBe(ThemeTransitionStatus.Committed);
laterObserverRan.ShouldBeTrue();
result.PublishDiagnostics.ShouldContain(diagnostic =>
diagnostic.Severity == ThemeDiagnosticSeverity.Warning &&
diagnostic.Message.Contains("observer failure", StringComparison.Ordinal));
sink.Messages.ShouldContain(message =>
message.Contains(nameof(ThemeManager.ThemeChanged), StringComparison.Ordinal) &&
message.Contains("observer failure", StringComparison.Ordinal));
manager.CurrentSnapshot.ShouldBeSameAs(prepared.Snapshot);
}
[Fact]
public void Publish_Sets_Explicit_Variant_And_Resources_Before_Result_Observers()
{
HeadlessTestApp.Run(() =>
{
var prepared = CreatePrepared("First", "#1677ff");
var manager = CreateManager((_, _, _) => ValueTask.FromResult(prepared));
manager.ConfigureStartup(Request("First"), null, null);
var application = Application.Current!;
var observed = false;
manager.ThemeChanged += (_, _) =>
{
observed = true;
application.RequestedThemeVariant.ShouldBe(ThemeVariant.Light);
var provider = manager.Resources.MergedDictionaries
.OfType<ThemeTokenResourceProvider>()
.ShouldHaveSingleItem();
provider.Snapshot.ShouldBeSameAs(prepared.Snapshot);
};
manager.InitializeApplication(application);
observed.ShouldBeTrue();
application.RequestedThemeVariant.ShouldBe(ThemeVariant.Light);
});
}
[Fact]
public void First_Root_Publish_Reports_Context_Observer_Failure()
{
HeadlessTestApp.Run(() =>
{
var prepared = CreatePrepared("First", "#1677ff");
var manager = CreateManager((_, _, _) => ValueTask.FromResult(prepared));
manager.ConfigureStartup(Request("First"), null, null);
var application = Application.Current!;
application.RequestedThemeVariant = ThemeVariant.Dark;
ThemeChangedEventArgs? observed = null;
using var subscription = application
.GetObservable(Application.RequestedThemeVariantProperty)
.Subscribe(_ =>
{
if (manager.CurrentSnapshot is not null)
{
manager.RootContext.Published += (_, _) =>
throw new InvalidOperationException("context observer failure");
}
});
manager.ThemeChanged += (_, args) => observed = args;
manager.InitializeApplication(application);
observed.ShouldNotBeNull();
observed!.PublishDiagnostics.ShouldContain(diagnostic =>
diagnostic.Severity == ThemeDiagnosticSeverity.Warning &&
diagnostic.Message.Contains("context observer failure", StringComparison.Ordinal));
});
}
[Fact]
public async Task Prepare_Failure_Leaves_The_Previously_Committed_State_Unchanged()
{
var firstPrepared = CreatePrepared("First", "#1677ff");
var failure = ThemeTransactionPreparation.Failed(
[new ThemeDiagnostic("TEST001", ThemeDiagnosticSeverity.Error, "Test", "$", "invalid")],
new InvalidOperationException("prepare failure"));
var manager = CreateManager((request, _, _) => ValueTask.FromResult(
request.ThemeId == "First" ? firstPrepared : failure));
var failures = 0;
manager.ThemeChangeFailed += (_, args) =>
{
failures++;
args.Request.ThemeId.ShouldBe("Broken");
};
var committed = await ApplyAsync(manager, Request("First"));
var previousState = manager.CurrentTheme;
var previousSnapshot = manager.CurrentSnapshot;
var failed = await ApplyAsync(manager, Request("Broken"));
committed.Status.ShouldBe(ThemeTransitionStatus.Committed);
failed.Status.ShouldBe(ThemeTransitionStatus.Failed);
failed.Diagnostics.ShouldContain(diagnostic => diagnostic.Code == "TEST001");
manager.CurrentTheme.ShouldBeSameAs(previousState);
manager.CurrentSnapshot.ShouldBeSameAs(previousSnapshot);
failures.ShouldBe(1);
}
[Fact]
public async Task Scope_Config_Revision_Change_Supersedes_A_Stale_Root_Preparation()
{
var initial = CreatePrepared("Initial", "#1677ff");
var next = CreatePrepared("Next", "#52c41a");
var nextStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var releaseNext = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var manager = CreateManager(async (request, _, _) =>
{
if (request.ThemeId == "Next")
{
nextStarted.SetResult();
await releaseNext.Task;
return next;
}
return initial;
});
(await ApplyAsync(manager, Request("Initial"))).Status.ShouldBe(ThemeTransitionStatus.Committed);
var provider = new ThemeConfigProvider
{
Config = new ThemeConfigBuilder()
.WithToken(nameof(DesignToken.ColorPrimary), "#ff0000")
.Build(),
Child = new Border()
};
var root = new LogicalRoot();
root.SetValue(ThemeScope.ContextProperty, manager.RootContext);
root.Child = provider;
var context = provider.GetValue(ThemeScope.ContextProperty)!;
var previousLocalSnapshot = context.Snapshot;
var localCommitted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
provider.ThemeChanged += (_, _) => localCommitted.TrySetResult();
var nextTask = ApplyAsync(manager, Request("Next"));
nextStarted.Task.IsCompleted.ShouldBeTrue();
provider.Config = new ThemeConfigBuilder()
.WithToken(nameof(DesignToken.ColorPrimary), "#00b96b")
.Build();
context.Snapshot.ShouldBeSameAs(previousLocalSnapshot);
releaseNext.SetResult();
(await nextTask).Status.ShouldBe(ThemeTransitionStatus.Superseded);
await localCommitted.Task;
context.Snapshot.Global<Avalonia.Media.Color>(nameof(DesignToken.ColorPrimary))
.ShouldBe(Avalonia.Media.Color.Parse("#00b96b"));
}
[Fact]
public async Task Root_Transaction_Replays_The_Latest_Local_Config_Queued_During_Local_Publish()
{
var initial = CreatePrepared("Initial", "#1677ff");
var next = CreatePrepared("Next", "#52c41a");
var nextStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var manager = CreateManager((request, _, _) => ValueTask.FromResult(
Prepare(request, initial, next, nextStarted)));
(await ApplyAsync(manager, Request("Initial"))).Status.ShouldBe(ThemeTransitionStatus.Committed);
var provider = new ThemeConfigProvider
{
Config = new ThemeConfigBuilder()
.WithToken(nameof(DesignToken.ColorPrimary), "#ff0000")
.Build(),
Child = new Border()
};
var root = new LogicalRoot();
root.SetValue(ThemeScope.ContextProperty, manager.RootContext);
root.Child = provider;
var context = provider.GetValue(ThemeScope.ContextProperty).ShouldNotBeNull();
var finalConfig = new ThemeConfigBuilder()
.WithToken(nameof(DesignToken.ColorPrimary), "#00b96b")
.Build();
var finalLocalCommit = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
Task<ThemeTransitionResult>? rootTransition = null;
var transactionUsersDuringLocalPublish = -1;
var localCommits = 0;
provider.ThemeChanged += (_, _) =>
{
localCommits++;
if (localCommits == 1)
{
transactionUsersDuringLocalPublish = GetTransactionExecutionUsers(manager);
provider.Config = finalConfig;
rootTransition = ApplyAsync(manager, Request("Next"));
nextStarted.Task.IsCompleted.ShouldBeFalse();
}
else
{
finalLocalCommit.TrySetResult();
}
};
provider.Config = new ThemeConfigBuilder()
.WithToken(nameof(DesignToken.ColorPrimary), "#fa8c16")
.Build();
transactionUsersDuringLocalPublish.ShouldBeGreaterThan(0);
(await rootTransition!).Status.ShouldBe(ThemeTransitionStatus.Committed);
await finalLocalCommit.Task.WaitAsync(TestContext.Current.CancellationToken);
context.Snapshot.Global<Avalonia.Media.Color>(nameof(DesignToken.ColorPrimary))
.ShouldBe(Avalonia.Media.Color.Parse("#00b96b"));
manager.ScopeGraph.TryGetNode(provider, out var node).ShouldBeTrue();
node!.LastValidConfig.ShouldBeSameAs(finalConfig);
static ThemeTransactionPreparation Prepare(
ThemeRequest request,
ThemeTransactionPreparation initial,
ThemeTransactionPreparation next,
TaskCompletionSource nextStarted)
{
if (request.ThemeId == "Next")
{
nextStarted.TrySetResult();
return next;
}
return initial;
}
}
[Fact]
public async Task Dispose_Supersedes_Active_And_Queued_Root_Transactions()
{
var prepared = CreatePrepared("First", "#1677ff");
var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var manager = CreateManager(async (_, _, _) =>
{
started.TrySetResult();
await release.Task;
return prepared;
});
var active = ApplyAsync(manager, Request("First"));
await started.Task;
var queued = ApplyAsync(manager, Request("Second"));
manager.Dispose();
(await queued.WaitAsync(TestContext.Current.CancellationToken))
.Status.ShouldBe(ThemeTransitionStatus.Superseded);
Should.Throw<ObjectDisposedException>(() => manager.ApplyThemeAsync(Request("Third")));
release.TrySetResult();
(await active.WaitAsync(TestContext.Current.CancellationToken))
.Status.ShouldBe(ThemeTransitionStatus.Superseded);
manager.CurrentTheme.ShouldBeNull();
manager.CurrentSnapshot.ShouldBeNull();
}
private static ThemeManager CreateManager(ThemePrepareDelegate prepare)
{
return new ThemeManager(static () => true, prepare);
}
private static ThemeRequest Request(string themeId)
{
return new ThemeRequest(themeId, null, ThemeTransitionReason.UserRequest);
}
private static Task<ThemeTransitionResult> ApplyAsync(
ThemeManager manager,
ThemeRequest request)
{
return manager.ApplyThemeAsync(request, TestContext.Current.CancellationToken);
}
private static int GetTransactionExecutionUsers(ThemeManager manager)
{
var field = typeof(ThemeManager).GetField(
"_transactionExecutionUsers",
BindingFlags.Instance | BindingFlags.NonPublic);
field.ShouldNotBeNull();
return (int)field.GetValue(manager)!;
}
private static ThemeTransactionPreparation CreatePrepared(string themeId, string color)
{
var registry = TypedThemeSnapshotCacheTests.CreateRegistry();
registry.TryGetGlobalToken(nameof(DesignToken.ColorPrimary), out var descriptor).ShouldBeTrue();
var parsed = descriptor!.Parse(color);
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 sealed class LogicalRoot : Decorator, ILogicalRoot
{
}
private sealed class RecordingLogSink : ILogSink
{
internal List<string> Messages { get; } = new();
public bool IsEnabled(LogEventLevel level, string area) => true;
public void Log(
LogEventLevel level,
string area,
object? source,
string messageTemplate)
{
Messages.Add(messageTemplate);
}
public void Log(
LogEventLevel level,
string area,
object? source,
string messageTemplate,
params object?[] propertyValues)
{
Messages.Add(string.Format(messageTemplate, propertyValues));
}
}
}