File: SelectTagHelperTest.cs
Web Access
Project: src\src\Mvc\Mvc.TagHelpers\test\Microsoft.AspNetCore.Mvc.TagHelpers.Test.csproj (Microsoft.AspNetCore.Mvc.TagHelpers.Test)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Collections;
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;
using Microsoft.AspNetCore.InternalTesting;
using Moq;
 
namespace Microsoft.AspNetCore.Mvc.TagHelpers;
 
public class SelectTagHelperTest
{
    // Model (List<Model> or Model instance), container type (Model or NestModel), model accessor,
    // property path / id, expected content.
    public static TheoryData<object, Type, Func<object>, NameAndId, string> GeneratesExpectedDataSet
    {
        get
        {
            var modelWithNull = new Model
            {
                NestedModel = new NestedModel
                {
                    Text = null,
                },
                Text = null,
            };
            var modelWithText = new Model
            {
                NestedModel = new NestedModel
                {
                    Text = "inner text",
                },
                Text = "outer text",
            };
            var models = new List<Model>
                {
                    modelWithNull,
                    modelWithText,
                };
            var noneSelected = "<option></option>" + Environment.NewLine +
                "<option>HtmlEncode[[outer text]]</option>" + Environment.NewLine +
                "<option>HtmlEncode[[inner text]]</option>" + Environment.NewLine +
                "<option>HtmlEncode[[other text]]</option>" + Environment.NewLine;
            var innerSelected = "<option></option>" + Environment.NewLine +
                "<option>HtmlEncode[[outer text]]</option>" + Environment.NewLine +
                "<option selected=\"HtmlEncode[[selected]]\">HtmlEncode[[inner text]]</option>" + Environment.NewLine +
                "<option>HtmlEncode[[other text]]</option>" + Environment.NewLine;
            var outerSelected = "<option></option>" + Environment.NewLine +
                "<option selected=\"HtmlEncode[[selected]]\">HtmlEncode[[outer text]]</option>" + Environment.NewLine +
                "<option>HtmlEncode[[inner text]]</option>" + Environment.NewLine +
                "<option>HtmlEncode[[other text]]</option>" + Environment.NewLine;
 
            return new TheoryData<object, Type, Func<object>, NameAndId, string>
                {
                    { null, typeof(Model), () => null, new NameAndId("Text", "Text"), noneSelected },
 
                    // Imitate a temporary variable set from somewhere else in the view model.
                    { null, typeof(Model), () => modelWithText.NestedModel.Text,
                        new NameAndId("item.Text", "item_Text"), innerSelected },
 
                    { modelWithNull, typeof(Model), () => modelWithNull.Text,
                        new NameAndId("Text", "Text"), noneSelected },
                    { modelWithText, typeof(Model), () => modelWithText.Text,
                        new NameAndId("Text", "Text"), outerSelected },
 
                    { modelWithNull, typeof(NestedModel), () => modelWithNull.NestedModel.Text,
                        new NameAndId("NestedModel.Text", "NestedModel_Text"), noneSelected },
                    { modelWithText, typeof(NestedModel), () => modelWithText.NestedModel.Text,
                        new NameAndId("NestedModel.Text", "NestedModel_Text"), innerSelected },
 
                    { models, typeof(Model), () => models[0].Text,
                        new NameAndId("[0].Text", "z0__Text"), noneSelected },
                    { models, typeof(NestedModel), () => models[0].NestedModel.Text,
                        new NameAndId("[0].NestedModel.Text", "z0__NestedModel_Text"), noneSelected },
 
                    { models, typeof(Model), () => models[1].Text,
                        new NameAndId("[1].Text", "z1__Text"), outerSelected },
                    { models, typeof(NestedModel), () => models[1].NestedModel.Text,
                        new NameAndId("[1].NestedModel.Text", "z1__NestedModel_Text"), innerSelected },
                };
        }
    }
 
