mirror of
https://github.com/AtomUI/AtomUI.git
synced 2026-10-18 06:21:52 +08:00
363 lines
13 KiB
C#
363 lines
13 KiB
C#
using Avalonia;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Controls.Presenters;
|
|
using Avalonia.Media;
|
|
using Avalonia.VisualTree;
|
|
using Shouldly;
|
|
using Xunit;
|
|
|
|
namespace AtomUI.Controls.Tests.ImageLoading;
|
|
|
|
public class AsyncImageTests
|
|
{
|
|
static AsyncImageTests()
|
|
{
|
|
AvaloniaTestApp.EnsureInitialized();
|
|
}
|
|
|
|
[Fact]
|
|
public void No_Source_Is_Idle_And_Explicit_Zero_Size_Is_A_Typed_Failure()
|
|
{
|
|
var borrowed = new TestBorrowedImage();
|
|
var image = new AsyncImage
|
|
{
|
|
Width = 32,
|
|
Height = 32,
|
|
DecodeMode = ImageDecodeMode.Explicit,
|
|
Source = new BorrowedImageSource(borrowed)
|
|
};
|
|
using var host = new ImageControlTestHost(image);
|
|
|
|
ImageControlTestHost.WaitUntil(() => image.IsFailed, "invalid explicit decode state");
|
|
image.LoadError.ShouldNotBeNull().Code.ShouldBe(ImageLoadErrorCode.InvalidSource);
|
|
image.Classes.ShouldContain(":failed");
|
|
|
|
image.DecodePixelWidth = 16;
|
|
ImageControlTestHost.WaitUntil(() => image.IsLoaded, "explicit image load");
|
|
image.LoadedImage.ShouldBeSameAs(borrowed);
|
|
|
|
image.Source = null;
|
|
image.LoadState.ShouldBe(ImageLoadState.Idle);
|
|
image.LoadError.ShouldBeNull();
|
|
image.LoadedImage.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void Borrowed_Image_Transitions_To_Loaded_And_Is_Not_Disposed()
|
|
{
|
|
var borrowed = new TestBorrowedImage();
|
|
ImageOpenedEventArgs? opened = null;
|
|
var image = new AsyncImage
|
|
{
|
|
Width = 32,
|
|
Height = 32,
|
|
Source = new BorrowedImageSource(borrowed)
|
|
};
|
|
image.ImageOpened += (_, args) => opened = args;
|
|
|
|
using (var host = new ImageControlTestHost(image))
|
|
{
|
|
ImageControlTestHost.WaitUntil(() => image.IsLoaded, "borrowed image load");
|
|
|
|
image.LoadState.ShouldBe(ImageLoadState.Loaded);
|
|
image.IsLoading.ShouldBeFalse();
|
|
image.IsFailed.ShouldBeFalse();
|
|
image.LoadedImage.ShouldBeSameAs(borrowed);
|
|
image.Classes.ShouldContain(":loaded");
|
|
image.Classes.ShouldContain(":has-image");
|
|
opened.ShouldNotBeNull().IsFallback.ShouldBeFalse();
|
|
var presenter = image.GetVisualDescendants()
|
|
.OfType<Image>()
|
|
.Single(control => control.Name == "PART_ImagePresenter");
|
|
presenter.IsVisible.ShouldBeTrue();
|
|
presenter.Source.ShouldBeSameAs(borrowed);
|
|
}
|
|
|
|
borrowed.DisposeCount.ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public void ImageOpened_Observer_Failure_Does_Not_Break_Load_State_Or_Stop_Other_Observers()
|
|
{
|
|
var observed = 0;
|
|
var image = new AsyncImage
|
|
{
|
|
Width = 32,
|
|
Height = 32,
|
|
Source = new BorrowedImageSource(new TestBorrowedImage())
|
|
};
|
|
image.ImageOpened += (_, _) => throw new InvalidOperationException("observer failed");
|
|
image.ImageOpened += (_, _) => Interlocked.Increment(ref observed);
|
|
|
|
using var host = new ImageControlTestHost(image);
|
|
|
|
ImageControlTestHost.WaitUntil(() => image.IsLoaded, "image opened observer isolation");
|
|
observed.ShouldBe(1);
|
|
image.LoadError.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void Failed_Primary_Uses_Fallback_Within_The_Same_Generation()
|
|
{
|
|
var fallback = new TestBorrowedImage();
|
|
ImageOpenedEventArgs? opened = null;
|
|
var image = new AsyncImage
|
|
{
|
|
Width = 32,
|
|
Height = 32,
|
|
Source = new BytesImageSource(new byte[] { 1 }, "invalid"),
|
|
FallbackSource = new BorrowedImageSource(fallback)
|
|
};
|
|
image.ImageOpened += (_, args) => opened = args;
|
|
using var host = new ImageControlTestHost(image);
|
|
|
|
ImageControlTestHost.WaitUntil(() => image.IsLoaded, "fallback image load");
|
|
|
|
image.LoadedImage.ShouldBeSameAs(fallback);
|
|
image.Classes.ShouldContain(":fallback");
|
|
opened.ShouldNotBeNull().IsFallback.ShouldBeTrue();
|
|
opened.Source.ShouldBeSameAs(image.FallbackSource);
|
|
}
|
|
|
|
[Fact]
|
|
public void Terminal_Failure_Shows_Error_Content_And_Raises_Event()
|
|
{
|
|
ImageFailedEventArgs? failed = null;
|
|
var image = new AsyncImage
|
|
{
|
|
Width = 32,
|
|
Height = 32,
|
|
Source = new BytesImageSource(new byte[] { 1 }, "invalid"),
|
|
ErrorContent = "Unable to load"
|
|
};
|
|
image.ImageFailed += (_, args) => failed = args;
|
|
using var host = new ImageControlTestHost(image);
|
|
|
|
ImageControlTestHost.WaitUntil(() => image.IsFailed, "image failure");
|
|
|
|
image.LoadError.ShouldNotBeNull().Exception.ShouldBeNull();
|
|
image.LoadError.ShouldNotBeNull().Code.ShouldBe(ImageLoadErrorCode.UnsupportedFormat);
|
|
failed.ShouldNotBeNull().Error.ShouldBeSameAs(image.LoadError);
|
|
image.Classes.ShouldContain(":failed");
|
|
var presenter = image.GetVisualDescendants()
|
|
.OfType<ContentPresenter>()
|
|
.Single(control => control.Name == "PART_ErrorPresenter");
|
|
presenter.IsVisible.ShouldBeTrue();
|
|
presenter.Content.ShouldBe("Unable to load");
|
|
}
|
|
|
|
[Fact]
|
|
public void Source_Replacement_Does_Not_Commit_Stale_Result()
|
|
{
|
|
var firstStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var releaseFirst = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var first = new StreamImageSource(
|
|
async _ =>
|
|
{
|
|
firstStarted.TrySetResult();
|
|
await releaseFirst.Task;
|
|
return new MemoryStream(ImageControlTestHost.CreatePng(16, 16));
|
|
},
|
|
"first",
|
|
"v1");
|
|
var secondImage = new TestBorrowedImage();
|
|
var second = new BorrowedImageSource(secondImage);
|
|
var image = new AsyncImage { Width = 32, Height = 32, Source = first };
|
|
using var host = new ImageControlTestHost(image);
|
|
ImageControlTestHost.WaitUntil(() => firstStarted.Task.IsCompleted, "first request start");
|
|
|
|
image.Source = second;
|
|
ImageControlTestHost.WaitUntil(
|
|
() => image.IsLoaded && ReferenceEquals(image.LoadedImage, secondImage),
|
|
"second image commit");
|
|
releaseFirst.TrySetResult();
|
|
Avalonia.Threading.Dispatcher.UIThread.RunJobs();
|
|
|
|
image.Source.ShouldBeSameAs(second);
|
|
image.LoadedImage.ShouldBeSameAs(secondImage);
|
|
image.LoadState.ShouldBe(ImageLoadState.Loaded);
|
|
}
|
|
|
|
[Fact]
|
|
public void Reload_Bypasses_Cache_And_Starts_A_New_Read()
|
|
{
|
|
var bytes = ImageControlTestHost.CreatePng(32, 32);
|
|
var reads = 0;
|
|
var source = new StreamImageSource(
|
|
_ =>
|
|
{
|
|
Interlocked.Increment(ref reads);
|
|
return ValueTask.FromResult<Stream>(new MemoryStream(bytes));
|
|
},
|
|
"reload",
|
|
"v1");
|
|
var image = new AsyncImage { Width = 32, Height = 32, Source = source };
|
|
using var host = new ImageControlTestHost(image);
|
|
ImageControlTestHost.WaitUntil(
|
|
() => image.LoadState is ImageLoadState.Loaded or ImageLoadState.Failed,
|
|
"initial image terminal state");
|
|
image.LoadError.ShouldBeNull();
|
|
reads.ShouldBe(1);
|
|
|
|
image.Reload();
|
|
ImageControlTestHost.WaitUntil(() => image.IsLoaded && reads == 2, "reloaded image");
|
|
|
|
reads.ShouldBe(2);
|
|
}
|
|
|
|
[Fact]
|
|
public void Detach_Releases_State_And_Reattach_Loads_Current_Source_Again()
|
|
{
|
|
var borrowed = new TestBorrowedImage();
|
|
var image = new AsyncImage
|
|
{
|
|
Width = 32,
|
|
Height = 32,
|
|
Source = new BorrowedImageSource(borrowed)
|
|
};
|
|
using var host = new ImageControlTestHost(image);
|
|
ImageControlTestHost.WaitUntil(() => image.IsLoaded, "initial attach load");
|
|
|
|
host.Detach();
|
|
image.LoadState.ShouldBe(ImageLoadState.Idle);
|
|
image.LoadedImage.ShouldBeNull();
|
|
|
|
host.Attach(image);
|
|
ImageControlTestHost.WaitUntil(() => image.IsLoaded, "reattach load");
|
|
image.LoadedImage.ShouldBeSameAs(borrowed);
|
|
borrowed.DisposeCount.ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public void Auto_Decode_Uses_Sixteen_Physical_Pixel_Buckets()
|
|
{
|
|
var bytes = ImageControlTestHost.CreatePng(64, 64);
|
|
ImageOpenedEventArgs? opened = null;
|
|
var image = new AsyncImage
|
|
{
|
|
Width = 17,
|
|
Height = 17,
|
|
Source = new BytesImageSource(bytes, "bucket")
|
|
};
|
|
image.ImageOpened += (_, args) => opened = args;
|
|
using var host = new ImageControlTestHost(image);
|
|
|
|
ImageControlTestHost.WaitUntil(
|
|
() => image.LoadState is ImageLoadState.Loaded or ImageLoadState.Failed,
|
|
"bucketed image terminal state");
|
|
image.LoadError.ShouldBeNull();
|
|
|
|
opened.ShouldNotBeNull().PixelWidth.ShouldBe(32);
|
|
opened.PixelHeight.ShouldBe(32);
|
|
}
|
|
|
|
[Fact]
|
|
public void Auto_Decode_Does_Not_Use_Content_Derived_Height_When_Height_Was_Unconstrained()
|
|
{
|
|
var image = new AsyncImage
|
|
{
|
|
Width = 168.5,
|
|
Source = new BorrowedImageSource(new TestBorrowedImage(523, 349))
|
|
};
|
|
|
|
image.Measure(new Size(168.5, double.PositiveInfinity));
|
|
image.Arrange(new Rect(0, 0, 168.5, 112));
|
|
|
|
var host = (IImageLoadControllerHost)image;
|
|
host.GetDecodePixelSize().ShouldBe((176, 0));
|
|
}
|
|
|
|
[Fact]
|
|
public void Asset_Svg_Decoded_Off_Thread_Can_Be_Measured_On_The_Ui_Thread()
|
|
{
|
|
var image = new AsyncImage
|
|
{
|
|
Width = 48,
|
|
Height = 32,
|
|
Source = new AssetImageSource(
|
|
new Uri("avares://AtomUI.Controls.Tests/Assets/ImageLoading/Test.svg"))
|
|
};
|
|
using var host = new ImageControlTestHost(image, width: 48, height: 32);
|
|
|
|
ImageControlTestHost.WaitUntil(
|
|
() => image.LoadState is ImageLoadState.Loaded or ImageLoadState.Failed,
|
|
"SVG image terminal state");
|
|
|
|
image.LoadError.ShouldBeNull();
|
|
image.LoadedImage.ShouldNotBeNull().Size.ShouldBe(new Size(48, 32));
|
|
Should.NotThrow(() => image.Measure(new Size(48, 32)));
|
|
}
|
|
|
|
[Fact]
|
|
public void Asset_Svg_Can_Be_Released_By_Background_Cache_Clear()
|
|
{
|
|
var loader = Application.Current.ShouldNotBeNull().GetImageLoader();
|
|
var source = new AssetImageSource(
|
|
new Uri("avares://AtomUI.Controls.Tests/Assets/ImageLoading/Test.svg"));
|
|
ImageLoadResult? result = null;
|
|
Exception? loadException = null;
|
|
var loadTask = Task.Run(async () =>
|
|
{
|
|
try
|
|
{
|
|
result = await loader.LoadAsync(
|
|
new ImageLoadRequest(source),
|
|
TestContext.Current.CancellationToken);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
loadException = exception;
|
|
}
|
|
}, TestContext.Current.CancellationToken);
|
|
ImageControlTestHost.WaitUntil(() => loadTask.IsCompleted, "SVG loader result");
|
|
loadException.ShouldBeNull();
|
|
var loadedResult = result.ShouldNotBeNull();
|
|
loadedResult.IsSuccess.ShouldBeTrue();
|
|
|
|
Exception? releaseException = null;
|
|
var releaseTask = Task.Run(async () =>
|
|
{
|
|
try
|
|
{
|
|
loadedResult.Dispose();
|
|
await loader.ClearCacheAsync(
|
|
new ImageCacheClearRequest(),
|
|
TestContext.Current.CancellationToken);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
releaseException = exception;
|
|
}
|
|
}, TestContext.Current.CancellationToken);
|
|
ImageControlTestHost.WaitUntil(() => releaseTask.IsCompleted, "background SVG release");
|
|
|
|
releaseException.ShouldBeNull();
|
|
Avalonia.Threading.Dispatcher.UIThread.RunJobs();
|
|
}
|
|
|
|
private sealed class TestBorrowedImage : IImage, IDisposable
|
|
{
|
|
private int _disposeCount;
|
|
|
|
private readonly Size _size;
|
|
|
|
internal TestBorrowedImage(double width = 24, double height = 24)
|
|
{
|
|
_size = new Size(width, height);
|
|
}
|
|
|
|
public Size Size => _size;
|
|
|
|
internal int DisposeCount => Volatile.Read(ref _disposeCount);
|
|
|
|
public void Draw(DrawingContext context, Rect sourceRect, Rect destRect)
|
|
{
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
Interlocked.Increment(ref _disposeCount);
|
|
}
|
|
}
|
|
}
|