File: PersistentStateValueProviderKeyResolverTests.cs
Web Access
Project: src\src\Components\Components\test\Microsoft.AspNetCore.Components.Tests.csproj (Microsoft.AspNetCore.Components.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Text.Json;
using Microsoft.AspNetCore.Components.Infrastructure;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Components.RenderTree;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Logging.Testing;
 
namespace Microsoft.AspNetCore.Components;
 
public class PersistentStateValueProviderKeyResolverTests
{
    public static TheoryData<object, object> ValidKeyTypesData => new TheoryData<object, object>
    {
        { true, false },
        { 'A', 'B' },
        { (sbyte)42, (sbyte)-42 },
        { (byte)240, (byte)15 },
        { (short)12345, (short)-12345 },
        { (ushort)54321, (ushort)12345 },
        { 42, -42 },
        { (uint)3000000000, (uint)1000000000 },
        { 9223372036854775807L, -9223372036854775808L },
        { (ulong)18446744073709551615UL, (ulong)1 },
        { 3.14159f, -3.14159f },
        { Math.PI, -Math.PI },
        { 123456.789m, -123456.789m },
        { new DateTime(2023, 1, 1), new DateTime(2023, 12, 31) },
        { new DateTimeOffset(2023, 1, 1, 0, 0, 0, TimeSpan.FromSeconds(0)), new DateTimeOffset(2023, 12, 31, 0, 0, 0, TimeSpan.FromSeconds(0)) },
        { "key1", "key2" },
        // Include a very long key to validate logic around growing buffers
        { new string('a', 10000), new string('b', 10000) },
        { Guid.NewGuid(), Guid.NewGuid() },
        { new DateOnly(2023, 1, 1), new DateOnly(2023, 12, 31) },
        { new TimeOnly(12, 34, 56), new TimeOnly(23, 45, 56) },
    };
 
    public static TheoryData<object, object> InvalidKeyTypesData => new TheoryData<object, object>
    {
        { new object(), new object() },
        { new TestComponent(), new TestComponent() }
    };
 
    [Fact]
    public async Task PersistAsync_CanPersistMultipleComponentsOfSameType_WhenParentProvidesDifferentKeys()
    {
        // Arrange
        var state = new Dictionary<string, byte[]>();
        var store = new TestStore(state);
        var persistenceManager = new ComponentStatePersistenceManager(
            NullLogger<ComponentStatePersistenceManager>.Instance,
            new ServiceCollection().BuildServiceProvider());
 
        persistenceManager.State.InitializeExistingState(state, RestoreContext.InitialValue);
 
        var renderer = new TestRenderer();
        var component1 = new TestComponent { State = "state1" };
        var component2 = new TestComponent { State = "state2" };
 
        var parentComponent = new ParentComponent();
 
        var componentStates = CreateComponentState(renderer, [(component1, "key1"), (component2, "key2")], parentComponent);
        var componentState1 = componentStates[0];
        var componentState2 = componentStates[1];
 
        var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(TestComponent.State), typeof(string));
 
        var provider = new PersistentStateValueProvider(persistenceManager.State, NullLogger<PersistentStateValueProvider>.Instance, new ServiceCollection().BuildServiceProvider());
 
        provider.Subscribe(componentState1, cascadingParameterInfo);
        provider.Subscribe(componentState2, cascadingParameterInfo);
 
        // Act
        await persistenceManager.PersistStateAsync(store, renderer);
 
        // Assert
        Assert.NotEmpty(store.State);
 
        // Check that keys are different for different component instances with different keys
        var key1 = PersistentStateValueProviderKeyResolver.ComputeKey(componentState1, cascadingParameterInfo.PropertyName);
        var key2 = PersistentStateValueProviderKeyResolver.ComputeKey(componentState2, cascadingParameterInfo.PropertyName);
 
        Assert.NotEqual(key1, key2);
 
        // Verify both states were persisted correctly
        var newState = new PersistentComponentState(new Dictionary<string, byte[]>(), [], []);
        newState.InitializeExistingState(store.State, null);
 
        Assert.True(newState.TryTakeFromJson<string>(key1, out var retrievedValue1));
        Assert.Equal("state1", retrievedValue1);
 
        Assert.True(newState.TryTakeFromJson<string>(key2, out var retrievedValue2));
        Assert.Equal("state2", retrievedValue2);
    }
 
    [Theory]
    [MemberData(nameof(ValidKeyTypesData))]
    public async Task PersistAsync_CanPersistMultipleComponentsOfSameType_SupportsDifferentKeyTypes(
        object componentKey1,
        object componentKey2)
    {
        // Arrange
        var state = new Dictionary<string, byte[]>();
        var store = new TestStore(state);
        var persistenceManager = new ComponentStatePersistenceManager(
            NullLogger<ComponentStatePersistenceManager>.Instance,
            new ServiceCollection().BuildServiceProvider());
 
        await persistenceManager.RestoreStateAsync(new TestStore([]), RestoreContext.InitialValue);
 
        var renderer = new TestRenderer();
        var component1 = new TestComponent { State = "state1" };
        var component2 = new TestComponent { State = "state2" };
 
        var parentComponent = new ParentComponent();
 
        var componentStates = CreateComponentState(renderer, [(component1, componentKey1), (component2, componentKey2)], parentComponent);
        var componentState1 = componentStates[0];
        var componentState2 = componentStates[1];
 
        var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(TestComponent.State), typeof(string));
 
        var provider = new PersistentStateValueProvider(persistenceManager.State, NullLogger<PersistentStateValueProvider>.Instance, new ServiceCollection().BuildServiceProvider());
 
        provider.Subscribe(componentState1, cascadingParameterInfo);
        provider.Subscribe(componentState2, cascadingParameterInfo);
 
        // Act
        await persistenceManager.PersistStateAsync(store, renderer);
 
        // Assert
        Assert.NotEmpty(store.State);
 
        // Check that keys are different for different component instances with different keys
        var key1 = PersistentStateValueProviderKeyResolver.ComputeKey(componentState1, cascadingParameterInfo.PropertyName);
        var key2 = PersistentStateValueProviderKeyResolver.ComputeKey(componentState2, cascadingParameterInfo.PropertyName);
 
        Assert.NotEqual(key1, key2);
 
        // Verify both states were persisted correctly
        var newState = new PersistentComponentState(new Dictionary<string, byte[]>(), [], []);
        newState.InitializeExistingState(store.State, null);
 
        Assert.True(newState.TryTakeFromJson<string>(key1, out var retrievedValue1));
        Assert.Equal("state1", retrievedValue1);
 
        Assert.True(newState.TryTakeFromJson<string>(key2, out var retrievedValue2));
        Assert.Equal("state2", retrievedValue2);
    }
 
    [Fact]
    public async Task PersistenceFails_IfMultipleComponentsOfSameType_TryToPersistDataWithoutParentComponents()
    {
        // Arrange
        var (logger, sink) = CreateTestLogger();
        var state = new Dictionary<string, byte[]>();
        var store = new TestStore(state);
        var persistenceManager = new ComponentStatePersistenceManager(
            logger,
            new ServiceCollection().BuildServiceProvider());
 
        persistenceManager.State.InitializeExistingState(state, RestoreContext.InitialValue);
 
        var renderer = new TestRenderer();
        var component1 = new TestComponent { State = "state1" };
        var component2 = new TestComponent { State = "state2" };
 
        var componentStates = CreateComponentState(renderer, [(component1, null), (component2, null)], null);
        var componentState1 = componentStates[0];
        var componentState2 = componentStates[1];
 
        var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(TestComponent.State), typeof(string));
 
        var provider = new PersistentStateValueProvider(persistenceManager.State, NullLogger<PersistentStateValueProvider>.Instance, new ServiceCollection().BuildServiceProvider());
 
        provider.Subscribe(componentState1, cascadingParameterInfo);
        provider.Subscribe(componentState2, cascadingParameterInfo);
 
        // Check that keys are the same for multiple component instances without keys
        var key1 = PersistentStateValueProviderKeyResolver.ComputeKey(componentState1, cascadingParameterInfo.PropertyName);
        var key2 = PersistentStateValueProviderKeyResolver.ComputeKey(componentState2, cascadingParameterInfo.PropertyName);
 
        Assert.Equal(key1, key2);
 
        // Act
        await persistenceManager.PersistStateAsync(store, renderer);
 
        // Assert
        var messages = sink.Writes.Where(x => x.LogLevel >= LogLevel.Warning).ToList();
        Assert.Single(messages);
    }
 
    private static (ILogger<ComponentStatePersistenceManager> logger, TestSink testLoggerSink) CreateTestLogger()
    {
        var testLoggerSink = new TestSink();
        var loggerFactory = new TestLoggerFactory(testLoggerSink, enabled: true);
        var logger = loggerFactory.CreateLogger<ComponentStatePersistenceManager>();
        return (logger, testLoggerSink);
    }
 
    [Fact]
    public async Task PersistentceFails_IfMultipleComponentsOfSameType_TryToPersistDataWithParentComponentOfSameType()
    {
        // Arrange
        var (logger, sink) = CreateTestLogger();
        var state = new Dictionary<string, byte[]>();
        var store = new TestStore(state);
        var persistenceManager = new ComponentStatePersistenceManager(
            logger,
            new ServiceCollection().BuildServiceProvider());
 
        persistenceManager.State.InitializeExistingState(state, RestoreContext.InitialValue);
 
        var renderer = new TestRenderer();
        var component1 = new TestComponent { State = "state1" };
        var component2 = new TestComponent { State = "state2" };
 
        var parentComponent = new TestComponent();
 
        var componentStates = CreateComponentState(renderer, [(component1, null), (component2, null)], parentComponent);
        var componentState1 = componentStates[0];
        var componentState2 = componentStates[1];
 
        var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(TestComponent.State), typeof(string));
 
        var provider = new PersistentStateValueProvider(persistenceManager.State, NullLogger<PersistentStateValueProvider>.Instance, new ServiceCollection().BuildServiceProvider());
 
        provider.Subscribe(componentState1, cascadingParameterInfo);
        provider.Subscribe(componentState2, cascadingParameterInfo);
 
        // Act
        await persistenceManager.PersistStateAsync(store, renderer);
 
        // Assert
        var messages = sink.Writes.Where(x => x.LogLevel >= LogLevel.Warning).ToList();
        Assert.Single(messages);
    }
 
    [Fact]
    public async Task PersistenceFails_MultipleComponentsUseTheSameKey()
    {
        // Arrange
        var (logger, sink) = CreateTestLogger();
        var state = new Dictionary<string, byte[]>();
        var store = new TestStore(state);
        var persistenceManager = new ComponentStatePersistenceManager(
            logger,
            new ServiceCollection().BuildServiceProvider());
 
        persistenceManager.State.InitializeExistingState(state, RestoreContext.InitialValue);
 
        var renderer = new TestRenderer();
        var component1 = new TestComponent { State = "state1" };
        var component2 = new TestComponent { State = "state2" };
 
        var parentComponent = new ParentComponent();
 
        var componentStates = CreateComponentState(renderer, [(component1, "key1"), (component2, "key1")], parentComponent);
        var componentState1 = componentStates[0];
        var componentState2 = componentStates[1];
 
        var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(TestComponent.State), typeof(string));
 
        var provider = new PersistentStateValueProvider(persistenceManager.State, NullLogger<PersistentStateValueProvider>.Instance, new ServiceCollection().BuildServiceProvider());
 
        provider.Subscribe(componentState1, cascadingParameterInfo);
        provider.Subscribe(componentState2, cascadingParameterInfo);
 
        // Act
        await persistenceManager.PersistStateAsync(store, renderer);
 
        // Assert
        var messages = sink.Writes.Where(x => x.LogLevel >= LogLevel.Warning).ToList();
        Assert.Single(messages);
    }
 
    [Theory]
    [MemberData(nameof(InvalidKeyTypesData))]
    public async Task PersistenceFails_MultipleComponentsUseInvalidKeyTypes(object componentKeyType1, object componentKeyType2)
    {
        // Arrange
        var (logger, sink) = CreateTestLogger();
        var state = new Dictionary<string, byte[]>();
        var store = new TestStore(state);
        var persistenceManager = new ComponentStatePersistenceManager(
            logger,
            new ServiceCollection().BuildServiceProvider());
 
        persistenceManager.State.InitializeExistingState(state, RestoreContext.InitialValue);
 
        var renderer = new TestRenderer();
        var component1 = new TestComponent { State = "state1" };
        var component2 = new TestComponent { State = "state2" };
 
        var parentComponent = new ParentComponent();
 
        var componentStates = CreateComponentState(renderer, [(component1, componentKeyType1), (component2, componentKeyType2)], parentComponent);
        var componentState1 = componentStates[0];
        var componentState2 = componentStates[1];
 
        var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(TestComponent.State), typeof(string));
 
        var provider = new PersistentStateValueProvider(persistenceManager.State, NullLogger<PersistentStateValueProvider>.Instance, new ServiceCollection().BuildServiceProvider());
 
        provider.Subscribe(componentState1, cascadingParameterInfo);
        provider.Subscribe(componentState2, cascadingParameterInfo);
 
        // Act
        await persistenceManager.PersistStateAsync(store, renderer);
 
        // Assert
        var messages = sink.Writes.Where(x => x.LogLevel >= LogLevel.Warning).ToList();
        Assert.Single(messages);
    }
 
    private static CascadingParameterInfo CreateCascadingParameterInfo(string propertyName, Type propertyType)
    {
        return new CascadingParameterInfo(
            new PersistentStateAttribute(),
            propertyName,
            propertyType);
    }
 
    private static List<ComponentState> CreateComponentState(
        TestRenderer renderer,
        List<(IComponent, object)> components,
        IComponent parentComponent = null)
    {
        var i = 1;
        var parentComponentState = parentComponent != null ? new ComponentState(renderer, i++, parentComponent, null) : null;
        var currentRenderTree = parentComponentState?.CurrentRenderTree;
        var result = new List<ComponentState>();
        foreach (var (component, key) in components)
        {
            var componentState = new ComponentState(renderer, i++, component, parentComponentState);
            if (currentRenderTree != null && key != null)
            {
                // Open component based on the actual component type
                if (component is TestComponent)
                {
                    currentRenderTree.OpenComponent<TestComponent>(0);
                }
                else if (component is ValueTypeTestComponent)
                {
                    currentRenderTree.OpenComponent<ValueTypeTestComponent>(0);
                }
                else
                {
                    currentRenderTree.OpenComponent<IComponent>(0);
                }
 
                var frames = currentRenderTree.GetFrames();
                frames.Array[frames.Count - 1].ComponentStateField = componentState;
                if (key != null)
                {
                    currentRenderTree.SetKey(key);
                }
                currentRenderTree.CloseComponent();
            }
 
            result.Add(componentState);
        }
 
        return result;
    }
 
    private class TestRenderer() : Renderer(new ServiceCollection().BuildServiceProvider(), NullLoggerFactory.Instance)
    {
        public override Dispatcher Dispatcher => Dispatcher.CreateDefault();
 
        protected override void HandleException(Exception exception) => throw new NotImplementedException();
        protected override Task UpdateDisplayAsync(in RenderBatch renderBatch) => throw new NotImplementedException();
    }
 
    private class TestComponent : IComponent
    {
        [PersistentState]
        public string State { get; set; }
 
        public void Attach(RenderHandle renderHandle) => throw new NotImplementedException();
        public Task SetParametersAsync(ParameterView parameters) => throw new NotImplementedException();
    }
 
    private class ValueTypeTestComponent : IComponent
    {
        [PersistentState]
        public int IntValue { get; set; }
 
        [PersistentState]
        public int? NullableIntValue { get; set; }
 
        [PersistentState]
        public (string, int) TupleValue { get; set; }
 
        [PersistentState]
        public (string, int)? NullableTupleValue { get; set; }
 
        public void Attach(RenderHandle renderHandle) => throw new NotImplementedException();
        public Task SetParametersAsync(ParameterView parameters) => throw new NotImplementedException();
    }
 
    private class TestStore(Dictionary<string, byte[]> initialState) : IPersistentComponentStateStore
    {
        public IDictionary<string, byte[]> State { get; set; } = initialState;
 
        public Task<IDictionary<string, byte[]>> GetPersistedStateAsync()
        {
            return Task.FromResult(State);
        }
 
        public Task PersistStateAsync(IReadOnlyDictionary<string, byte[]> state)
        {
            // We copy the data here because it's no longer available after this call completes.
            State = state.ToDictionary(k => k.Key, v => v.Value);
            return Task.CompletedTask;
        }
    }
 
    private class ParentComponent : IComponent
    {
        public void Attach(RenderHandle renderHandle) => throw new NotImplementedException();
        public Task SetParametersAsync(ParameterView parameters) => throw new NotImplementedException();
    }
}