mirror of
https://github.com/AtomUI/AtomUI.git
synced 2026-10-19 06:58:57 +08:00
229 lines
8.4 KiB
C#
229 lines
8.4 KiB
C#
using AtomUI.Controls;
|
|
using Avalonia;
|
|
using Shouldly;
|
|
using Xunit;
|
|
|
|
namespace AtomUI.Controls.Shared.Tests.ImageLoading;
|
|
|
|
public class ImageLoaderLifecycleTests
|
|
{
|
|
[Fact]
|
|
public void Attach_Publishes_One_Application_Loader_And_Detach_Removes_It()
|
|
{
|
|
var application = new Application();
|
|
using var loader = CreateLoader();
|
|
using var competingLoader = CreateLoader();
|
|
|
|
application.TryGetImageLoader().ShouldBeNull();
|
|
Should.Throw<InvalidOperationException>(() => application.GetImageLoader())
|
|
.Message.ShouldContain("UseImageLoading");
|
|
|
|
loader.Attach(application);
|
|
|
|
application.GetImageLoader().ShouldBeSameAs(loader);
|
|
application.TryGetImageLoader().ShouldBeSameAs(loader);
|
|
Should.Throw<InvalidOperationException>(() => competingLoader.Attach(application))
|
|
.Message.ShouldContain("already attached");
|
|
|
|
loader.Detach(application);
|
|
|
|
application.TryGetImageLoader().ShouldBeNull();
|
|
competingLoader.Attach(application);
|
|
application.GetImageLoader().ShouldBeSameAs(competingLoader);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Dispose_Stops_Publishing_The_Attached_Loader()
|
|
{
|
|
var application = new Application();
|
|
var loader = CreateLoader();
|
|
loader.Attach(application);
|
|
|
|
loader.Dispose();
|
|
|
|
application.TryGetImageLoader().ShouldBeNull();
|
|
await Should.ThrowAsync<ObjectDisposedException>(() =>
|
|
loader.LoadAsync(
|
|
new ImageLoadRequest(new BytesImageSource(new byte[] { 1 })))
|
|
.AsTask());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Waiter_Timeout_Is_Typed_And_Does_Not_Cancel_A_Shared_Request_With_Other_Waiters()
|
|
{
|
|
var readStarted = NewSignal();
|
|
var releaseRead = NewSignal();
|
|
var underlyingCanceled = 0;
|
|
var source = new StreamImageSource(
|
|
async token =>
|
|
{
|
|
using var registration = token.Register(() => Interlocked.Exchange(ref underlyingCanceled, 1));
|
|
readStarted.TrySetResult();
|
|
await releaseRead.Task.WaitAsync(token);
|
|
return new MemoryStream(ImageLoadingTestSupport.CreatePngHeader());
|
|
},
|
|
"waiter-timeout",
|
|
"v1");
|
|
using var loader = CreateLoader();
|
|
var cancellationToken = TestContext.Current.CancellationToken;
|
|
|
|
var survivingWaiter = loader.LoadAsync(
|
|
new ImageLoadRequest(source)
|
|
{
|
|
Options = new ImageRequestOptions { Timeout = TimeSpan.FromSeconds(5) }
|
|
},
|
|
cancellationToken).AsTask();
|
|
await readStarted.Task.WaitAsync(cancellationToken);
|
|
var timedOutWaiter = loader.LoadAsync(
|
|
new ImageLoadRequest(source)
|
|
{
|
|
Options = new ImageRequestOptions { Timeout = TimeSpan.FromMilliseconds(50) }
|
|
},
|
|
cancellationToken).AsTask();
|
|
|
|
using var timedOutResult = await timedOutWaiter.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken);
|
|
|
|
timedOutResult.IsSuccess.ShouldBeFalse();
|
|
timedOutResult.Error.ShouldNotBeNull().Code.ShouldBe(ImageLoadErrorCode.Timeout);
|
|
Volatile.Read(ref underlyingCanceled).ShouldBe(0);
|
|
survivingWaiter.IsCompleted.ShouldBeFalse();
|
|
|
|
releaseRead.TrySetResult();
|
|
using var survivingResult = await survivingWaiter.WaitAsync(cancellationToken);
|
|
|
|
survivingResult.IsSuccess.ShouldBeTrue();
|
|
Volatile.Read(ref underlyingCanceled).ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Unexpected_Source_Exception_Is_Reported_As_A_Typed_Failure()
|
|
{
|
|
using var loader = CreateLoader();
|
|
var source = new StreamImageSource(
|
|
_ => ValueTask.FromException<Stream>(new InvalidOperationException("source failed")),
|
|
"throwing-source");
|
|
|
|
using var result = await loader.LoadAsync(
|
|
new ImageLoadRequest(source),
|
|
TestContext.Current.CancellationToken);
|
|
|
|
result.IsSuccess.ShouldBeFalse();
|
|
result.Error.ShouldNotBeNull().Code.ShouldBe(ImageLoadErrorCode.InvalidSource);
|
|
result.Error.Exception.ShouldBeOfType<InvalidOperationException>();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadEvent_Observer_Failure_Does_Not_Change_Load_Result_Or_Stop_Other_Observers()
|
|
{
|
|
using var loader = CreateLoader();
|
|
var observed = 0;
|
|
loader.LoadEvent += (_, _) => throw new InvalidOperationException("diagnostics observer failed");
|
|
loader.LoadEvent += (_, _) => Interlocked.Increment(ref observed);
|
|
|
|
using var result = await loader.LoadAsync(
|
|
new ImageLoadRequest(new BytesImageSource(ImageLoadingTestSupport.CreatePngHeader())),
|
|
TestContext.Current.CancellationToken);
|
|
|
|
result.IsSuccess.ShouldBeTrue();
|
|
Volatile.Read(ref observed).ShouldBeGreaterThanOrEqualTo(2);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Progress_Observer_Failure_Does_Not_Change_Load_Result()
|
|
{
|
|
using var loader = CreateLoader();
|
|
using var result = await loader.LoadAsync(
|
|
new ImageLoadRequest(new BytesImageSource(ImageLoadingTestSupport.CreatePngHeader()))
|
|
{
|
|
Progress = new ThrowingProgress()
|
|
},
|
|
TestContext.Current.CancellationToken);
|
|
|
|
result.IsSuccess.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Internal_Shared_Operation_Cancellation_Is_Not_Reported_As_A_Source_Failure()
|
|
{
|
|
var readStarted = NewSignal();
|
|
var releaseRead = NewSignal();
|
|
var source = new StreamImageSource(
|
|
async token =>
|
|
{
|
|
readStarted.TrySetResult();
|
|
await releaseRead.Task.WaitAsync(token);
|
|
return new MemoryStream(ImageLoadingTestSupport.CreatePngHeader());
|
|
},
|
|
"shared-cancel-misclassify",
|
|
"v1");
|
|
using var loader = CreateLoader();
|
|
var cancellationToken = TestContext.Current.CancellationToken;
|
|
|
|
var waiter = loader.LoadAsync(
|
|
new ImageLoadRequest(source)
|
|
{
|
|
Options = new ImageRequestOptions { Timeout = TimeSpan.FromSeconds(30) }
|
|
},
|
|
cancellationToken).AsTask();
|
|
await readStarted.Task.WaitAsync(cancellationToken);
|
|
|
|
try
|
|
{
|
|
// ClearCacheAsync(CancelInFlight) 拆除共享操作;调用方自身 token 并未取消。
|
|
// 该内部取消必须按取消(OperationCanceledException)交付,而不是源失败结果。
|
|
await loader.ClearCacheAsync(new ImageCacheClearRequest(), cancellationToken);
|
|
|
|
await Should.ThrowAsync<OperationCanceledException>(
|
|
() => waiter.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken));
|
|
}
|
|
finally
|
|
{
|
|
releaseRead.TrySetResult();
|
|
}
|
|
}
|
|
|
|
private static ImageLoader CreateLoader()
|
|
{
|
|
return new ImageLoader(
|
|
ImageLoadingTestSupport.CreateOptions(),
|
|
[new PassThroughCodec()]);
|
|
}
|
|
|
|
private static TaskCompletionSource NewSignal() =>
|
|
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
private sealed class ThrowingProgress : IProgress<ImageLoadProgress>
|
|
{
|
|
public void Report(ImageLoadProgress value) => throw new InvalidOperationException("progress observer failed");
|
|
}
|
|
|
|
private sealed class PassThroughCodec : ImageCodec
|
|
{
|
|
internal override string Id => "lifecycle-test";
|
|
|
|
internal override int Version => 1;
|
|
|
|
internal override bool CanDecode(ImageProbeResult probe, ImageSource source) => true;
|
|
|
|
internal override Task<ImageDecodedCacheEntry> DecodeAsync(
|
|
ImageEncodedContent content,
|
|
ImageProbeResult probe,
|
|
NormalizedImageRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var width = request.DecodePixelWidth > 0 ? request.DecodePixelWidth : probe.PixelWidth;
|
|
var height = request.DecodePixelHeight > 0 ? request.DecodePixelHeight : probe.PixelHeight;
|
|
return Task.FromResult(new ImageDecodedCacheEntry(
|
|
new TestImage(width, height),
|
|
ownsImage: true,
|
|
probe.PixelWidth,
|
|
probe.PixelHeight,
|
|
width,
|
|
height,
|
|
checked((long)width * height * 4),
|
|
probe.MediaType,
|
|
content.Origin));
|
|
}
|
|
}
|
|
}
|