    // Items property value, attribute name, attribute value, expected items value (passed to generator). Provides
    // cross product of Items and attributes. These values should not interact.
    public static TheoryData<IEnumerable<SelectListItem>, string, string, IEnumerable<SelectListItem>>
        ItemsAndMultipleDataSet
    {
        get
        {
            var arrayItems = new[] { new SelectListItem() };
            var listItems = new List<SelectListItem>();
            var multiItems = new MultiSelectList(Enumerable.Range(0, 5));
            var selectItems = new SelectList(Enumerable.Range(0, 5));
            var itemsData = new[]
            {
                    new[] { (IEnumerable<SelectListItem>)null, Enumerable.Empty<SelectListItem>() },
                    new[] { arrayItems, arrayItems },
                    new[] { listItems, listItems },
                    new[] { multiItems, multiItems },
                    new[] { selectItems, selectItems },
                };
            var attributeData = new Dictionary<string, string>(StringComparer.Ordinal)
                {
                    // SelectTagHelper ignores all "multiple" attribute values.
                    { "multiple", null },
                    { "mUltiple", string.Empty },
                    { "muLtiple", "true" },
                    { "Multiple", "false" },
                    { "MUltiple", "multiple" },
                    { "MuLtiple", "Multiple" },
                    { "mUlTiPlE", "mUlTiPlE" },
                    { "mULTiPlE", "MULTIPLE" },
                    { "mUlTIPlE", "Invalid" },
                    { "MULTiPLE", "0" },
                    { "MUlTIPLE", "1" },
                    { "MULTIPLE", "__true" },
                    // SelectTagHelper also ignores non-"multiple" attributes.
                    { "multiple_", "multiple" },
                    { "not-multiple", "multiple" },
                    { "__multiple", "multiple" },
                };
 
            var theoryData =
                new TheoryData<IEnumerable<SelectListItem>, string, string, IEnumerable<SelectListItem>>();
            foreach (var items in itemsData)
            {
                foreach (var attribute in attributeData)
                {
                    theoryData.Add(items[0], attribute.Key, attribute.Value, items[1]);
                }
            }
 
            return theoryData;
        }
    }
 
    // Model type, model, allowMultiple expected value
    public static TheoryData<Type, object, bool> RealModelTypeDataSet
    {
        get
        {
            return new TheoryData<Type, object, bool>
                {
                    { typeof(object), string.Empty, false },
                    { typeof(string), null, false },
                    { typeof(int?), null, false },
                    { typeof(int), 23, false },
                    { typeof(IEnumerable), null, true },
                    { typeof(IEnumerable<string>), null, true },
                    { typeof(List<int>), null, true },
                    { typeof(object), new[] { "", "", "" }, true },
                    { typeof(object), new List<string>(), true },
                };
        }
    }
 
