Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AsyncImageLoader.Avalonia.Demo/App.axaml.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
using AsyncImageLoader.Avalonia.Demo.ViewModels;
using AsyncImageLoader.Avalonia.Demo.Views;
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using AsyncImageLoader.Avalonia.Demo.ViewModels;
using AsyncImageLoader.Avalonia.Demo.Views;

namespace AsyncImageLoader.Avalonia.Demo;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ public class GetClassNameConverter : IValueConverter {
public static GetClassNameConverter Instance { get; } = new();

/// <inheritdoc />
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) {
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) {
if (value is null) {
return "null";
}
Expand All @@ -17,7 +17,7 @@ public class GetClassNameConverter : IValueConverter {
}

/// <inheritdoc />
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) {
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) {
throw new NotSupportedException();
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using Avalonia.Media.Imaging;
using Avalonia.Platform;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;

namespace AsyncImageLoader.Avalonia.Demo.Pages;
Expand Down
3 changes: 1 addition & 2 deletions AsyncImageLoader.Avalonia.Demo/Program.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
using System;
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using ReactiveUI.Avalonia;

namespace AsyncImageLoader.Avalonia.Demo;
Expand All @@ -18,5 +17,5 @@ public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
.LogToTrace()
.UseReactiveUI(rxui => { });
.UseReactiveUI(_ => { });
}
22 changes: 14 additions & 8 deletions AsyncImageLoader.Avalonia.Demo/Services/LongLoader.cs
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
using System.Threading.Tasks;
using AsyncImageLoader.Loaders;
using Avalonia.Media.Imaging;
using System.Threading;
using System.Threading.Tasks;
using AsyncImageLoader.Core.Leases;
using AsyncImageLoader.Core.Pipeline;

namespace AsyncImageLoader.Avalonia.Demo.Services;

