diff --git a/test/Microsoft.AspNet.Mvc.TagHelpers.Test/SelectTagHelperTest.cs b/test/Microsoft.AspNet.Mvc.TagHelpers.Test/SelectTagHelperTest.cs new file mode 100644 index 0000000000..7543badd65 --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.TagHelpers.Test/SelectTagHelperTest.cs @@ -0,0 +1,520 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNet.Mvc.ModelBinding; +using Microsoft.AspNet.Mvc.Rendering; +using Microsoft.AspNet.Razor.Runtime.TagHelpers; +using Moq; +using Xunit; + +namespace Microsoft.AspNet.Mvc.TagHelpers +{ + public class SelectTagHelperTest + { + // Model (List or Model instance), container type (Model or NestModel), model accessor, + // property path / id, expected content. + public static TheoryData, 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 + { + modelWithNull, + modelWithText, + }; + var noneSelected = "" + Environment.NewLine + + "" + Environment.NewLine + + "" + Environment.NewLine + + "" + Environment.NewLine; + var innerSelected = "" + Environment.NewLine + + "" + Environment.NewLine + + "" + Environment.NewLine + + "" + Environment.NewLine; + var outerSelected = "" + Environment.NewLine + + "" + Environment.NewLine + + "" + Environment.NewLine + + "" + Environment.NewLine; + + return new TheoryData, NameAndId, string> + { + { null, typeof(Model), () => null, new NameAndId("Text", "Text"), noneSelected }, + + { 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 }, + + // Top-level indexing does not work end-to-end due to code generation issue #1345. + // TODO: Remove above comment when #1345 is fixed. + { 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 }, + + // Skip last two test cases because DefaultHtmlGenerator evaluates expression name against + // ViewData, not using ModelMetadata.Model. ViewData.Eval() handles simple property paths and some + // dictionary lookups, but not indexing into an array or list. Will file a follow-up bug on this... + ////{ 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 value, Multiple value, expected items value (passed to generator), expected allowMultiple. + // Provides cross product of Items and Multiple values. These attribute values should not interact. + public static TheoryData, string, IEnumerable, bool> + ItemsAndMultipleDataSet + { + get + { + var arrayItems = new[] { new SelectListItem() }; + var listItems = new List(); + var multiItems = new MultiSelectList(Enumerable.Range(0, 5)); + var selectItems = new SelectList(Enumerable.Range(0, 5)); + var itemsData = new[] + { + new[] { (IEnumerable)null, Enumerable.Empty() }, + new[] { arrayItems, arrayItems }, + new[] { listItems, listItems }, + new[] { multiItems, multiItems }, + new[] { selectItems, selectItems }, + }; + var mutlipleData = new[] + { + new Tuple(null, false), // allowMultiple determined by string datatype. + new Tuple("", false), // allowMultiple determined by string datatype. + new Tuple("true", true), + new Tuple("false", false), + new Tuple("multiple", true), + new Tuple("Multiple", true), + new Tuple("MULTIPLE", true), + }; + + var theoryData = + new TheoryData, string, IEnumerable, bool>(); + foreach (var items in itemsData) + { + foreach (var multiples in mutlipleData) + { + theoryData.Add(items[0], multiples.Item1, items[1], multiples.Item2); + } + } + + return theoryData; + } + } + + // Model type, model, allowMultiple expected value + public static TheoryData RealModelTypeDataSet + { + get + { + return new TheoryData + { + { typeof(object), string.Empty, false }, + { typeof(string), null, false }, + { typeof(int?), null, false }, + { typeof(int), 23, false }, + { typeof(IEnumerable), null, true }, + { typeof(IEnumerable), null, true }, + { typeof(List), null, true }, + { typeof(object), new[] { "", "", "" }, true }, + { typeof(object), new List(), true }, + }; + } + } + + [Theory] + [MemberData(nameof(GeneratesExpectedDataSet))] + public async Task ProcessAsync_GeneratesExpectedOutput( + object model, + Type containerType, + Func modelAccessor, + NameAndId nameAndId, + string ignored) + { + // Arrange + var originalAttributes = new Dictionary + { + { "class", "form-control" }, + }; + var originalContent = "original content"; + var originalTagName = "not-select"; + + var expectedAttributes = new Dictionary(originalAttributes) + { + { "id", nameAndId.Id }, + { "name", nameAndId.Name }, + { "valid", "from validation attributes" }, + }; + var expectedContent = originalContent; + var expectedTagName = "select"; + + var metadataProvider = new DataAnnotationsModelMetadataProvider(); + + // Property name is either nameof(Model.Text) or nameof(NestedModel.Text). + var metadata = metadataProvider.GetMetadataForProperty(modelAccessor, containerType, propertyName: "Text"); + var modelExpression = new ModelExpression(nameAndId.Name, metadata); + + var tagHelperContext = new TagHelperContext(new Dictionary()); + var output = new TagHelperOutput(originalTagName, originalAttributes, expectedContent) + { + SelfClosing = true, + }; + + var htmlGenerator = new TestableHtmlGenerator(metadataProvider) + { + ValidationAttributes = + { + { "valid", "from validation attributes" }, + } + }; + var viewContext = TestableHtmlGenerator.GetViewContext(model, htmlGenerator, metadataProvider); + var tagHelper = new SelectTagHelper + { + For = modelExpression, + Generator = htmlGenerator, + ViewContext = viewContext, + }; + + // Act + await tagHelper.ProcessAsync(tagHelperContext, output); + + // Assert + Assert.Equal(expectedAttributes, output.Attributes); + Assert.Equal(expectedContent, output.Content); + Assert.False(output.SelfClosing); + Assert.Equal(expectedTagName, output.TagName); + } + + [Theory] + [MemberData(nameof(GeneratesExpectedDataSet))] + public async Task ProcessAsync_GeneratesExpectedOutput_WithItems( + object model, + Type containerType, + Func modelAccessor, + NameAndId nameAndId, + string expectedOptions) + { + // Arrange + var originalAttributes = new Dictionary + { + { "class", "form-control" }, + }; + var originalContent = "original content"; + var originalTagName = "not-select"; + + var expectedAttributes = new Dictionary(originalAttributes) + { + { "id", nameAndId.Id }, + { "name", nameAndId.Name }, + { "valid", "from validation attributes" }, + }; + var expectedContent = originalContent + expectedOptions; + var expectedTagName = "select"; + + var metadataProvider = new DataAnnotationsModelMetadataProvider(); + + // Property name is either nameof(Model.Text) or nameof(NestedModel.Text). + var metadata = metadataProvider.GetMetadataForProperty(modelAccessor, containerType, propertyName: "Text"); + var modelExpression = new ModelExpression(nameAndId.Name, metadata); + + var tagHelperContext = new TagHelperContext(new Dictionary()); + var output = new TagHelperOutput(originalTagName, originalAttributes, originalContent) + { + SelfClosing = true, + }; + + var items = new SelectList(new[] { "", "outer text", "inner text", "other text" }); + var htmlGenerator = new TestableHtmlGenerator(metadataProvider) + { + ValidationAttributes = + { + { "valid", "from validation attributes" }, + } + }; + var viewContext = TestableHtmlGenerator.GetViewContext(model, htmlGenerator, metadataProvider); + var tagHelper = new SelectTagHelper + { + For = modelExpression, + Generator = htmlGenerator, + Items = items, + ViewContext = viewContext, + }; + + // Act + await tagHelper.ProcessAsync(tagHelperContext, output); + + // Assert + Assert.Equal(expectedAttributes, output.Attributes); + Assert.Equal(expectedContent, output.Content); + Assert.False(output.SelfClosing); + Assert.Equal(expectedTagName, output.TagName); + } + + [Theory] + [MemberData(nameof(ItemsAndMultipleDataSet))] + public async Task ProcessAsync_CallsGeneratorWithExpectedValues_ItemsAndMultiple( + IEnumerable inputItems, + string multiple, + IEnumerable expectedItems, + bool expectedAllowMultiple) + { + // Arrange + var contextAttributes = new Dictionary + { + // Attribute will be restored if value matches "multiple". + { "multiple", multiple }, + }; + var originalAttributes = new Dictionary(); + var content = "original content"; + var propertyName = "Property1"; + var tagName = "not-select"; + + var tagHelperContext = new TagHelperContext(contextAttributes); + var output = new TagHelperOutput(tagName, originalAttributes, content); + + // TODO: In real (model => model) scenario, ModelExpression should have name "" and + // TemplateInfo.HtmlFieldPrefix should be "Property1" but empty ModelExpression name is not currently + // supported, see #1408. + var metadataProvider = new EmptyModelMetadataProvider(); + string model = null; + var metadata = metadataProvider.GetMetadataForType(() => model, typeof(string)); + var modelExpression = new ModelExpression(propertyName, metadata); + + var htmlGenerator = new Mock(MockBehavior.Strict); + var viewContext = TestableHtmlGenerator.GetViewContext(model, htmlGenerator.Object, metadataProvider); + htmlGenerator + .Setup(real => real.GenerateSelect( + viewContext, + metadata, + null, // optionLabel + propertyName, // name + expectedItems, + expectedAllowMultiple, + null)) // htmlAttributes + .Returns((TagBuilder)null) + .Verifiable(); + + var tagHelper = new SelectTagHelper + { + For = modelExpression, + Items = inputItems, + Generator = htmlGenerator.Object, + Multiple = multiple, + ViewContext = viewContext, + }; + + // Act + await tagHelper.ProcessAsync(tagHelperContext, output); + + // Assert + htmlGenerator.Verify(); + } + + [Theory] + [MemberData(nameof(RealModelTypeDataSet))] + public async Task TagHelper_CallsGeneratorWithExpectedValues_RealModelType( + Type modelType, + object model, + bool allowMultiple) + { + // Arrange + var contextAttributes = new Dictionary(); + var originalAttributes = new Dictionary(); + var content = "original content"; + var propertyName = "Property1"; + var tagName = "not-select"; + + var tagHelperContext = new TagHelperContext(contextAttributes); + var output = new TagHelperOutput(tagName, originalAttributes, content); + + var metadataProvider = new EmptyModelMetadataProvider(); + var metadata = metadataProvider.GetMetadataForType(() => model, modelType); + var modelExpression = new ModelExpression(propertyName, metadata); + + var htmlGenerator = new Mock(MockBehavior.Strict); + var viewContext = TestableHtmlGenerator.GetViewContext(model, htmlGenerator.Object, metadataProvider); + htmlGenerator + .Setup(real => real.GenerateSelect( + viewContext, + metadata, + null, // optionLabel + propertyName, // name + It.IsAny>(), + allowMultiple, + null)) // htmlAttributes + .Returns((TagBuilder)null) + .Verifiable(); + + var tagHelper = new SelectTagHelper + { + For = modelExpression, + Generator = htmlGenerator.Object, + ViewContext = viewContext, + }; + + // Act + await tagHelper.ProcessAsync(tagHelperContext, output); + + // Assert + htmlGenerator.Verify(); + } + + [Theory] + [InlineData("multiple")] + [InlineData("mUlTiPlE")] + [InlineData("MULTIPLE")] + public async Task ProcessAsync_RestoresMultiple_IfForNotBound(string attributeName) + { + // Arrange + var contextAttributes = new Dictionary + { + { attributeName, "I'm more than one" }, + }; + var originalAttributes = new Dictionary + { + { "class", "form-control" }, + { "size", "2" }, + }; + var expectedAttributes = new Dictionary(originalAttributes); + expectedAttributes[attributeName] = (string)contextAttributes[attributeName]; + var expectedContent = "original content"; + var expectedTagName = "not-select"; + + var tagHelperContext = new TagHelperContext(contextAttributes); + var output = new TagHelperOutput(expectedTagName, originalAttributes, expectedContent) + { + SelfClosing = true, + }; + + var tagHelper = new SelectTagHelper + { + Multiple = "I'm more than one", + }; + + // Act + await tagHelper.ProcessAsync(tagHelperContext, output); + + // Assert + Assert.Equal(expectedAttributes, output.Attributes); + Assert.Equal(expectedContent, output.Content); + Assert.True(output.SelfClosing); + Assert.Equal(expectedTagName, output.TagName); + } + + [Fact] + public async Task ProcessAsync_Throws_IfForNotBoundButItemsIs() + { + // Arrange + var contextAttributes = new Dictionary(); + var originalAttributes = new Dictionary(); + var content = "original content"; + var tagName = "not-select"; + var expectedMessage = "Cannot determine body for . Acceptable values are 'false', 'true' and 'multiple'."; + + var tagHelperContext = new TagHelperContext(contextAttributes); + var output = new TagHelperOutput(tagName, originalAttributes, content); + + var metadataProvider = new EmptyModelMetadataProvider(); + string model = null; + var metadata = metadataProvider.GetMetadataForType(() => model, typeof(string)); + var modelExpression = new ModelExpression("Property1", metadata); + + var tagHelper = new SelectTagHelper + { + For = modelExpression, + Multiple = multiple, + }; + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => tagHelper.ProcessAsync(tagHelperContext, output)); + Assert.Equal(expectedMessage, exception.Message); + } + + 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; } + } + } +}