    [Theory]
    [MemberData(nameof(GeneratesExpectedDataSet))]
    public async Task ProcessAsync_GeneratesExpectedOutput(
        object model,
        Type containerType,
        Func<object> modelAccessor,
        NameAndId nameAndId,
        string ignored)
    {
        // Arrange
        var originalAttributes = new TagHelperAttributeList
            {
                { "class", "form-control" },
            };
        var originalPostContent = "original content";
 
        var expectedAttributes = new TagHelperAttributeList(originalAttributes)
            {
                { "id", nameAndId.Id },
                { "name", nameAndId.Name },
                { "valid", "from validation attributes" },
            };
        var expectedPreContent = "original pre-content";
        var expectedContent = "original content";
        var expectedPostContent = originalPostContent;
        var expectedTagName = "not-select";
 
        var metadataProvider = new TestModelMetadataProvider();
        var containerMetadata = metadataProvider.GetMetadataForType(containerType);
        var containerExplorer = metadataProvider.GetModelExplorerForType(containerType, model);
 
        var propertyMetadata = metadataProvider.GetMetadataForProperty(containerType, "Text");
        var modelExplorer = containerExplorer.GetExplorerForExpression(propertyMetadata, modelAccessor());
 
        var modelExpression = new ModelExpression(nameAndId.Name, modelExplorer);
 
        var tagHelperContext = new TagHelperContext(
            tagName: "select",
            allAttributes: new TagHelperAttributeList(
                Enumerable.Empty<TagHelperAttribute>()),
            items: new Dictionary<object, object>(),
            uniqueId: "test");
        var output = new TagHelperOutput(
            expectedTagName,
            originalAttributes,
            getChildContentAsync: (useCachedResult, encoder) =>
            {
                var tagHelperContent = new DefaultTagHelperContent();
                tagHelperContent.SetContent("Something");
                return Task.FromResult<TagHelperContent>(tagHelperContent);
            })
        {
            TagMode = TagMode.SelfClosing,
        };
        output.PreContent.SetContent(expectedPreContent);
        output.Content.SetContent(expectedContent);
        output.PostContent.SetContent(originalPostContent);
 
        var htmlGenerator = new TestableHtmlGenerator(metadataProvider)
        {
            ValidationAttributes =
                {
                    {  "valid", "from validation attributes" },
                }
        };
        var viewContext = TestableHtmlGenerator.GetViewContext(model, htmlGenerator, metadataProvider);
        var tagHelper = new SelectTagHelper(htmlGenerator)
        {
            For = modelExpression,
            ViewContext = viewContext,
        };
 
        // Act
        tagHelper.Init(tagHelperContext);
        await tagHelper.ProcessAsync(tagHelperContext, output);
 
        // Assert
        Assert.Equal(TagMode.SelfClosing, output.TagMode);
        Assert.Equal(expectedAttributes, output.Attributes);
        Assert.Equal(expectedPreContent, output.PreContent.GetContent());
        Assert.Equal(expectedContent, output.Content.GetContent());
        Assert.Equal(expectedPostContent, output.PostContent.GetContent());
        Assert.Equal(expectedTagName, output.TagName);
 
        Assert.Single(
            tagHelperContext.Items,
            entry => (Type)entry.Key == typeof(SelectTagHelper));
    }
 
    [Theory]
    [MemberData(nameof(GeneratesExpectedDataSet))]
    public async Task ProcessAsync_WithItems_GeneratesExpectedOutput_DoesNotChangeSelectList(
        object model,
        Type containerType,
        Func<object> modelAccessor,
        NameAndId nameAndId,
        string expectedOptions)
    {
        // Arrange
        var originalAttributes = new TagHelperAttributeList
            {
                { "class", "form-control" },
            };
        var originalPostContent = "original content";
 
        var expectedAttributes = new TagHelperAttributeList(originalAttributes)
            {
                { "id", nameAndId.Id },
                { "name", nameAndId.Name },
                { "valid", "from validation attributes" },
            };
        var expectedPreContent = "original pre-content";
        var expectedContent = "original content";
        var expectedPostContent = originalPostContent + expectedOptions;
        var expectedTagName = "select";
 
        var metadataProvider = new TestModelMetadataProvider();
 
        var containerMetadata = metadataProvider.GetMetadataForType(containerType);
        var containerExplorer = metadataProvider.GetModelExplorerForType(containerType, model);
 
        var propertyMetadata = metadataProvider.GetMetadataForProperty(containerType, "Text");
        var modelExplorer = containerExplorer.GetExplorerForExpression(propertyMetadata, modelAccessor());
 
        var modelExpression = new ModelExpression(nameAndId.Name, modelExplorer);
 
        var tagHelperContext = new TagHelperContext(
            tagName: "select",
            allAttributes: new TagHelperAttributeList(
                Enumerable.Empty<TagHelperAttribute>()),
            items: new Dictionary<object, object>(),
            uniqueId: "test");
        var output = new TagHelperOutput(
            expectedTagName,
            originalAttributes,
            getChildContentAsync: (useCachedResult, encoder) =>
            {
                var tagHelperContent = new DefaultTagHelperContent();
                tagHelperContent.AppendHtml("Something");
                return Task.FromResult<TagHelperContent>(tagHelperContent);
            })
        {
            TagMode = TagMode.SelfClosing,
        };
        output.PreContent.AppendHtml(expectedPreContent);
        output.Content.AppendHtml(expectedContent);
        output.PostContent.AppendHtml(originalPostContent);
 
        var htmlGenerator = new TestableHtmlGenerator(metadataProvider)
        {
            ValidationAttributes =
                {
                    {  "valid", "from validation attributes" },
                }
        };
        var viewContext = TestableHtmlGenerator.GetViewContext(model, htmlGenerator, metadataProvider);
 
        var items = new SelectList(new[] { "", "outer text", "inner text", "other text" });
        var savedDisabled = items.Select(item => item.Disabled).ToList();
        var savedGroup = items.Select(item => item.Group).ToList();
        var savedSelected = items.Select(item => item.Selected).ToList();
        var savedText = items.Select(item => item.Text).ToList();
        var savedValue = items.Select(item => item.Value).ToList();
 
        var tagHelper = new SelectTagHelper(htmlGenerator)
        {
            For = modelExpression,
            Items = items,
            ViewContext = viewContext,
        };
 
        // Act
        tagHelper.Init(tagHelperContext);
        await tagHelper.ProcessAsync(tagHelperContext, output);
 
        // Assert
        Assert.Equal(TagMode.SelfClosing, output.TagMode);
        Assert.Equal(expectedAttributes, output.Attributes);
        Assert.Equal(expectedPreContent, output.PreContent.GetContent());
        Assert.Equal(expectedContent, output.Content.GetContent());
        Assert.Equal(expectedPostContent, HtmlContentUtilities.HtmlContentToString(output.PostContent));
        Assert.Equal(expectedTagName, output.TagName);
 
        Assert.Single(
            tagHelperContext.Items,
            entry => (Type)entry.Key == typeof(SelectTagHelper));
 
        Assert.Equal(savedDisabled, items.Select(item => item.Disabled));
        Assert.Equal(savedGroup, items.Select(item => item.Group));
        Assert.Equal(savedSelected, items.Select(item => item.Selected));
        Assert.Equal(savedText, items.Select(item => item.Text));
        Assert.Equal(savedValue, items.Select(item => item.Value));
    }
 