public class LongLoader : BaseWebImageLoader {
public static LongLoader Instance { get; } = new LongLoader();
public sealed class LongLoader : IAsyncImageLoader {
public static LongLoader Instance { get; } = new();
private readonly ImageLoaderPipeline _inner = ImageLoaderPipelineBuilder.Uncached().Build();

protected override async Task<Bitmap?> LoadAsync(string url) {
public async Task<IImageLease?> LoadAsync(ImageLoadRequest request, CancellationToken cancellationToken = default) {
await Task.Delay(1000);
return await base.LoadAsync(url);
return await _inner.LoadAsync(request, cancellationToken);
}
}

public void Dispose() {
_inner.Dispose();
}
}
7 changes: 3 additions & 4 deletions AsyncImageLoader.Avalonia.Demo/ViewLocator.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
using System;
using AsyncImageLoader.Avalonia.Demo.ViewModels;
using Avalonia.Controls;
using Avalonia.Controls.Templates;
using AsyncImageLoader.Avalonia.Demo.ViewModels;

namespace AsyncImageLoader.Avalonia.Demo;

Expand All @@ -13,9 +13,8 @@ public Control Build(object? data) {
if (type != null) {
return (Control)Activator.CreateInstance(type)!;
}
else {
return new TextBlock { Text = "Not Found: " + name };
}

return new TextBlock { Text = "Not Found: " + name };
}

public bool Match(object? data) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
using System;
using System.Collections.Generic;
using System.Text;

namespace AsyncImageLoader.Avalonia.Demo.ViewModels;
namespace AsyncImageLoader.Avalonia.Demo.ViewModels;

public class MainWindowViewModel : ViewModelBase {
public string Greeting => "Welcome to Avalonia!";
Expand Down
5 changes: 1 addition & 4 deletions AsyncImageLoader.Avalonia.Demo/ViewModels/ViewModelBase.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
using System;
using System.Collections.Generic;
using System.Text;
using ReactiveUI;
using ReactiveUI;

namespace AsyncImageLoader.Avalonia.Demo.ViewModels;

Expand Down
1 change: 0 additions & 1 deletion AsyncImageLoader.Avalonia.Demo/Views/MainWindow.axaml.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
<ItemGroup>
<PackageReference Include="Avalonia.Headless" />
<PackageReference Include="AwesomeAssertions" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio">
Expand Down
113 changes: 113 additions & 0 deletions AsyncImageLoader.Avalonia.Tests/BaseWebImageLoaderTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
using System;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using AsyncImageLoader.Core.Pipeline;
using AsyncImageLoader.Loaders;
using Avalonia.Media.Imaging;
using AwesomeAssertions;
using Xunit;

namespace AsyncImageLoader.Avalonia.Tests;

#pragma warning disable CS0618 // Compatibility facade remains covered until removal.
public sealed class BaseWebImageLoaderTests {
private static readonly byte[] Png = Convert.FromBase64String(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=");

[Fact]
public async Task LoadsBitmapFromHttpResponse() {
using var client = new HttpClient(new TestHttpMessageHandler(request => {
request.RequestUri.Should().Be(new Uri("https://example.test/image.png"));
return TestHttpMessageHandler.CreateResponse(Png);
}));
using var loader = new BaseWebImageLoader(client, false);

using var lease = await loader.LoadAsync(new ImageLoadRequest("https://example.test/image.png"));
var bitmap = lease?.Image as Bitmap;

bitmap.Should().NotBeNull();
bitmap.Size.Width.Should().Be(1);
bitmap.Size.Height.Should().Be(1);
}

[Fact]
public async Task LoadsBitmapFromNonSeekableHttpResponse() {
using var client = new HttpClient(new TestHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) {
Content = new StreamContent(new NonSeekableReadStream(Png))
}));
using var loader = new BaseWebImageLoader(client, false);

using var lease = await loader.LoadAsync(new ImageLoadRequest("https://example.test/image.png"));

lease.Should().NotBeNull();
lease.Image.Should().BeOfType<Bitmap>();
}

[Fact]
public async Task ReturnsNullForHttpError() {
using var client = new HttpClient(new TestHttpMessageHandler(_ =>
TestHttpMessageHandler.CreateResponse(Array.Empty<byte>(), HttpStatusCode.NotFound)));
using var loader = new BaseWebImageLoader(client, false);

using var lease = await loader.LoadAsync(new ImageLoadRequest("https://example.test/missing.png"));
var bitmap = lease?.Image;

bitmap.Should().BeNull();
}

[Fact]
public async Task DoesNotRetainImagesBetweenRequests() {
var requests = 0;
using var client = new HttpClient(new TestHttpMessageHandler(_ => {
requests++;
return TestHttpMessageHandler.CreateResponse(Png);
}));
using var loader = new BaseWebImageLoader(client, false);

using var first = await loader.LoadAsync(new ImageLoadRequest("https://example.test/image.png"));
using var second = await loader.LoadAsync(new ImageLoadRequest("https://example.test/image.png"));

first.Should().NotBeNull();
second.Should().NotBeNull();
first.Image.Should().NotBeSameAs(second.Image);
requests.Should().Be(2);
}

private sealed class NonSeekableReadStream : Stream {
private readonly MemoryStream _inner;

public NonSeekableReadStream(byte[] content) {
_inner = new MemoryStream(content, writable: false);
}

public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => throw new NotSupportedException();
public override long Position {
get => throw new NotSupportedException();
set => throw new NotSupportedException();
}

public override int Read(byte[] buffer, int offset, int count) => _inner.Read(buffer, offset, count);
public override int Read(Span<byte> buffer) => _inner.Read(buffer);
public override ValueTask<int> ReadAsync(
Memory<byte> buffer,
CancellationToken cancellationToken = default) => _inner.ReadAsync(buffer, cancellationToken);
public override void Flush() => throw new NotSupportedException();
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();

protected override void Dispose(bool disposing) {
if (disposing)
_inner.Dispose();
base.Dispose(disposing);
}
}

}
#pragma warning restore CS0618
35 changes: 35 additions & 0 deletions AsyncImageLoader.Avalonia.Tests/BitmapDecoderTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using AsyncImageLoader.Core.Decoding;
using AwesomeAssertions;
using Xunit;

namespace AsyncImageLoader.Avalonia.Tests;

public sealed class BitmapDecoderTests {
private static readonly byte[] Png = Convert.FromBase64String(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=");

[Fact]
public async Task DecodesBitmapFromStream() {
var decoder = new BitmapDecoder();
using var stream = new MemoryStream(Png);

using var bitmap = await decoder.DecodeAsync(stream);

bitmap.Size.Width.Should().Be(1);
bitmap.Size.Height.Should().Be(1);
}

[Fact]
public async Task DoesNotDecodeAfterCancellation() {
var decoder = new BitmapDecoder();
using var stream = new MemoryStream(Png);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();

await Assert.ThrowsAsync<OperationCanceledException>(() => decoder.DecodeAsync(stream, cancellation.Token));
}
}
95 changes: 95 additions & 0 deletions AsyncImageLoader.Avalonia.Tests/CustomImageLoaderTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using AsyncImageLoader.Core.Leases;
using AsyncImageLoader.Core.Pipeline;
using Avalonia;
using Avalonia.Media;
using AwesomeAssertions;
using Xunit;

namespace AsyncImageLoader.Avalonia.Tests;

public sealed class CustomImageLoaderTests {
[Fact]
public async Task CustomLoaderCanIgnoreBuiltInPipeline() {
using var loader = new CustomLoader();

using var lease = await loader.LoadAsync(new ImageLoadRequest("custom://image"));

lease.Should().NotBeNull();
loader.Requests.Should().Be(1);
lease.Image.Should().BeOfType<TestImage>();
}

[Fact]
public void OwnedLeaseDisposesImageExactlyOnce() {
var image = new TestImage();
var bitmap = image;
var lease = ImageLease.Owned(bitmap);

lease.Dispose();
lease.Dispose();

Assert.Throws<ObjectDisposedException>(() => bitmap.Size);
}

[Fact]
public void CustomReleaseActionIsCalledOnce() {
var bitmap = new TestImage();
var releases = 0;
var lease = ImageLease.Create(bitmap, () => releases++);

lease.Dispose();
lease.Dispose();

releases.Should().Be(1);
bitmap.Dispose();
}

[Fact]
public void NonOwningLeaseDoesNotDisposeImage() {
var bitmap = new TestImage();
var lease = ImageLease.NonOwning(bitmap);

lease.Dispose();

bitmap.Size.Width.Should().Be(1);
bitmap.Dispose();
}

private sealed class CustomLoader : IAsyncImageLoader {
public int Requests { get; private set; }

public Task<IImageLease?> LoadAsync(
ImageLoadRequest request,
CancellationToken cancellationToken = default) {
Requests++;
return Task.FromResult<IImageLease?>(ImageLease.Owned(new TestImage()));
}

public void Dispose() {
}
}

private sealed class TestImage : IImage, IDisposable {
private bool _disposed;

public Size Size {
get {
if (_disposed)
throw new ObjectDisposedException(nameof(TestImage));
return new Size(1, 1);
}
}

public void Draw(DrawingContext context, Rect sourceRect, Rect destRect) {
if (_disposed)
throw new ObjectDisposedException(nameof(TestImage));
}

public void Dispose() {
_disposed = true;
}
}
}
Loading