File: Forms\InputBaseTest.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.
 
using System.Globalization;
using Microsoft.AspNetCore.Components.Test.Helpers;
 
namespace Microsoft.AspNetCore.Components.Forms;
 
public class InputBaseTest
{
    [Fact]
    public async Task ThrowsIfEditContextChanges()
    {
        // Arrange
        var model = new TestModel();
        var rootComponent = new TestInputHostComponent<string, TestInputComponent<string>> { EditContext = new EditContext(model), ValueExpression = () => model.StringProperty };
        await InputRenderer.RenderAndGetComponent(rootComponent);
 
        // Act/Assert
        rootComponent.EditContext = new EditContext(model);
        var ex = Assert.Throws<InvalidOperationException>(() => rootComponent.TriggerRender());
        Assert.StartsWith($"{typeof(TestInputComponent<string>)} does not support changing the EditContext dynamically", ex.Message);
    }
 
    [Fact]
    public async Task ThrowsIfNoValueExpressionIsSupplied()
    {
        // Arrange
        var model = new TestModel();
        var rootComponent = new TestInputHostComponent<string, TestInputComponent<string>> { EditContext = new EditContext(model) };
 
        // Act/Assert
        var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => InputRenderer.RenderAndGetComponent(rootComponent));
        Assert.Contains($"{typeof(TestInputComponent<string>)} requires a value for the 'ValueExpression' parameter. Normally this is provided automatically when using 'bind-Value'.", ex.Message);
    }
 
    [Fact]
    public async Task GetsCurrentValueFromValueParameter()
    {
        // Arrange
        var model = new TestModel();
        var rootComponent = new TestInputHostComponent<string, TestInputComponent<string>>
        {
            EditContext = new EditContext(model),
            Value = "some value",
            ValueExpression = () => model.StringProperty
        };
 
        // Act
        var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent);
 
        // Assert
        Assert.Equal("some value", inputComponent.CurrentValue);
    }
 
    [Fact]
    public async Task ExposesEditContextToSubclass()
    {
        // Arrange
        var model = new TestModel();
        var rootComponent = new TestInputHostComponent<string, TestInputComponent<string>>
        {
            EditContext = new EditContext(model),
            Value = "some value",
            ValueExpression = () => model.StringProperty
        };
 
        // Act
        var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent);
 
        // Assert
        Assert.Same(rootComponent.EditContext, inputComponent.EditContext);
    }
 
    [Fact]
    public async Task ExposesFieldIdentifierToSubclass()
    {
        // Arrange
        var model = new TestModel();
        var rootComponent = new TestInputHostComponent<string, TestInputComponent<string>>
        {
            EditContext = new EditContext(model),
            Value = "some value",
            ValueExpression = () => model.StringProperty
        };
 
        // Act
        var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent);
 
        // Assert
        Assert.Equal(FieldIdentifier.Create(() => model.StringProperty), inputComponent.FieldIdentifier);
    }
 
    [Fact]
    public async Task CanReadBackChangesToCurrentValue()
    {
        // Arrange
        var model = new TestModel();
        var rootComponent = new TestInputHostComponent<string, TestInputComponent<string>>
        {
            EditContext = new EditContext(model),
            Value = "initial value",
            ValueExpression = () => model.StringProperty
        };
        var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent);
        Assert.Equal("initial value", inputComponent.CurrentValue);
 
        // Act
        inputComponent.CurrentValue = "new value";
 
        // Assert
        Assert.Equal("new value", inputComponent.CurrentValue);
    }
 
    [Fact]
    public async Task CanRenderWithoutEditContext()
    {
        // Arrange
        var model = new TestModel();
        var value = "some value";
        var rootComponent = new TestInputHostComponent<string, TestInputComponent<string>>
        {
            Value = value,
            ValueExpression = () => value
        };
 
        // Act/Assert
        var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent);
        Assert.Null(inputComponent.EditContext);
    }
 
    [Fact]
    public async Task WritingToCurrentValueInvokesValueChangedIfDifferent()
    {
        // Arrange
        var model = new TestModel();
        var valueChangedCallLog = new List<string>();
        var rootComponent = new TestInputHostComponent<string, TestInputComponent<string>>
        {
            EditContext = new EditContext(model),
            Value = "initial value",
            ValueChanged = val => valueChangedCallLog.Add(val),
            ValueExpression = () => model.StringProperty
        };
        var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent);
        Assert.Empty(valueChangedCallLog);
 
        // Act
        inputComponent.CurrentValue = "new value";
 
        // Assert
        Assert.Single(valueChangedCallLog, "new value");
    }
 
    [Fact]
    public async Task WritingToCurrentValueDoesNotInvokeValueChangedIfUnchanged()
    {
        // Arrange
        var model = new TestModel();
        var valueChangedCallLog = new List<string>();
        var rootComponent = new TestInputHostComponent<string, TestInputComponent<string>>
        {
            EditContext = new EditContext(model),
            Value = "initial value",
            ValueChanged = val => valueChangedCallLog.Add(val),
            ValueExpression = () => model.StringProperty
        };
        var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent);
        Assert.Empty(valueChangedCallLog);
 
        // Act
        inputComponent.CurrentValue = "initial value";
 
        // Assert
        Assert.Empty(valueChangedCallLog);
    }
 
    [Fact]
    public async Task WritingToCurrentValueNotifiesEditContext()
    {
        // Arrange
        var model = new TestModel();
        var rootComponent = new TestInputHostComponent<string, TestInputComponent<string>>
        {
            EditContext = new EditContext(model),
            Value = "initial value",
            ValueExpression = () => model.StringProperty
        };
        var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent);
        Assert.False(rootComponent.EditContext.IsModified(() => model.StringProperty));
 
        // Act
        inputComponent.CurrentValue = "new value";
 
        // Assert
        Assert.True(rootComponent.EditContext.IsModified(() => model.StringProperty));
    }
 
    [Fact]
    public async Task SuppliesFieldClassCorrespondingToFieldState()
    {
        // Arrange
        var model = new TestModel();
        var rootComponent = new TestInputHostComponent<string, TestInputComponent<string>>
        {
            EditContext = new EditContext(model),
            ValueExpression = () => model.StringProperty
        };
        var fieldIdentifier = FieldIdentifier.Create(() => model.StringProperty);
 
        // Act/Assert: Initially, it's valid and unmodified
        var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent);
        Assert.Equal("valid", inputComponent.CssClass); //  no Class was specified
 
        // Act/Assert: Modify the field
        rootComponent.EditContext.NotifyFieldChanged(fieldIdentifier);
        Assert.Equal("modified valid", inputComponent.CssClass);
 
        // Act/Assert: Make it invalid
        var messages = new ValidationMessageStore(rootComponent.EditContext);
        messages.Add(fieldIdentifier, "I do not like this value");
        Assert.Equal("modified invalid", inputComponent.CssClass);
 
        // Act/Assert: Clear the modification flag
        rootComponent.EditContext.MarkAsUnmodified(fieldIdentifier);
        Assert.Equal("invalid", inputComponent.CssClass);
 
        // Act/Assert: Make it valid
        messages.Clear();
        Assert.Equal("valid", inputComponent.CssClass);
    }
 
    [Fact]
    public async Task CssClassCombinesClassWithFieldClass()
    {
        // Arrange
        var model = new TestModel();
        var rootComponent = new TestInputHostComponent<string, TestInputComponent<string>>
        {
            AdditionalAttributes = new Dictionary<string, object>()
                {
                    { "class", "my-class other-class" },
                },
            EditContext = new EditContext(model),
            ValueExpression = () => model.StringProperty
        };
        var fieldIdentifier = FieldIdentifier.Create(() => model.StringProperty);
 
        // Act/Assert
        var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent);
        Assert.Equal("my-class other-class valid", inputComponent.CssClass);
 
        // Act/Assert: Retains custom class when changing field class
        rootComponent.EditContext.NotifyFieldChanged(fieldIdentifier);
        Assert.Equal("my-class other-class modified valid", inputComponent.CssClass);
    }
 
    [Fact]
    public async Task SuppliesCurrentValueAsStringWithFormatting()
    {
        // Arrange
        var model = new TestModel();
        var rootComponent = new TestInputHostComponent<DateTime, TestDateInputComponent>
        {
            EditContext = new EditContext(model),
            Value = new DateTime(1915, 3, 2),
            ValueExpression = () => model.DateProperty
        };
        var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent);
 
        // Act/Assert
        Assert.Equal("1915/03/02", inputComponent.CurrentValueAsString);
    }
 
    [Fact]
    public async Task ParsesCurrentValueAsStringWhenChanged_Valid()
    {
        // Arrange
        var model = new TestModel();
        var valueChangedArgs = new List<DateTime>();
        var rootComponent = new TestInputHostComponent<DateTime, TestDateInputComponent>
        {
            EditContext = new EditContext(model),
            ValueChanged = valueChangedArgs.Add,
            ValueExpression = () => model.DateProperty
        };
        var fieldIdentifier = FieldIdentifier.Create(() => model.DateProperty);
        var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent);
        var numValidationStateChanges = 0;
        rootComponent.EditContext.OnValidationStateChanged += (sender, eventArgs) => { numValidationStateChanges++; };
 
        // Act
        await inputComponent.SetCurrentValueAsStringAsync("1991/11/20");
 
        // Assert
        var receivedParsedValue = valueChangedArgs.Single();
        Assert.Equal(1991, receivedParsedValue.Year);
        Assert.Equal(11, receivedParsedValue.Month);
        Assert.Equal(20, receivedParsedValue.Day);
        Assert.True(rootComponent.EditContext.IsModified(fieldIdentifier));
        Assert.Empty(rootComponent.EditContext.GetValidationMessages(fieldIdentifier));
        Assert.Equal(0, numValidationStateChanges);
    }
 
    [Fact]
    public async Task ParsesCurrentValueAsStringWhenChanged_Invalid()
    {
        // Arrange
        var model = new TestModel();
        var valueChangedArgs = new List<DateTime>();
        var rootComponent = new TestInputHostComponent<DateTime, TestDateInputComponent>
        {
            EditContext = new EditContext(model),
            ValueChanged = valueChangedArgs.Add,
            ValueExpression = () => model.DateProperty
        };
        var fieldIdentifier = FieldIdentifier.Create(() => model.DateProperty);
        var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent);
        var numValidationStateChanges = 0;
        rootComponent.EditContext.OnValidationStateChanged += (sender, eventArgs) => { numValidationStateChanges++; };
 
        // Act/Assert 1: Transition to invalid
        await inputComponent.SetCurrentValueAsStringAsync("1991/11/40");
        Assert.Empty(valueChangedArgs);
        Assert.True(rootComponent.EditContext.IsModified(fieldIdentifier));
        Assert.Equal(new[] { "Bad date value" }, rootComponent.EditContext.GetValidationMessages(fieldIdentifier));
        Assert.Equal(1, numValidationStateChanges);
 
        // Act/Assert 2: Transition to valid
        await inputComponent.SetCurrentValueAsStringAsync("1991/11/20");
        var receivedParsedValue = valueChangedArgs.Single();
        Assert.Equal(1991, receivedParsedValue.Year);
        Assert.Equal(11, receivedParsedValue.Month);
        Assert.Equal(20, receivedParsedValue.Day);
        Assert.True(rootComponent.EditContext.IsModified(fieldIdentifier));
        Assert.Empty(rootComponent.EditContext.GetValidationMessages(fieldIdentifier));
        Assert.Equal(2, numValidationStateChanges);
    }
 
    [Fact]
    public async Task ClearsParsingValidationMessagesWhenDisposed()
    {
        // Arrange
        var model = new TestModel();
        var rootComponent = new TestInputHostComponent<DateTime, TestDateInputComponent>
        {
            EditContext = new EditContext(model),
            ValueExpression = () => model.DateProperty
        };
        var fieldIdentifier = FieldIdentifier.Create(() => model.DateProperty);
        var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent);
 
        // Act + Assert 1 (Precondition): The test needs a validation message to be removed later.
        await inputComponent.SetCurrentValueAsStringAsync("1991/11/40");
        Assert.Equal(new[] { "Bad date value" }, rootComponent.EditContext.GetValidationMessages(fieldIdentifier));
 
        // Act: Dispose the input component
        (inputComponent as IDisposable).Dispose();
 
        // Assert 2
        Assert.Empty(rootComponent.EditContext.GetValidationMessages(fieldIdentifier));
    }
 
    [Fact]
    public async Task RespondsToValidationStateChangeNotifications()
    {
        // Arrange
        var model = new TestModel();
        var rootComponent = new TestInputHostComponent<string, TestInputComponent<string>>
        {
            EditContext = new EditContext(model),
            ValueExpression = () => model.StringProperty
        };
        var fieldIdentifier = FieldIdentifier.Create(() => model.StringProperty);
        var renderer = new TestRenderer();
        var rootComponentId = renderer.AssignRootComponentId(rootComponent);
        await renderer.RenderRootComponentAsync(rootComponentId);
 
        // Initally, it rendered one batch and is valid
        var batch1 = renderer.Batches.Single();
        var componentFrame1 = batch1.GetComponentFrames<TestInputComponent<string>>().Single();
        var inputComponentId = componentFrame1.ComponentId;
        var component = (TestInputComponent<string>)componentFrame1.Component;
        Assert.Equal("valid", component.CssClass);
        Assert.Null(component.AdditionalAttributes);
 
        // Act: update the field state in the EditContext and notify
        var messageStore = new ValidationMessageStore(rootComponent.EditContext);
        messageStore.Add(fieldIdentifier, "Some message");
        await renderer.Dispatcher.InvokeAsync(rootComponent.EditContext.NotifyValidationStateChanged);
 
        // Assert: The input component rendered itself again and now has the new class
        var batch2 = renderer.Batches.Skip(1).Single();
        Assert.Equal(inputComponentId, batch2.DiffsByComponentId.Keys.Single());
        Assert.Equal("invalid", component.CssClass);
        Assert.NotNull(component.AdditionalAttributes);
        Assert.True(component.AdditionalAttributes.ContainsKey("aria-invalid"));
    }
 
    [Fact]
    public async Task UnsubscribesFromValidationStateChangeNotifications()
    {
        // Arrange
        var model = new TestModel();
        var rootComponent = new TestInputHostComponent<string, TestInputComponent<string>>
        {
            EditContext = new EditContext(model),
            ValueExpression = () => model.StringProperty
        };
        var fieldIdentifier = FieldIdentifier.Create(() => model.StringProperty);
        var renderer = new TestRenderer();
        var rootComponentId = renderer.AssignRootComponentId(rootComponent);
        await renderer.RenderRootComponentAsync(rootComponentId);
        var component = renderer.Batches.Single().GetComponentFrames<TestInputComponent<string>>().Single().Component;
 
        // Act: dispose, then update the field state in the EditContext and notify
        ((IDisposable)component).Dispose();
        var messageStore = new ValidationMessageStore(rootComponent.EditContext);
        messageStore.Add(fieldIdentifier, "Some message");
        await renderer.Dispatcher.InvokeAsync(rootComponent.EditContext.NotifyValidationStateChanged);
 
        // Assert: No additional render
        Assert.Empty(renderer.Batches.Skip(1));
    }
 
    [Fact]
    public async Task AriaAttributeIsRenderedWhenTheValidationStateIsInvalidOnFirstRender()
    {
        // Arrange// Arrange
        var model = new TestModel();
        var invalidContext = new EditContext(model);
 
        var rootComponent = new TestInputHostComponent<string, TestInputComponent<string>>
        {
            EditContext = invalidContext,
            ValueExpression = () => model.StringProperty
        };
 
        var fieldIdentifier = FieldIdentifier.Create(() => model.StringProperty);
        var messageStore = new ValidationMessageStore(invalidContext);
        messageStore.Add(fieldIdentifier, "Test error message");
 
        var renderer = new TestRenderer();
        var rootComponentId = renderer.AssignRootComponentId(rootComponent);
        await renderer.RenderRootComponentAsync(rootComponentId);
 
        // Initally, it rendered one batch and is valid
        var batch1 = renderer.Batches.Single();
        var componentFrame1 = batch1.GetComponentFrames<TestInputComponent<string>>().Single();
        var inputComponentId = componentFrame1.ComponentId;
        var component = (TestInputComponent<string>)componentFrame1.Component;
        Assert.Equal("invalid", component.CssClass);
        Assert.NotNull(component.AdditionalAttributes);
        Assert.Single(component.AdditionalAttributes);
        //Check for "true" see https://www.w3.org/TR/wai-aria-1.1/#aria-invalid
        Assert.Equal("true", component.AdditionalAttributes["aria-invalid"]);
    }
 
    [Fact]
    public async Task UserSpecifiedAriaValueIsNotChangedIfInvalid()
    {
        // Arrange// Arrange
        var model = new TestModel();
        var invalidContext = new EditContext(model);
 
        var rootComponent = new TestInputHostComponent<string, TestInputComponent<string>>
        {
            EditContext = invalidContext,
            ValueExpression = () => model.StringProperty
        };
        rootComponent.AdditionalAttributes = new Dictionary<string, object>();
        rootComponent.AdditionalAttributes["aria-invalid"] = "userSpecifiedValue";
 
        var fieldIdentifier = FieldIdentifier.Create(() => model.StringProperty);
        var messageStore = new ValidationMessageStore(invalidContext);
        messageStore.Add(fieldIdentifier, "Test error message");
 
        var renderer = new TestRenderer();
        var rootComponentId = renderer.AssignRootComponentId(rootComponent);
        await renderer.RenderRootComponentAsync(rootComponentId);
 
        // Initally, it rendered one batch and is valid
        var batch1 = renderer.Batches.Single();
        var componentFrame1 = batch1.GetComponentFrames<TestInputComponent<string>>().Single();
        var inputComponentId = componentFrame1.ComponentId;
        var component = (TestInputComponent<string>)componentFrame1.Component;
        Assert.Equal("invalid", component.CssClass);
        Assert.NotNull(component.AdditionalAttributes);
        Assert.Single(component.AdditionalAttributes);
        Assert.Equal("userSpecifiedValue", component.AdditionalAttributes["aria-invalid"]);
    }
 
    [Fact]
    public async Task AriaAttributeRemovedWhenStateChangesToValidFromInvalid()
    {
        // Arrange
        var model = new TestModel();
        var rootComponent = new TestInputHostComponent<string, TestInputComponent<string>>
        {
            EditContext = new EditContext(model),
            ValueExpression = () => model.StringProperty
        };
        var fieldIdentifier = FieldIdentifier.Create(() => model.StringProperty);
        var renderer = new TestRenderer();
        var messageStore = new ValidationMessageStore(rootComponent.EditContext);
        messageStore.Add(fieldIdentifier, "Artificial error message");
        var rootComponentId = renderer.AssignRootComponentId(rootComponent);
        await renderer.RenderRootComponentAsync(rootComponentId);
 
        // Initally, it rendered one batch and is invalid
        var batch1 = renderer.Batches.Single();
        var componentFrame1 = batch1.GetComponentFrames<TestInputComponent<string>>().Single();
        var inputComponentId = componentFrame1.ComponentId;
        var component = (TestInputComponent<string>)componentFrame1.Component;
        Assert.Equal("invalid", component.CssClass);
        Assert.NotNull(component.AdditionalAttributes);
        Assert.True(component.AdditionalAttributes.ContainsKey("aria-invalid"));
 
        // Act: update the field state in the EditContext and notify
        messageStore.Clear(fieldIdentifier);
        await renderer.Dispatcher.InvokeAsync(rootComponent.EditContext.NotifyValidationStateChanged);
 
        // Assert: The input component rendered itself again and now has the new class
        var batch2 = renderer.Batches.Skip(1).Single();
        Assert.Equal(inputComponentId, batch2.DiffsByComponentId.Keys.Single());
        Assert.Equal("valid", component.CssClass);
        Assert.Null(component.AdditionalAttributes);
    }
 
    class TestModel
    {
        public string StringProperty { get; set; }
 
        public DateTime DateProperty { get; set; }
    }
 
    class TestInputComponent<T> : InputBase<T>
    {
        // Expose protected members publicly for tests
 
        public new T CurrentValue
        {
            get => base.CurrentValue;
            set { base.CurrentValue = value; }
        }
 
        public new string CurrentValueAsString
        {
            get => base.CurrentValueAsString;
        }
 
        public new IReadOnlyDictionary<string, object> AdditionalAttributes => base.AdditionalAttributes;
 
        public new string CssClass => base.CssClass;
 
        public new EditContext EditContext => base.EditContext;
 
        public new FieldIdentifier FieldIdentifier => base.FieldIdentifier;
 
        protected override bool TryParseValueFromString(string value, out T result, out string validationErrorMessage)
        {
            throw new NotImplementedException();
        }
 
        public async Task SetCurrentValueAsStringAsync(string value)
        {
            // This is equivalent to the subclass writing to CurrentValueAsString
            // (e.g., from @bind), except to simplify the test code there's an InvokeAsync
            // here. In production code it wouldn't normally be required because @bind
            // calls run on the sync context anyway.
            await InvokeAsync(() => { base.CurrentValueAsString = value; });
        }
    }
 
    private class TestDateInputComponent : TestInputComponent<DateTime>
    {
        protected override string FormatValueAsString(DateTime value)
            => value.ToString("yyyy/MM/dd", CultureInfo.InvariantCulture);
 
        protected override bool TryParseValueFromString(string value, out DateTime result, out string validationErrorMessage)
        {
            if (DateTime.TryParse(value, out result))
            {
                validationErrorMessage = null;
                return true;
            }
            else
            {
                validationErrorMessage = "Bad date value";
                return false;
            }
        }
    }
}