    [Fact]
    public async Task ProcessAsync_WithItems_AndNoModelExpression_GeneratesExpectedOutput()
    {
        // Arrange
        var originalAttributes = new TagHelperAttributeList
            {
                { "class", "form-control" },
            };
        var originalPostContent = "original content";
 
        var expectedAttributes = new TagHelperAttributeList(originalAttributes);
        var selectItems = new SelectList(Enumerable.Range(0, 5));
        var expectedOptions = "<option>HtmlEncode[[0]]</option>" + Environment.NewLine
            + "<option>HtmlEncode[[1]]</option>" + Environment.NewLine
            + "<option>HtmlEncode[[2]]</option>" + Environment.NewLine
            + "<option>HtmlEncode[[3]]</option>" + Environment.NewLine
            + "<option>HtmlEncode[[4]]</option>" + Environment.NewLine;
 
        var expectedPreContent = "original pre-content";
        var expectedContent = "original content";
        var expectedPostContent = originalPostContent + expectedOptions;
        var expectedTagName = "select";
 
        var tagHelperContext = new TagHelperContext(
            tagName: "select",
            allAttributes: new TagHelperAttributeList(
                Enumerable.Empty<TagHelperAttribute>()),
            items: new Dictionary<object, object>(),
            uniqueId: "test");
        var output = new TagHelperOutput(
            expectedTagName,
            originalAttributes,
            getChildContentAsync: (useCachedResult, encoder) =>
            {
                var tagHelperContent = new DefaultTagHelperContent();
                tagHelperContent.AppendHtml("Something");
                return Task.FromResult<TagHelperContent>(tagHelperContent);
            })
        {
            TagMode = TagMode.SelfClosing,
        };
        output.PreContent.AppendHtml(expectedPreContent);
        output.Content.AppendHtml(expectedContent);
        output.PostContent.AppendHtml(originalPostContent);
 
        var metadataProvider = new TestModelMetadataProvider();
        var htmlGenerator = new TestableHtmlGenerator(metadataProvider);
        var viewContext = TestableHtmlGenerator.GetViewContext(
            model: null,
            htmlGenerator: htmlGenerator,
            metadataProvider: metadataProvider);
 
        var tagHelper = new SelectTagHelper(htmlGenerator)
        {
            Items = selectItems,
            ViewContext = viewContext,
        };
 
        // Act
        tagHelper.Init(tagHelperContext);
        await tagHelper.ProcessAsync(tagHelperContext, output);
 
        // Assert
        Assert.Equal(TagMode.SelfClosing, output.TagMode);
        Assert.Equal(expectedAttributes, output.Attributes);
        Assert.Equal(expectedPreContent, output.PreContent.GetContent());
        Assert.Equal(expectedContent, output.Content.GetContent());
        Assert.Equal(expectedPostContent, HtmlContentUtilities.HtmlContentToString(output.PostContent));
        Assert.Equal(expectedTagName, output.TagName);
 
        var kvp = Assert.Single(tagHelperContext.Items);
        Assert.Equal(typeof(SelectTagHelper), kvp.Key);
        Assert.Null(kvp.Value);
    }
 
