Correct examination of `IHtmlHelper.Label()` return value

- #5317
- previously worked only because `TagBuilder` cannot be empty and `HtmlString` overrides `ToString()`
 - `TagBuilder.ToString()` (now the type's `FullName`) is also never empty
- copy `NullHtmlEncoder` from Razor and give it a better name (`PassThroughHtmlEncoder`)
 - not available in this project and (from its namespace) not intended for general use
This commit is contained in:
Doug Bunting 2016-09-25 00:03:45 -07:00
parent 27e4822a7b
commit f222fa4349
3 changed files with 294 additions and 17 deletions

View File

@ -6,6 +6,9 @@ using System.Collections;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Text;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.Rendering;
@ -272,12 +275,16 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal
if (!propertyMetadata.HideSurroundingHtml)
{
var label = htmlHelper.Label(propertyMetadata.PropertyName, labelText: null, htmlAttributes: null);
if (!string.IsNullOrEmpty(label.ToString()))
using (var writer = new HasContentTextWriter())
{
var labelTag = new TagBuilder("div");
labelTag.AddCssClass("editor-label");
labelTag.InnerHtml.SetHtmlContent(label);
content.AppendLine(labelTag);
label.WriteTo(writer, PassThroughHtmlEncoder.Default);
if (writer.HasContent)
{
var labelTag = new TagBuilder("div");
labelTag.AddCssClass("editor-label");
labelTag.InnerHtml.SetHtmlContent(label);
content.AppendLine(labelTag);
}
}
var valueDivTag = new TagBuilder("div");
@ -433,5 +440,111 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal
format: null,
htmlAttributes: htmlAttributes);
}
private class HasContentTextWriter : TextWriter
{
public HasContentTextWriter()
{
}
public bool HasContent { get; private set; }
public override Encoding Encoding => Null.Encoding;
public override void Write(char value)
{
HasContent = true;
}
public override void Write(char[] buffer, int index, int count)
{
if (count > 0)
{
HasContent = true;
}
}
public override void Write(string value)
{
if (!string.IsNullOrEmpty(value))
{
HasContent = true;
}
}
}
// An HTML encoder which passes through all input data. Does no encoding.
// Copied from Microsoft.AspNetCore.Razor.TagHelpers.NullHtmlEncoder.
private class PassThroughHtmlEncoder : HtmlEncoder
{
private PassThroughHtmlEncoder()
{
}
public static new PassThroughHtmlEncoder Default { get; } = new PassThroughHtmlEncoder();
public override int MaxOutputCharactersPerInputCharacter => 1;
public override string Encode(string value)
{
return value;
}
public override void Encode(TextWriter output, char[] value, int startIndex, int characterCount)
{
if (output == null)
{
throw new ArgumentNullException(nameof(output));
}
if (characterCount == 0)
{
return;
}
output.Write(value, startIndex, characterCount);
}
public override void Encode(TextWriter output, string value, int startIndex, int characterCount)
{
if (output == null)
{
throw new ArgumentNullException(nameof(output));
}
if (value == null)
{
throw new ArgumentNullException(nameof(value));
}
if (characterCount == 0)
{
return;
}
output.Write(value.Substring(startIndex, characterCount));
}
public override unsafe int FindFirstCharacterToEncode(char* text, int textLength)
{
return -1;
}
public override unsafe bool TryEncodeUnicodeScalar(
int unicodeScalar,
char* buffer,
int bufferLength,
out int numberOfCharactersWritten)
{
numberOfCharactersWritten = 0;
return false;
}
public override bool WillEncode(int unicodeScalar)
{
return false;
}
}
}
}

View File

