mirror of
https://github.com/AtomUI/AtomUI.git
synced 2026-10-20 07:39:09 +08:00
402 lines
14 KiB
C#
402 lines
14 KiB
C#
using AtomUI.Controls;
|
|
using Avalonia;
|
|
using System.Net;
|
|
using System.Net.Http.Headers;
|
|
using Shouldly;
|
|
using Xunit;
|
|
|
|
namespace AtomUI.Controls.Shared.Tests.ImageLoading;
|
|
|
|
public class ImageLoaderLifecycleTests
|
|
{
|
|
[Fact]
|
|
public void Attached_Loader_Dispose_Without_Image_Use_Does_Not_Create_Http_Transport()
|
|
{
|
|
var application = new Application();
|
|
var creations = 0;
|
|
var loader = CreateLoader(() =>
|
|
{
|
|
creations++;
|
|
return CreateHttpTransport(new CountingHandler());
|
|
});
|
|
loader.Attach(application);
|
|
|
|
loader.Dispose();
|
|
|
|
creations.ShouldBe(0);
|
|
application.TryGetImageLoader().ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void Unused_Injected_Http_Handler_Is_Disposed_With_Loader()
|
|
{
|
|
var handler = new CountingHandler();
|
|
var loader = new ImageLoader(
|
|
ImageLoadingTestSupport.CreateOptions(),
|
|
[new PassThroughCodec()],
|
|
handler);
|
|
|
|
loader.Dispose();
|
|
|
|
handler.DisposeCount.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NonHttp_Use_Does_Not_Delay_Injected_Handler_Disposal()
|
|
{
|
|
var handler = new CountingHandler();
|
|
var loader = new ImageLoader(
|
|
ImageLoadingTestSupport.CreateOptions(),
|
|
[new PassThroughCodec()],
|
|
handler);
|
|
using var result = await loader.LoadAsync(
|
|
new ImageLoadRequest(new BytesImageSource(ImageLoadingTestSupport.CreatePngHeader())),
|
|
TestContext.Current.CancellationToken);
|
|
|
|
loader.Dispose();
|
|
|
|
result.IsSuccess.ShouldBeTrue();
|
|
handler.DisposeCount.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Http_Use_Transfers_Injected_Handler_Ownership_To_Transport()
|
|
{
|
|
var handler = new CountingHandler(CreatePngResponse);
|
|
var loader = new ImageLoader(
|
|
ImageLoadingTestSupport.CreateOptions(),
|
|
[new PassThroughCodec()],
|
|
handler);
|
|
using var result = await loader.LoadAsync(
|
|
new ImageLoadRequest(new HttpImageSource(new Uri("https://example.com/injected.png"))),
|
|
TestContext.Current.CancellationToken);
|
|
|
|
loader.Dispose();
|
|
|
|
result.IsSuccess.ShouldBeTrue();
|
|
handler.DisposeCount.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task First_Http_Use_Creates_Transport_And_Loader_Disposes_Its_Handler()
|
|
{
|
|
var application = new Application();
|
|
var creations = 0;
|
|
var handler = new CountingHandler(CreatePngResponse);
|
|
var loader = CreateLoader(() =>
|
|
{
|
|
creations++;
|
|
return CreateHttpTransport(handler);
|
|
});
|
|
loader.Attach(application);
|
|
|
|
using var result = await loader.LoadAsync(
|
|
new ImageLoadRequest(new HttpImageSource(new Uri("https://example.com/application.png"))),
|
|
TestContext.Current.CancellationToken);
|
|
loader.Dispose();
|
|
|
|
result.IsSuccess.ShouldBeTrue();
|
|
creations.ShouldBe(1);
|
|
handler.DisposeCount.ShouldBe(1);
|
|
application.TryGetImageLoader().ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Independent_Application_Loaders_Own_Separate_Http_Transports()
|
|
{
|
|
var firstApplication = new Application();
|
|
var secondApplication = new Application();
|
|
var firstCreations = 0;
|
|
var secondCreations = 0;
|
|
using var firstLoader = CreateLoader(() =>
|
|
{
|
|
firstCreations++;
|
|
return CreateHttpTransport(new CountingHandler(CreatePngResponse));
|
|
});
|
|
using var secondLoader = CreateLoader(() =>
|
|
{
|
|
secondCreations++;
|
|
return CreateHttpTransport(new CountingHandler(CreatePngResponse));
|
|
});
|
|
firstLoader.Attach(firstApplication);
|
|
secondLoader.Attach(secondApplication);
|
|
|
|
using var first = await firstLoader.LoadAsync(
|
|
new ImageLoadRequest(new HttpImageSource(new Uri("https://example.com/first.png"))),
|
|
TestContext.Current.CancellationToken);
|
|
secondCreations.ShouldBe(0);
|
|
using var second = await secondLoader.LoadAsync(
|
|
new ImageLoadRequest(new HttpImageSource(new Uri("https://example.com/second.png"))),
|
|
TestContext.Current.CancellationToken);
|
|
|
|
first.IsSuccess.ShouldBeTrue();
|
|
second.IsSuccess.ShouldBeTrue();
|
|
firstCreations.ShouldBe(1);
|
|
secondCreations.ShouldBe(1);
|
|
firstApplication.GetImageLoader().ShouldBeSameAs(firstLoader);
|
|
secondApplication.GetImageLoader().ShouldBeSameAs(secondLoader);
|
|
}
|
|
|
|
[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(Func<HttpImageTransport>? httpTransportFactory = null)
|
|
{
|
|
return new ImageLoader(
|
|
ImageLoadingTestSupport.CreateOptions(),
|
|
[new PassThroughCodec()],
|
|
httpTransportFactory: httpTransportFactory);
|
|
}
|
|
|
|
private static HttpImageTransport CreateHttpTransport(HttpMessageHandler handler) =>
|
|
new(ImageLoadingTestSupport.CreateOptions(), handler);
|
|
|
|
private static HttpResponseMessage CreatePngResponse(HttpRequestMessage request)
|
|
{
|
|
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
|
{
|
|
Content = new ByteArrayContent(ImageLoadingTestSupport.CreatePngHeader())
|
|
};
|
|
response.Content.Headers.ContentType = new MediaTypeHeaderValue("image/png");
|
|
return response;
|
|
}
|
|
|
|
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 CountingHandler : HttpMessageHandler
|
|
{
|
|
private readonly Func<HttpRequestMessage, HttpResponseMessage> _send;
|
|
|
|
internal CountingHandler(Func<HttpRequestMessage, HttpResponseMessage>? send = null)
|
|
{
|
|
_send = send ?? (_ => throw new NotSupportedException());
|
|
}
|
|
|
|
internal int DisposeCount { get; private set; }
|
|
|
|
protected override Task<HttpResponseMessage> SendAsync(
|
|
HttpRequestMessage request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
return Task.FromResult(_send(request));
|
|
}
|
|
|
|
protected override void Dispose(bool disposing)
|
|
{
|
|
if (disposing)
|
|
{
|
|
DisposeCount++;
|
|
}
|
|
base.Dispose(disposing);
|
|
}
|
|
}
|
|
|
|
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));
|
|
}
|
|
}
|
|
}
|