    [Theory]
    [MemberData(nameof(GeneratesExpectedDataSet))]
    public async Task ProcessAsyncInTemplate_WithItems_GeneratesExpectedOutput_DoesNotChangeSelectList(
        object model,
        Type containerType,
        Func<object> modelAccessor,
        NameAndId nameAndId,
        string expectedOptions)
    {
        // Arrange
        var originalAttributes = new TagHelperAttributeList
            {
                { "class", "form-control" },
            };
        var originalPostContent = "original content";
 
        var expectedAttributes = new TagHelperAttributeList(originalAttributes)
            {
                { "id", nameAndId.Id },
                { "name", nameAndId.Name },
                { "valid", "from validation attributes" },
            };
        var expectedPreContent = "original pre-content";
        var expectedContent = "original content";
        var expectedPostContent = originalPostContent + expectedOptions;
        var expectedTagName = "select";
 
        var metadataProvider = new TestModelMetadataProvider();
 
        var containerMetadata = metadataProvider.GetMetadataForType(containerType);
        var containerExplorer = metadataProvider.GetModelExplorerForType(containerType, model);
 
        var propertyMetadata = metadataProvider.GetMetadataForProperty(containerType, "Text");
        var modelExplorer = containerExplorer.GetExplorerForExpression(propertyMetadata, modelAccessor());
 
        var modelExpression = new ModelExpression(name: string.Empty, modelExplorer: modelExplorer);
 
        var tagHelperContext = new TagHelperContext(
            tagName: "select",
            allAttributes: new TagHelperAttributeList(
                Enumerable.Empty<TagHelperAttribute>()),
            items: new Dictionary<object, object>(),
            uniqueId: "test");
        var output = new TagHelperOutput(
            expectedTagName,
            originalAttributes,
            getChildContentAsync: (useCachedResult, encoder) =>
            {
                var tagHelperContent = new DefaultTagHelperContent();
                tagHelperContent.AppendHtml("Something");
                return Task.FromResult<TagHelperContent>(tagHelperContent);
            })
        {
            TagMode = TagMode.SelfClosing,
        };
        output.PreContent.AppendHtml(expectedPreContent);
        output.Content.AppendHtml(expectedContent);
        output.PostContent.AppendHtml(originalPostContent);
 
        var htmlGenerator = new TestableHtmlGenerator(metadataProvider)
        {
            ValidationAttributes =
                {
                    {  "valid", "from validation attributes" },
                }
        };
        var viewContext = TestableHtmlGenerator.GetViewContext(model, htmlGenerator, metadataProvider);
        viewContext.ViewData.TemplateInfo.HtmlFieldPrefix = nameAndId.Name;
 
        var items = new SelectList(new[] { "", "outer text", "inner text", "other text" });
        var savedDisabled = items.Select(item => item.Disabled).ToList();
        var savedGroup = items.Select(item => item.Group).ToList();
        var savedSelected = items.Select(item => item.Selected).ToList();
        var savedText = items.Select(item => item.Text).ToList();
        var savedValue = items.Select(item => item.Value).ToList();
        var tagHelper = new SelectTagHelper(htmlGenerator)
        {
            For = modelExpression,
            Items = items,
            ViewContext = viewContext,
        };
 
        // Act
        tagHelper.Init(tagHelperContext);
        await tagHelper.ProcessAsync(tagHelperContext, output);
 
        // Assert
        Assert.Equal(TagMode.SelfClosing, output.TagMode);
        Assert.Equal(expectedAttributes, output.Attributes);
        Assert.Equal(expectedPreContent, output.PreContent.GetContent());
        Assert.Equal(expectedContent, output.Content.GetContent());
        Assert.Equal(expectedPostContent, HtmlContentUtilities.HtmlContentToString(output.PostContent));
        Assert.Equal(expectedTagName, output.TagName);
 
        Assert.Single(
            tagHelperContext.Items,
            entry => (Type)entry.Key == typeof(SelectTagHelper));
 
        Assert.Equal(savedDisabled, items.Select(item => item.Disabled));
        Assert.Equal(savedGroup, items.Select(item => item.Group));
        Assert.Equal(savedSelected, items.Select(item => item.Selected));
        Assert.Equal(savedText, items.Select(item => item.Text));
        Assert.Equal(savedValue, items.Select(item => item.Value));
    }
 
