Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -113,5 +113,38 @@ public void NullTagName_Throws()
{
Assert.Throws<System.ArgumentNullException>(() => HtmlElementFactory.Create(null));
}

/// <summary>
/// Both 'kbd' and 'kdb' map to the existing <see cref="HtmlElementTextSemanticsKdb"/>.
/// </summary>
[Fact]
public void KbdTag_MapsToKdbElement()
{
var element = HtmlElementFactory.Create("kbd");

Assert.IsType<HtmlElementTextSemanticsKdb>(element);
}

/// <summary>
/// The 'keygen' tag is resolved to <see cref="HtmlElementFormKeygen"/>.
/// </summary>
[Fact]
public void KnownTag_Keygen_ReturnsCorrectType()
{
var element = HtmlElementFactory.Create("keygen");

Assert.IsType<HtmlElementFormKeygen>(element);
}

/// <summary>
/// The 'command' tag is resolved to <see cref="HtmlElementInteractiveCommand"/>.
/// </summary>
[Fact]
public void KnownTag_Command_ReturnsCorrectType()
{
var element = HtmlElementFactory.Create("command");

Assert.IsType<HtmlElementInteractiveCommand>(element);
}
}
}
156 changes: 156 additions & 0 deletions src/WebExpress.WebCore.Test/Html/Parser/UnitTestHtmlParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -340,5 +340,161 @@ public void RoundTrip_Img_ProducesEquivalentHtml()
// validation
Assert.Equal(html, restoredHtml);
}

// ------------------------------------------------------------------
// Additional tests
// ------------------------------------------------------------------

/// <summary>
/// ParseSingle returns the first node.
/// </summary>
[Fact]
public void ParseSingle_ReturnsFirstNode()
{
var node = Parser.ParseSingle("<div></div>");

Assert.IsType<HtmlElementTextContentDiv>(node);
}

/// <summary>
/// ParseSingle returns null for an empty input.
/// </summary>
[Fact]
public void ParseSingle_EmptyInput_ReturnsNull()
{
var node = Parser.ParseSingle("");

Assert.Null(node);
}

/// <summary>
/// An element with an inline style attribute retains its value.
/// </summary>
[Fact]
public void InlineStyleAttribute_IsPreserved()
{
var nodes = Parser.Parse("<div style=\"color: red;\"></div>");
var div = nodes.OfType<HtmlElementTextContentDiv>().Single();

Assert.Equal("color: red;", div.Style);
}

/// <summary>
/// A table structure with thead, tbody, and rows is correctly reconstructed.
/// </summary>
[Fact]
public void TableStructure_IsReconstructed()
{
var nodes = Parser.Parse("<table><thead><tr><th>Header</th></tr></thead><tbody><tr><td>Cell</td></tr></tbody></table>");
var table = nodes.OfType<HtmlElementTableTable>().Single();
var thead = table.Elements.OfType<HtmlElementTableThead>().Single();
var tbody = table.Elements.OfType<HtmlElementTableTbody>().Single();

Assert.NotNull(thead);
Assert.NotNull(tbody);
}

/// <summary>
/// Multiple top-level elements are all returned.
/// </summary>
[Fact]
public void MultipleRoots_AreAllReturned()
{
var nodes = Parser.Parse("<p>one</p><p>two</p>");

Assert.Equal(2, nodes.Count);
Assert.All(nodes, n => Assert.IsType<HtmlElementTextContentP>(n));
}

/// <summary>
/// A mismatched end tag is handled gracefully without throwing.
/// </summary>
[Fact]
public void MismatchedEndTag_IsHandledGracefully()
{
var nodes = Parser.Parse("<div><span>text</div>");

var div = nodes.OfType<HtmlElementTextContentDiv>().Single();
Assert.NotNull(div);
}

/// <summary>
/// Mixed text and element children are preserved in order.
/// </summary>
[Fact]
public void MixedContent_TextAndElements_ArePreserved()
{
var nodes = Parser.Parse("<p>Hello <strong>World</strong>!</p>");
var p = nodes.OfType<HtmlElementTextContentP>().Single();

Assert.Equal(3, p.Elements.Count());
}

/// <summary>
/// Roundtrip of a styled element preserves the style attribute.
/// </summary>
[Fact]
public void RoundTrip_StyleAttribute_IsPreserved()
{
// arrange
var original = new HtmlElementTextContentDiv();
original.Style = "color: red;";

// act
var html = original.ToString().Trim();
var parsed = Parser.Parse(html);

var restored = parsed.OfType<HtmlElementTextContentDiv>().Single();

// validation
Assert.Equal("color: red;", restored.Style);
}

/// <summary>
/// Roundtrip of an anchor element preserves href and text content.
/// </summary>
[Fact]
public void RoundTrip_Anchor_PreservesHrefAndText()
{
// arrange
var original = new HtmlElementTextSemanticsA(new HtmlText("click me"));
original.Href = "https://example.com";

// act
var html = original.ToString().Trim();
var parsed = Parser.Parse(html);

var a = parsed.OfType<HtmlElementTextSemanticsA>().Single();
var text = a.Elements.OfType<HtmlText>().Single();

// validation
Assert.Equal("https://example.com", a.Href);
Assert.Equal("click me", text.Value);
}

/// <summary>
/// A form with input fields is correctly reconstructed.
/// </summary>
[Fact]
public void FormWithInputs_IsReconstructed()
{
var nodes = Parser.Parse("<form action=\"/submit\"><input type=\"text\" name=\"q\"></form>");
var form = nodes.OfType<HtmlElementFormForm>().Single();
var input = form.Elements.OfType<HtmlElementFieldInput>().Single();

Assert.NotNull(input);
}

