diff --git a/src/WebExpress.WebCore.Test/Html/Parser/UnitTestHtmlElementFactory.cs b/src/WebExpress.WebCore.Test/Html/Parser/UnitTestHtmlElementFactory.cs index fe218ef..b0b162f 100644 --- a/src/WebExpress.WebCore.Test/Html/Parser/UnitTestHtmlElementFactory.cs +++ b/src/WebExpress.WebCore.Test/Html/Parser/UnitTestHtmlElementFactory.cs @@ -113,5 +113,38 @@ public void NullTagName_Throws() { Assert.Throws(() => HtmlElementFactory.Create(null)); } + + /// + /// Both 'kbd' and 'kdb' map to the existing . + /// + [Fact] + public void KbdTag_MapsToKdbElement() + { + var element = HtmlElementFactory.Create("kbd"); + + Assert.IsType(element); + } + + /// + /// The 'keygen' tag is resolved to . + /// + [Fact] + public void KnownTag_Keygen_ReturnsCorrectType() + { + var element = HtmlElementFactory.Create("keygen"); + + Assert.IsType(element); + } + + /// + /// The 'command' tag is resolved to . + /// + [Fact] + public void KnownTag_Command_ReturnsCorrectType() + { + var element = HtmlElementFactory.Create("command"); + + Assert.IsType(element); + } } } diff --git a/src/WebExpress.WebCore.Test/Html/Parser/UnitTestHtmlParser.cs b/src/WebExpress.WebCore.Test/Html/Parser/UnitTestHtmlParser.cs index 6e52df8..5738752 100644 --- a/src/WebExpress.WebCore.Test/Html/Parser/UnitTestHtmlParser.cs +++ b/src/WebExpress.WebCore.Test/Html/Parser/UnitTestHtmlParser.cs @@ -340,5 +340,161 @@ public void RoundTrip_Img_ProducesEquivalentHtml() // validation Assert.Equal(html, restoredHtml); } + + // ------------------------------------------------------------------ + // Additional tests + // ------------------------------------------------------------------ + + /// + /// ParseSingle returns the first node. + /// + [Fact] + public void ParseSingle_ReturnsFirstNode() + { + var node = Parser.ParseSingle("
"); + + Assert.IsType(node); + } + + /// + /// ParseSingle returns null for an empty input. + /// + [Fact] + public void ParseSingle_EmptyInput_ReturnsNull() + { + var node = Parser.ParseSingle(""); + + Assert.Null(node); + } + + /// + /// An element with an inline style attribute retains its value. + /// + [Fact] + public void InlineStyleAttribute_IsPreserved() + { + var nodes = Parser.Parse("
"); + var div = nodes.OfType().Single(); + + Assert.Equal("color: red;", div.Style); + } + + /// + /// A table structure with thead, tbody, and rows is correctly reconstructed. + /// + [Fact] + public void TableStructure_IsReconstructed() + { + var nodes = Parser.Parse("
Header
Cell
"); + var table = nodes.OfType().Single(); + var thead = table.Elements.OfType().Single(); + var tbody = table.Elements.OfType().Single(); + + Assert.NotNull(thead); + Assert.NotNull(tbody); + } + + /// + /// Multiple top-level elements are all returned. + /// + [Fact] + public void MultipleRoots_AreAllReturned() + { + var nodes = Parser.Parse("

one

two

"); + + Assert.Equal(2, nodes.Count); + Assert.All(nodes, n => Assert.IsType(n)); + } + + /// + /// A mismatched end tag is handled gracefully without throwing. + /// + [Fact] + public void MismatchedEndTag_IsHandledGracefully() + { + var nodes = Parser.Parse("
text
"); + + var div = nodes.OfType().Single(); + Assert.NotNull(div); + } + + /// + /// Mixed text and element children are preserved in order. + /// + [Fact] + public void MixedContent_TextAndElements_ArePreserved() + { + var nodes = Parser.Parse("

Hello World!