    [Theory]
    [MemberData(nameof(ItemsAndMultipleDataSet))]
    public async Task ProcessAsync_CallsGeneratorWithExpectedValues_ItemsAndAttribute(
        IEnumerable<SelectListItem> inputItems,
        string attributeName,
        string attributeValue,
        IEnumerable<SelectListItem> expectedItems)
    {
        // Arrange
        var contextAttributes = new TagHelperAttributeList
            {
                // Provided for completeness. Select tag helper does not confirm AllAttributes set is consistent.
                { attributeName, attributeValue },
            };
        var originalAttributes = new TagHelperAttributeList
            {
                { attributeName, attributeValue },
            };
        var propertyName = "Property1";
        var expectedTagName = "select";
 
        var tagHelperContext = new TagHelperContext(
            tagName: "select",
            allAttributes: contextAttributes,
            items: new Dictionary<object, object>(),
            uniqueId: "test");
 
        var output = new TagHelperOutput(
            expectedTagName,
            originalAttributes,
            getChildContentAsync: (useCachedResult, encoder) =>
            {
                var tagHelperContent = new DefaultTagHelperContent();
                tagHelperContent.SetContent("Something");
                return Task.FromResult<TagHelperContent>(tagHelperContent);
            });
        var metadataProvider = new EmptyModelMetadataProvider();
        string model = null;
        var modelExplorer = metadataProvider.GetModelExplorerForType(typeof(string), model);
 
        var htmlGenerator = new Mock<IHtmlGenerator>(MockBehavior.Strict);
        var viewContext = TestableHtmlGenerator.GetViewContext(model, htmlGenerator.Object, metadataProvider);
 
        // Simulate a (model => model) scenario. E.g. the calling helper may appear in a low-level template.
        var modelExpression = new ModelExpression(string.Empty, modelExplorer);
        viewContext.ViewData.TemplateInfo.HtmlFieldPrefix = propertyName;
 
        var currentValues = new string[0];
        htmlGenerator
            .Setup(real => real.GetCurrentValues(
                viewContext,
                modelExplorer,
                string.Empty,   // expression
                false))         // allowMultiple
            .Returns(currentValues)
            .Verifiable();
        htmlGenerator
            .Setup(real => real.GenerateSelect(
                viewContext,
                modelExplorer,
                null,           // optionLabel
                string.Empty,   // expression
                expectedItems,
                currentValues,
                false,          // allowMultiple
                null))          // htmlAttributes
            .Returns((TagBuilder)null)
            .Verifiable();
 
        var tagHelper = new SelectTagHelper(htmlGenerator.Object)
        {
            For = modelExpression,
            Items = inputItems,
            ViewContext = viewContext,
        };
 
        // Act
        tagHelper.Init(tagHelperContext);
        await tagHelper.ProcessAsync(tagHelperContext, output);
 
        // Assert
        htmlGenerator.Verify();
 
        var keyValuePair = Assert.Single(
            tagHelperContext.Items,
            entry => (Type)entry.Key == typeof(SelectTagHelper));
        var actualCurrentValues = Assert.IsType<CurrentValues>(keyValuePair.Value);
        Assert.Same(currentValues, actualCurrentValues.Values);
    }
 
