Files

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));
}
}
}