"); + var p = nodes.OfType().Single(); + + Assert.Equal(3, p.Elements.Count()); + } + + /// + /// Roundtrip of a styled element preserves the style attribute. + /// + [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().Single(); + + // validation + Assert.Equal("color: red;", restored.Style); + } + + /// + /// Roundtrip of an anchor element preserves href and text content. + /// + [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().Single(); + var text = a.Elements.OfType().Single(); + + // validation + Assert.Equal("https://example.com", a.Href); + Assert.Equal("click me", text.Value); + } + + /// + /// A form with input fields is correctly reconstructed. + /// + [Fact] + public void FormWithInputs_IsReconstructed() + { + var nodes = Parser.Parse("
"); + var form = nodes.OfType().Single(); + var input = form.Elements.OfType().Single(); + + Assert.NotNull(input); + } + + /// + /// The kbd tag (standard HTML) maps to HtmlElementTextSemanticsKdb. + /// + [Fact] + public void KbdTag_MapsToKdbElement() + { + var nodes = Parser.Parse("Ctrl+C"); + var kbd = nodes.OfType().Single(); + + Assert.NotNull(kbd); + } } } diff --git a/src/WebExpress.WebCore.Test/Html/Parser/UnitTestHtmlTokenizer.cs b/src/WebExpress.WebCore.Test/Html/Parser/UnitTestHtmlTokenizer.cs index fc7261b..3dda876 100644 --- a/src/WebExpress.WebCore.Test/Html/Parser/UnitTestHtmlTokenizer.cs +++ b/src/WebExpress.WebCore.Test/Html/Parser/UnitTestHtmlTokenizer.cs @@ -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 + // ------------------------------------------------------------------ + + /// + /// Whitespace-only text between tags is preserved as a text token. + /// + [Fact] + public void WhitespaceText_IsPreservedAsTextToken() + { + var tokens = Tokenize("
"); + + 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); + } + + /// + /// An unquoted attribute value is read until whitespace or closing bracket. + /// + [Fact] + public void UnquotedAttributeValue_IsExtracted() + { + var tokens = Tokenize("
"); + + var attr = tokens[0].Attributes.Single(); + Assert.Equal("class", attr.Name); + Assert.Equal("foo", attr.Value); + } + + /// + /// An inline style attribute is preserved in its entirety. + /// + [Fact] + public void InlineStyleAttribute_IsPreserved() + { + var tokens = Tokenize("
"); + + var attr = tokens[0].Attributes.Single(); + Assert.Equal("style", attr.Name); + Assert.Equal("color: red; font-size: 14px;", attr.Value); + } + + /// + /// A stray less-than character is emitted as text. + /// + [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); + } + + /// + /// A keygen void element without slash is emitted as self-closing. + /// + [Fact] + public void KeygenVoidElement_ReturnsSelfClosing() + { + var tokens = Tokenize(""); + + Assert.Equal(HtmlTokenType.SelfClosingTag, tokens[0].Type); + Assert.Equal("keygen", tokens[0].TagName); + } } } diff --git a/src/WebExpress.WebCore/WebHtml/Parser/HtmlElementFactory.cs b/src/WebExpress.WebCore/WebHtml/Parser/HtmlElementFactory.cs index 96c5bc5..2c5251b 100644 --- a/src/WebExpress.WebCore/WebHtml/Parser/HtmlElementFactory.cs +++ b/src/WebExpress.WebCore/WebHtml/Parser/HtmlElementFactory.cs @@ -77,6 +77,10 @@ public class HtmlElementFactory ["dfn"] = () => new HtmlElementTextSemanticsDfn(), ["em"] = () => new HtmlElementTextSemanticsEm(), ["i"] = () => new HtmlElementTextSemanticsI(), + // The standard HTML element is , but the existing class uses "kdb" as + // its element name. Both spellings are mapped so that the parser handles + // real-world HTML () as well as the project's own renderer output (). + ["kbd"] = () => new HtmlElementTextSemanticsKdb(), ["kdb"] = () => new HtmlElementTextSemanticsKdb(), ["mark"] = () => new HtmlElementTextSemanticsMark(), ["q"] = () => new HtmlElementTextSemanticsQ(), @@ -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(), @@ -146,6 +151,7 @@ public class HtmlElementFactory ["textarea"] = () => new HtmlElementFormTextarea(), // Interactive + ["command"] = () => new HtmlElementInteractiveCommand(), ["details"] = () => new HtmlElementInteractiveDetails(), ["menu"] = () => new HtmlElementInteractiveMenu(), ["summary"] = () => new HtmlElementInteractiveSummary(), diff --git a/src/WebExpress.WebCore/WebHtml/Parser/HtmlTokenizer.cs b/src/WebExpress.WebCore/WebHtml/Parser/HtmlTokenizer.cs index d835cde..b96e085 100644 --- a/src/WebExpress.WebCore/WebHtml/Parser/HtmlTokenizer.cs +++ b/src/WebExpress.WebCore/WebHtml/Parser/HtmlTokenizer.cs @@ -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" }; ///