    [Theory]
    [MemberData(nameof(RealModelTypeDataSet))]
    public async Task TagHelper_CallsGeneratorWithExpectedValues_RealModelType(
        Type modelType,
        object model,
        bool allowMultiple)
    {
        // Arrange
        var contextAttributes = new TagHelperAttributeList(
                Enumerable.Empty<TagHelperAttribute>());
        var originalAttributes = new TagHelperAttributeList();
        var propertyName = "Property1";
        var tagName = "select";
 
        var tagHelperContext = new TagHelperContext(
            tagName: "select",
            allAttributes: contextAttributes,
            items: new Dictionary<object, object>(),
            uniqueId: "test");
        var output = new TagHelperOutput(
            tagName,
            originalAttributes,
            getChildContentAsync: (useCachedResult, encoder) =>
            {
                var tagHelperContent = new DefaultTagHelperContent();
                tagHelperContent.SetContent("Something");
                return Task.FromResult<TagHelperContent>(tagHelperContent);
            });
        var metadataProvider = new EmptyModelMetadataProvider();
        var modelExplorer = metadataProvider.GetModelExplorerForType(modelType, model);
        var modelExpression = new ModelExpression(propertyName, modelExplorer);
 
        var htmlGenerator = new Mock<IHtmlGenerator>(MockBehavior.Strict);
        var viewContext = TestableHtmlGenerator.GetViewContext(model, htmlGenerator.Object, metadataProvider);
        var currentValues = new string[0];
        htmlGenerator
            .Setup(real => real.GetCurrentValues(
                viewContext,
                modelExplorer,
                propertyName,   // expression
                allowMultiple))
            .Returns(currentValues)
            .Verifiable();
        htmlGenerator
            .Setup(real => real.GenerateSelect(
                viewContext,
                modelExplorer,
                null,           // optionLabel
                propertyName,   // expression
                It.IsAny<IEnumerable<SelectListItem>>(),
                currentValues,
                allowMultiple,
                null))          // htmlAttributes
            .Returns((TagBuilder)null)
            .Verifiable();
 
        var tagHelper = new SelectTagHelper(htmlGenerator.Object)
        {
            For = modelExpression,
            ViewContext = viewContext,
        };
 
        // Act
        tagHelper.Init(tagHelperContext);
        await tagHelper.ProcessAsync(tagHelperContext, output);
 
        // Assert
        htmlGenerator.Verify();
 
        var keyValuePair = Assert.Single(
            tagHelperContext.Items,
            entry => (Type)entry.Key == typeof(SelectTagHelper));
        var actualCurrentValues = Assert.IsType<CurrentValues>(keyValuePair.Value);
        Assert.Same(currentValues, actualCurrentValues.Values);
    }
 
    [Fact]
    public void Process_WithEmptyForName_Throws()
    {
        // Arrange
        var expectedMessage = "The name of an HTML field cannot be null or empty. Instead use methods " +
            "Microsoft.AspNetCore.Mvc.Rendering.IHtmlHelper.Editor or Microsoft.AspNetCore.Mvc.Rendering." +
            "IHtmlHelper`1.EditorFor with a non-empty htmlFieldName argument value.";
        var expectedTagName = "select";
 
        var metadataProvider = new EmptyModelMetadataProvider();
        var htmlGenerator = new TestableHtmlGenerator(metadataProvider);
        var model = "model-value";
        var modelExplorer = metadataProvider.GetModelExplorerForType(typeof(string), model);
        var modelExpression = new ModelExpression(name: string.Empty, modelExplorer: modelExplorer);
        var viewContext = TestableHtmlGenerator.GetViewContext(model, htmlGenerator, metadataProvider);
        var tagHelper = new SelectTagHelper(htmlGenerator)
        {
            For = modelExpression,
            ViewContext = viewContext,
        };
 
        var context = new TagHelperContext(new TagHelperAttributeList(), new Dictionary<object, object>(), "test");
        var output = new TagHelperOutput(
            expectedTagName,
            new TagHelperAttributeList(),
            (_, __) => Task.FromResult<TagHelperContent>(new DefaultTagHelperContent()));
 
        // Act & Assert
        ExceptionAssert.ThrowsArgument(
            () => tagHelper.Process(context, output),
            paramName: "expression",
            exceptionMessage: expectedMessage);
    }
 
