File: Image\ImageTest.cs
Web Access
Project: src\src\Components\Web\test\Microsoft.AspNetCore.Components.Web.Tests.csproj (Microsoft.AspNetCore.Components.Web.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
#nullable enable
 
using System;
using System.Collections.Generic;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using System.Linq;
using System.Text.Json;
using System.IO;
using Microsoft.AspNetCore.Components.Test.Helpers;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.JSInterop;
using Xunit;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.RenderTree;
 
namespace Microsoft.AspNetCore.Components.Web.Image.Tests;
 
/// <summary>
/// Unit tests for the Image component
/// </summary>
public class ImageTest
{
    private static readonly byte[] PngBytes = Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAA1JREFUGFdjqK6u/g8ABVcCcYoGhmwAAAAASUVORK5CYII=");
 
    [Fact]
    public async Task LoadsImage_InvokesSetImageAsync_WhenSourceProvided()
    {
        var js = new FakeImageJsRuntime(cacheHit: false);
        using var renderer = CreateRenderer(js);
        var comp = (Image)renderer.InstantiateComponent<Image>();
        var id = renderer.AssignRootComponentId(comp);
 
        var source = new ImageSource(PngBytes, "image/png", cacheKey: "png-1");
        await renderer.RenderRootComponentAsync(id, ParameterView.FromDictionary(new Dictionary<string, object?>
        {
            [nameof(Image.Source)] = source,
        }));
 
        Assert.Equal(1, js.Count("Blazor._internal.BinaryImageComponent.setImageAsync"));
    }
 
    [Fact]
    public async Task SkipsReload_OnSameCacheKey()
    {
        var js = new FakeImageJsRuntime(cacheHit: false);
        using var renderer = CreateRenderer(js);
        var comp = (Image)renderer.InstantiateComponent<Image>();
        var id = renderer.AssignRootComponentId(comp);
 
        var s1 = new ImageSource(new byte[10], "image/png", cacheKey: "same");
        await renderer.RenderRootComponentAsync(id, ParameterView.FromDictionary(new Dictionary<string, object?>
        {
            [nameof(Image.Source)] = s1,
        }));
 
        var s2 = new ImageSource(new byte[20], "image/png", cacheKey: "same");
        await renderer.RenderRootComponentAsync(id, ParameterView.FromDictionary(new Dictionary<string, object?>
        {
            [nameof(Image.Source)] = s2,
        }));
 
        // Implementation skips reloading when cache key unchanged.
        Assert.Equal(1, js.Count("Blazor._internal.BinaryImageComponent.setImageAsync"));
    }
 
    [Fact]
    public async Task NullSource_Throws()
    {
        var js = new FakeImageJsRuntime(cacheHit: false);
        using var renderer = CreateRenderer(js);
        var comp = (Image)renderer.InstantiateComponent<Image>();
        var id = renderer.AssignRootComponentId(comp);
 
        await Assert.ThrowsAsync<InvalidOperationException>(async () =>
        {
            await renderer.RenderRootComponentAsync(id, ParameterView.FromDictionary(new Dictionary<string, object?>
            {
                [nameof(Image.Source)] = null,
            }));
        });
 
        // Ensure no JS interop calls were made
        Assert.Equal(0, js.TotalInvocationCount);
    }
 
    [Fact]
    public async Task ParameterChange_DifferentCacheKey_Reloads()
    {
        var js = new FakeImageJsRuntime(cacheHit: false);
        using var renderer = CreateRenderer(js);
        var comp = (Image)renderer.InstantiateComponent<Image>();
        var id = renderer.AssignRootComponentId(comp);
        var s1 = new ImageSource(new byte[4], "image/png", "key-a");
        await renderer.RenderRootComponentAsync(id, ParameterView.FromDictionary(new Dictionary<string, object?>
        {
            [nameof(Image.Source)] = s1,
        }));
        var s2 = new ImageSource(new byte[6], "image/png", "key-b");
        await renderer.RenderRootComponentAsync(id, ParameterView.FromDictionary(new Dictionary<string, object?>
        {
            [nameof(Image.Source)] = s2,
        }));
        Assert.Equal(2, js.Count("Blazor._internal.BinaryImageComponent.setImageAsync"));
    }
 
    [Fact]
    public async Task ChangingSource_CancelsPreviousLoad()
    {
        var js = new FakeImageJsRuntime(cacheHit: false) { DelayOnFirstSetCall = true };
        using var renderer = CreateRenderer(js);
        var comp = (Image)renderer.InstantiateComponent<Image>();
        var id = renderer.AssignRootComponentId(comp);
 
        var s1 = new ImageSource(new byte[10], "image/png", "k1");
        await renderer.RenderRootComponentAsync(id, ParameterView.FromDictionary(new Dictionary<string, object?>
        {
            [nameof(Image.Source)] = s1,
        }));
 
        var s2 = new ImageSource(new byte[10], "image/png", "k2");
        await renderer.RenderRootComponentAsync(id, ParameterView.FromDictionary(new Dictionary<string, object?>
        {
            [nameof(Image.Source)] = s2,
        }));
 
        // Give a tiny bit of time for cancellation to propagate
        for (var i = 0; i < 10 && js.CapturedTokens.Count < 2; i++)
        {
            await Task.Delay(10);
        }
 
        Assert.NotEmpty(js.CapturedTokens);
        Assert.True(js.CapturedTokens.First().IsCancellationRequested);
 
        // Two invocations total (first canceled, second completes)
        Assert.Equal(2, js.Count("Blazor._internal.BinaryImageComponent.setImageAsync"));
    }
 
    private static TestRenderer CreateRenderer(IJSRuntime js)
    {
        var services = new ServiceCollection();
        services.AddLogging();
        services.AddSingleton(js);
        return new InteractiveTestRenderer(services.BuildServiceProvider());
    }
 
    private sealed class InteractiveTestRenderer : TestRenderer
    {
        public InteractiveTestRenderer(IServiceProvider serviceProvider) : base(serviceProvider) { }
        protected internal override RendererInfo RendererInfo => new RendererInfo("Test", isInteractive: true);
    }
 
    private sealed class FakeImageJsRuntime : IJSRuntime
    {
        public sealed record Invocation(string Identifier, object?[] Args, CancellationToken Token);
        private readonly ConcurrentQueue<Invocation> _invocations = new();
        private readonly ConcurrentDictionary<string, bool> _memoryCache = new();
        private readonly bool _forceCacheHit;
 
        public FakeImageJsRuntime(bool cacheHit) { _forceCacheHit = cacheHit; }
 
        public int TotalInvocationCount => _invocations.Count;
        public int Count(string id) => _invocations.Count(i => i.Identifier == id);
        public IReadOnlyList<CancellationToken> CapturedTokens => _invocations.Select(i => i.Token).ToList();
        public void MarkCached(string cacheKey) => _memoryCache[cacheKey] = true;
 
        // Simulation flags
        public bool DelayOnFirstSetCall { get; set; }
        public bool ForceFail { get; set; }
        public bool FailOnce { get; set; } = true;
        public bool FailIfTotalBytesIsZero { get; set; }
        private bool _failUsed;
        private int _setCalls;
 
        public ValueTask<TValue> InvokeAsync<TValue>(string identifier, object?[]? args)
            => InvokeAsync<TValue>(identifier, CancellationToken.None, args ?? Array.Empty<object?>());
 
        public ValueTask<TValue> InvokeAsync<TValue>(string identifier, CancellationToken cancellationToken, object?[]? args)
        {
            args ??= Array.Empty<object?>();
            _invocations.Enqueue(new Invocation(identifier, args, cancellationToken));
 
            if (identifier == "Blazor._internal.BinaryImageComponent.setImageAsync")
            {
                _setCalls++;
                var cacheKey = args.Length >= 4 ? args[3] as string : null;
                var hasStream = args.Length >= 2 && args[1] != null;
                long? totalBytes = null;
                if (args.Length >= 5 && args[4] != null)
                {
                    try { totalBytes = Convert.ToInt64(args[4], System.Globalization.CultureInfo.InvariantCulture); } catch { totalBytes = null; }
                }
 
                if (DelayOnFirstSetCall && _setCalls == 1)
                {
                    var tcs = new TaskCompletionSource<TValue>(TaskCreationOptions.RunContinuationsAsynchronously);
                    cancellationToken.Register(() => tcs.TrySetException(new OperationCanceledException(cancellationToken)));
                    return new ValueTask<TValue>(tcs.Task);
                }
 
                var shouldFail = (ForceFail && (!_failUsed || !FailOnce))
                                 || (FailIfTotalBytesIsZero && (totalBytes.HasValue && totalBytes.Value == 0));
                if (ForceFail)
                {
                    _failUsed = true;
                }
 
                var fromCache = !shouldFail && cacheKey != null && (_forceCacheHit || _memoryCache.ContainsKey(cacheKey));
                if (!fromCache && hasStream && !string.IsNullOrEmpty(cacheKey) && !shouldFail)
                {
                    _memoryCache[cacheKey!] = true;
                }
 
                var t = typeof(TValue);
                object? instance = Activator.CreateInstance(t, nonPublic: true);
                if (instance is null)
                {
                    return ValueTask.FromResult(default(TValue)!);
                }
 
                var setProp = (string name, object? value) =>
                {
                    var p = t.GetProperty(name, System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance);
                    p?.SetValue(instance, value);
                };
 
                if (shouldFail)
                {
                    setProp("Success", false);
                    setProp("FromCache", false);
                    setProp("ObjectUrl", null);
                    setProp("Error", "simulated-failure");
                }
                else
                {
                    setProp("Success", hasStream || fromCache);
                    setProp("FromCache", fromCache);
                    setProp("ObjectUrl", (hasStream || fromCache) && cacheKey != null ? $"blob:{cacheKey}:{Guid.NewGuid()}" : null);
                    setProp("Error", null);
                }
 
                return ValueTask.FromResult((TValue)instance);
            }
 
            return ValueTask.FromResult(default(TValue)!);
        }
    }
}