@ -12,6 +12,7 @@
]
},
"buildOptions": {
"allowUnsafe": true,
"warningsAsErrors": true,
"keyFile": "../../tools/Key.snk",
"nowarn": [

View File

@ -5,6 +5,7 @@ using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Encodings.Web;
@ -86,22 +87,108 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal
}
}
// label's IHtmlContent -> expected label text
public static TheoryData<IHtmlContent, string> ObjectTemplate_ChecksWriteTo_NotToStringData
{
get
{
// Similar to HtmlString.Empty today.
var noopContentWithEmptyToString = new Mock<IHtmlContent>(MockBehavior.Strict);
noopContentWithEmptyToString
.Setup(c => c.ToString())
.Returns(string.Empty);
noopContentWithEmptyToString.Setup(c => c.WriteTo(It.IsAny<TextWriter>(), It.IsAny<HtmlEncoder>()));
// Similar to an empty StringHtmlContent today.
var noopContentWithNonEmptyToString = new Mock<IHtmlContent>(MockBehavior.Strict);
noopContentWithNonEmptyToString
.Setup(c => c.ToString())
.Returns(typeof(StringHtmlContent).FullName);
noopContentWithNonEmptyToString.Setup(c => c.WriteTo(It.IsAny<TextWriter>(), It.IsAny<HtmlEncoder>()));
// Makes noop calls on the TextWriter.
var busyNoopContentWithNonEmptyToString = new Mock<IHtmlContent>(MockBehavior.Strict);
busyNoopContentWithNonEmptyToString
.Setup(c => c.ToString())
.Returns(typeof(StringHtmlContent).FullName);
busyNoopContentWithNonEmptyToString
.Setup(c => c.WriteTo(It.IsAny<TextWriter>(), It.IsAny<HtmlEncoder>()))
.Callback<TextWriter, HtmlEncoder>((writer, encoder) =>
{
writer.Write(string.Empty);
writer.Write(new char[0]);
writer.Write((char[])null);
writer.Write((object)null);
writer.Write((string)null);
writer.Write(format: "{0}", arg0: null);
writer.Write(new char[] { 'a', 'b', 'c' }, index: 1, count: 0);
});
// Unrealistic but covers all the bases.
var writingContentWithEmptyToString = new Mock<IHtmlContent>(MockBehavior.Strict);
writingContentWithEmptyToString
.Setup(c => c.ToString())
.Returns(string.Empty);
writingContentWithEmptyToString
.Setup(c => c.WriteTo(It.IsAny<TextWriter>(), It.IsAny<HtmlEncoder>()))
.Callback<TextWriter, HtmlEncoder>((writer, encoder) => writer.Write("Some string"));
// Similar to TagBuilder today.
var writingContentWithNonEmptyToString = new Mock<IHtmlContent>(MockBehavior.Strict);
writingContentWithNonEmptyToString
.Setup(c => c.ToString())
.Returns(typeof(TagBuilder).FullName);
writingContentWithNonEmptyToString
.Setup(c => c.WriteTo(It.IsAny<TextWriter>(), It.IsAny<HtmlEncoder>()))
.Callback<TextWriter, HtmlEncoder>((writer, encoder) => writer.Write("Some string"));
// label's IHtmlContent -> expected label text
return new TheoryData<IHtmlContent, string>
{
// Types HtmlHelper actually uses.
{ HtmlString.Empty, string.Empty },
{
new TagBuilder("label"),
"<div class=\"HtmlEncode[[editor-label]]\"><label></label></div>" + Environment.NewLine
},
// Another IHtmlContent implementation that does not override ToString().
{ new StringHtmlContent(string.Empty), string.Empty },
// Mocks
{ noopContentWithEmptyToString.Object, string.Empty },
{ noopContentWithNonEmptyToString.Object, string.Empty },
{ busyNoopContentWithNonEmptyToString.Object, string.Empty },
{
writingContentWithEmptyToString.Object,
"<div class=\"HtmlEncode[[editor-label]]\">Some string</div>" + Environment.NewLine
},
{
writingContentWithNonEmptyToString.Object,
"<div class=\"HtmlEncode[[editor-label]]\">Some string</div>" + Environment.NewLine
},
};
}
}
[Fact]
public void ObjectTemplateEditsSimplePropertiesOnObjectByDefault()
{
var expected =
"<div class=\"HtmlEncode[[editor-label]]\"><label for=\"HtmlEncode[[Property1]]\">HtmlEncode[[Property1]]</label></div>" + Environment.NewLine
+ "<div class=\"HtmlEncode[[editor-field]]\">Model = p1, ModelType = System.String, PropertyName = Property1," +
" SimpleDisplayText = p1 " +
"<span class=\"HtmlEncode[[field-validation-valid]]\" data-valmsg-for=\"HtmlEncode[[Property1]]\" data-valmsg-replace=\"HtmlEncode[[true]]\">" +
"</span></div>" + Environment.NewLine
+ "<div class=\"HtmlEncode[[editor-label]]\"><label for=\"HtmlEncode[[Property2]]\">HtmlEncode[[Prop2]]</label></div>" + Environment.NewLine
+ "<div class=\"HtmlEncode[[editor-field]]\">Model = (null), ModelType = System.String, PropertyName = Property2," +
" SimpleDisplayText = (null) " +
"<span class=\"HtmlEncode[[field-validation-valid]]\" data-valmsg-for=\"HtmlEncode[[Property2]]\" data-valmsg-replace=\"HtmlEncode[[true]]\">" +
"</span></div>" + Environment.NewLine;
// Arrange
var expected =
"<div class=\"HtmlEncode[[editor-label]]\"><label for=\"HtmlEncode[[Property1]]\">HtmlEncode[[Property1]]</label></div>" +
Environment.NewLine +
"<div class=\"HtmlEncode[[editor-field]]\">Model = p1, ModelType = System.String, PropertyName = Property1, SimpleDisplayText = p1 " +
"<span class=\"HtmlEncode[[field-validation-valid]]\" data-valmsg-for=\"HtmlEncode[[Property1]]\" data-valmsg-replace=\"HtmlEncode[[true]]\">" +
"</span></div>" +
Environment.NewLine +
"<div class=\"HtmlEncode[[editor-label]]\"><label for=\"HtmlEncode[[Property2]]\">HtmlEncode[[Prop2]]</label></div>" +
Environment.NewLine +
"<div class=\"HtmlEncode[[editor-field]]\">Model = (null), ModelType = System.String, PropertyName = Property2, SimpleDisplayText = (null) " +
"<span class=\"HtmlEncode[[field-validation-valid]]\" data-valmsg-for=\"HtmlEncode[[Property2]]\" data-valmsg-replace=\"HtmlEncode[[true]]\">" +
"</span></div>" +
Environment.NewLine;
var model = new DefaultTemplatesUtilities.ObjectTemplateModel { Property1 = "p1", Property2 = null };
var html = DefaultTemplatesUtilities.GetHtmlHelper(model);
@ -112,6 +199,82 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal
Assert.Equal(expected, HtmlContentUtilities.HtmlContentToString(result));
}
// Expect almost the same HTML as in ObjectTemplateEditsSimplePropertiesOnObjectByDefault(). Only difference is
// the <div class="editor-label">...</div> is not present for Property1.
[Fact]
public void ObjectTemplateSkipsLabel_IfDisplayNameIsEmpty()
{
// Arrange
var expected =
"<div class=\"HtmlEncode[[editor-field]]\">Model = p1, ModelType = System.String, PropertyName = Property1, SimpleDisplayText = p1 " +
"<span class=\"HtmlEncode[[field-validation-valid]]\" data-valmsg-for=\"HtmlEncode[[Property1]]\" data-valmsg-replace=\"HtmlEncode[[true]]\">" +
"</span></div>" +
Environment.NewLine +
"<div class=\"HtmlEncode[[editor-label]]\"><label for=\"HtmlEncode[[Property2]]\">HtmlEncode[[Prop2]]</label></div>" +
Environment.NewLine +
"<div class=\"HtmlEncode[[editor-field]]\">Model = (null), ModelType = System.String, PropertyName = Property2, SimpleDisplayText = (null) " +
"<span class=\"HtmlEncode[[field-validation-valid]]\" data-valmsg-for=\"HtmlEncode[[Property2]]\" data-valmsg-replace=\"HtmlEncode[[true]]\">" +
"</span></div>" +
Environment.NewLine;
var provider = new TestModelMetadataProvider();
provider
.ForProperty<DefaultTemplatesUtilities.ObjectTemplateModel>(
nameof(DefaultTemplatesUtilities.ObjectTemplateModel.Property1))
.DisplayDetails(dd => dd.DisplayName = () => string.Empty);
var model = new DefaultTemplatesUtilities.ObjectTemplateModel { Property1 = "p1", Property2 = null };
var html = DefaultTemplatesUtilities.GetHtmlHelper(model, provider);
// Act
var result = DefaultEditorTemplates.ObjectTemplate(html);
// Assert
Assert.Equal(expected, HtmlContentUtilities.HtmlContentToString(result));
}
[Theory]
[MemberData(nameof(ObjectTemplate_ChecksWriteTo_NotToStringData))]
public void ObjectTemplate_ChecksWriteTo_NotToString(IHtmlContent labelContent, string expectedLabel)
{
// Arrange
var expected =
expectedLabel +
"<div class=\"HtmlEncode[[editor-field]]\">Model = (null), ModelType = System.String, PropertyName = Property1, SimpleDisplayText = (null) " +
"</div>" +
Environment.NewLine +
expectedLabel +
"<div class=\"HtmlEncode[[editor-field]]\">Model = (null), ModelType = System.String, PropertyName = Property2, SimpleDisplayText = (null) " +
"</div>" +
Environment.NewLine;
var helperToCopy = DefaultTemplatesUtilities.GetHtmlHelper();
var helperMock = new Mock<IHtmlHelper>(MockBehavior.Strict);
helperMock.SetupGet(h => h.ViewContext).Returns(helperToCopy.ViewContext);
helperMock.SetupGet(h => h.ViewData).Returns(helperToCopy.ViewData);
helperMock
.Setup(h => h.Label(
It.Is<string>(s => string.Equals("Property1", s, StringComparison.Ordinal) ||
string.Equals("Property2", s, StringComparison.Ordinal)),
null, // labelText
null)) // htmlAttributes
.Returns(labelContent);
helperMock
.Setup(h => h.ValidationMessage(
It.Is<string>(s => string.Equals("Property1", s, StringComparison.Ordinal) ||
string.Equals("Property2", s, StringComparison.Ordinal)),
null, // message
null, // htmlAttributes
null)) // tag
.Returns(HtmlString.Empty);
// Act
var result = DefaultEditorTemplates.ObjectTemplate(helperMock.Object);
// Assert
Assert.Equal(expected, HtmlContentUtilities.HtmlContentToString(result));
}
[Fact]
public void ObjectTemplateDisplaysNullDisplayTextWithNullModelAndTemplateDepthGreaterThanOne()
{