    [Fact]
    public void Process_WithEmptyForName_DoesNotThrow_WithName()
    {
        // Arrange
        var expectedAttributeValue = "-expression-";
        var expectedTagName = "select";
 
        var metadataProvider = new EmptyModelMetadataProvider();
        var htmlGenerator = new TestableHtmlGenerator(metadataProvider);
        var model = "model-value";
        var modelExplorer = metadataProvider.GetModelExplorerForType(typeof(string), model);
        var modelExpression = new ModelExpression(name: string.Empty, modelExplorer: modelExplorer);
        var viewContext = TestableHtmlGenerator.GetViewContext(model, htmlGenerator, metadataProvider);
        var tagHelper = new SelectTagHelper(htmlGenerator)
        {
            For = modelExpression,
            Name = expectedAttributeValue,
            ViewContext = viewContext,
        };
 
        var attributes = new TagHelperAttributeList
            {
                { "name", expectedAttributeValue },
            };
 
        var context = new TagHelperContext(attributes, new Dictionary<object, object>(), "test");
        var output = new TagHelperOutput(
            expectedTagName,
            new TagHelperAttributeList(),
            (_, __) => Task.FromResult<TagHelperContent>(new DefaultTagHelperContent()));
 
        // Act
        tagHelper.Process(context, output);
 
        // Assert
        Assert.Equal(expectedTagName, output.TagName);
        Assert.False(output.IsContentModified);
 
        var attribute = Assert.Single(output.Attributes);
        Assert.Equal("name", attribute.Name);
        Assert.Equal(expectedAttributeValue, attribute.Value);
    }
 
    [Fact]
    public void Process_PassesNameThrough_EvenIfNullFor()
    {
        // Arrange
        var expectedAttributeValue = "-expression-";
        var expectedTagName = "span";
 
        var selectList = Array.Empty<SelectListItem>();
        var generator = new Mock<IHtmlGenerator>(MockBehavior.Strict);
        generator
            .Setup(gen => gen.GenerateGroupsAndOptions(/* optionLabel: */ null, selectList))
            .Returns(HtmlString.Empty)
            .Verifiable();
 
        var metadataProvider = new EmptyModelMetadataProvider();
        var viewContext = TestableHtmlGenerator.GetViewContext(
            model: null,
            htmlGenerator: generator.Object,
            metadataProvider: metadataProvider);
 
        var tagHelper = new SelectTagHelper(generator.Object)
        {
            Items = selectList,
            Name = expectedAttributeValue,
            ViewContext = viewContext,
        };
 
        var attributes = new TagHelperAttributeList
            {
                { "name", expectedAttributeValue },
            };
 
        var tagHelperContext = new TagHelperContext(attributes, new Dictionary<object, object>(), "test");
        var output = new TagHelperOutput(
            expectedTagName,
            new TagHelperAttributeList(),
            (_, __) => Task.FromResult<TagHelperContent>(new DefaultTagHelperContent()));
 
        // Act
        tagHelper.Process(tagHelperContext, output);
 
        // Assert
        generator.VerifyAll();
        Assert.Equal(expectedTagName, output.TagName);
        var attribute = Assert.Single(output.Attributes);
        Assert.Equal("name", attribute.Name);
        Assert.Equal(expectedAttributeValue, attribute.Value);
    }
 
    public class NameAndId
    {
        public NameAndId(string name, string id)
        {
            Name = name;
            Id = id;
        }
 
        public string Name { get; private set; }
 
        public string Id { get; private set; }
    }
 
    private class Model
    {
        public string Text { get; set; }
 
        public NestedModel NestedModel { get; set; }
    }
 
    private class NestedModel
    {
        public string Text { get; set; }
    }
}