/// <summary>
/// The kbd tag (standard HTML) maps to HtmlElementTextSemanticsKdb.
/// </summary>
[Fact]
public void KbdTag_MapsToKdbElement()
{
var nodes = Parser.Parse("<kbd>Ctrl+C</kbd>");
var kbd = nodes.OfType<HtmlElementTextSemanticsKdb>().Single();

Assert.NotNull(kbd);
}
}
}
71 changes: 71 additions & 0 deletions src/WebExpress.WebCore.Test/Html/Parser/UnitTestHtmlTokenizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -246,5 +246,76 @@ public void TagNameNormalisation_IsLowerCase()
Assert.Equal("div", tokens[0].TagName);
Assert.Equal("class", tokens[0].Attributes[0].Name);
}

// ------------------------------------------------------------------
// Whitespace and edge cases
// ------------------------------------------------------------------

/// <summary>
/// Whitespace-only text between tags is preserved as a text token.
/// </summary>
[Fact]
public void WhitespaceText_IsPreservedAsTextToken()
{
var tokens = Tokenize("<div> </div>");

Assert.Equal(HtmlTokenType.StartTag, tokens[0].Type);
Assert.Equal(HtmlTokenType.Text, tokens[1].Type);
Assert.Equal(" ", tokens[1].Value);
Assert.Equal(HtmlTokenType.EndTag, tokens[2].Type);
}

/// <summary>
/// An unquoted attribute value is read until whitespace or closing bracket.
/// </summary>
[Fact]
public void UnquotedAttributeValue_IsExtracted()
{
var tokens = Tokenize("<div class=foo>");

var attr = tokens[0].Attributes.Single();
Assert.Equal("class", attr.Name);
Assert.Equal("foo", attr.Value);
}

/// <summary>
/// An inline style attribute is preserved in its entirety.
/// </summary>
[Fact]
public void InlineStyleAttribute_IsPreserved()
{
var tokens = Tokenize("<div style=\"color: red; font-size: 14px;\">");

var attr = tokens[0].Attributes.Single();
Assert.Equal("style", attr.Name);
Assert.Equal("color: red; font-size: 14px;", attr.Value);
}

/// <summary>
/// A stray less-than character is emitted as text.
/// </summary>
[Fact]
public void StrayLessThan_IsEmittedAsText()
{
var tokens = Tokenize("a < b");

Assert.Equal(HtmlTokenType.Text, tokens[0].Type);
Assert.Equal("a ", tokens[0].Value);
Assert.Equal(HtmlTokenType.Text, tokens[1].Type);
Assert.Equal("<", tokens[1].Value);
Assert.Equal(HtmlTokenType.Text, tokens[2].Type);
}

/// <summary>
/// A keygen void element without slash is emitted as self-closing.
/// </summary>
[Fact]
public void KeygenVoidElement_ReturnsSelfClosing()
{
var tokens = Tokenize("<keygen>");

Assert.Equal(HtmlTokenType.SelfClosingTag, tokens[0].Type);
Assert.Equal("keygen", tokens[0].TagName);
}
}
}
6 changes: 6 additions & 0 deletions src/WebExpress.WebCore/WebHtml/Parser/HtmlElementFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ public class HtmlElementFactory
["dfn"] = () => new HtmlElementTextSemanticsDfn(),
["em"] = () => new HtmlElementTextSemanticsEm(),
["i"] = () => new HtmlElementTextSemanticsI(),
// The standard HTML element is <kbd>, but the existing class uses "kdb" as
// its element name. Both spellings are mapped so that the parser handles
// real-world HTML (<kbd>) as well as the project's own renderer output (<kdb>).
["kbd"] = () => new HtmlElementTextSemanticsKdb(),
["kdb"] = () => new HtmlElementTextSemanticsKdb(),
["mark"] = () => new HtmlElementTextSemanticsMark(),
["q"] = () => new HtmlElementTextSemanticsQ(),
Expand Down Expand Up @@ -138,6 +142,7 @@ public class HtmlElementFactory
["datalist"] = () => new HtmlElementFormDatalist(),
["fieldset"] = () => new HtmlElementFormFieldset(),
["form"] = () => new HtmlElementFormForm(),
["keygen"] = () => new HtmlElementFormKeygen(),
["meter"] = () => new HtmlElementFormMeter(),
["optgroup"] = () => new HtmlElementFormOptgroup(),
["option"] = () => new HtmlElementFormOption(),
Expand All @@ -146,6 +151,7 @@ public class HtmlElementFactory
["textarea"] = () => new HtmlElementFormTextarea(),

// Interactive
["command"] = () => new HtmlElementInteractiveCommand(),
["details"] = () => new HtmlElementInteractiveDetails(),
["menu"] = () => new HtmlElementInteractiveMenu(),
["summary"] = () => new HtmlElementInteractiveSummary(),
Expand Down
2 changes: 1 addition & 1 deletion src/WebExpress.WebCore/WebHtml/Parser/HtmlTokenizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public class HtmlTokenizer
new(StringComparer.OrdinalIgnoreCase)
{
"area", "base", "br", "col", "embed", "hr", "img", "input",
"link", "meta", "param", "source", "track", "wbr"
"keygen", "link", "meta", "param", "source", "track", "wbr"
};

/// <summary>
Expand Down