using AngleSharp.Dom; using AngleSharp.Html.Parser; using AngleSharp.Html.Dom; namespace XFESystem.Browser.Tests; public sealed class CssLayoutTests { private static IDocument Page(string body, string css = "") => new HtmlParser().ParseDocument( "" + body + ""); private static BrowserDisplayList Layout(IDocument page, double width = 800, double height = 600) => new BrowserLayoutEngine().Layout(page, new(width, height), textMetrics: new ExactMetrics()); private static IElement Node(IDocument page, string id = "A") => page.GetElementById(id)!; private static BrowserLayoutBox Box(BrowserDisplayList list, INode node) => list.Boxes.Single(box => ReferenceEquals(box.Node, node)); private static void Rect(BrowserRect actual, double x, double y, double width, double height) { Assert.Equal(x, actual.X, 5); Assert.Equal(y, actual.Y, 5); Assert.Equal(width, actual.Width, 5); Assert.Equal(height, actual.Height, 5); } private sealed class ExactMetrics : IBrowserTextMetrics { public double Measure(string text, BrowserFont font) => text.Sum(character => character == 'W' ? 1 : character == 'i' ? .25 : .5) * font.Size; } [Fact] public void BoxModelUsesContentPaddingBorderAndMargin() { var page = Page("
", "#A{width:100px;height:50px;margin:10px 20px;padding:4px 6px;border:2px solid blue}#B{height:10px}"); var result = Layout(page); var box = Box(result, Node(page)); Rect(box.ContentBox, 28, 16, 100, 50); Rect(box.BorderBox, 20, 10, 116, 62); Rect(box.MarginBox, 0, 0, 156, 82); Rect(result.GetBoundingClientRect(Node(page, "B")), 0, 82, 800, 10); Assert.Equal(4, result.Commands.Count(command => command.NodeId == box.NodeId && command.Kind == BrowserDrawKind.Border)); } [Fact] public void BorderBoxAndMinMaxConstrainActualContent() { var page = Page("
", "#A{box-sizing:border-box;width:200px;min-width:230px;max-width:250px;height:60px;padding:10px;border:2px solid red}"); var box = Box(Layout(page), Node(page)); Rect(box.BorderBox, 0, 0, 230, 60); Rect(box.ContentBox, 12, 12, 206, 36); } [Fact] public void AutoMarginsCenterDeclaredWidth() { var page = Page("
", "#A{width:100px;height:20px;margin:0 auto}"); Rect(Layout(page).GetBoundingClientRect(Node(page)), 350, 0, 100, 20); } [Fact] public void SelectorSpecificityImportantAndInlineCascade() { var page = Page("
", "div{color:black} .card{color:red} main [data-kind=code]{color:green} #A{color:blue!important;width:100px} #A.card{color:purple}"); var style = Layout(page).GetComputedStyle(Node(page))!; Assert.Equal("rgb(0, 0, 255)", style.GetPropertyValue("color")); Assert.Equal("17px", style.GetPropertyValue("width")); } [Fact] public void InlineImportantWinsAuthorImportantAndLaterEqualSpecificityWins() { var page = Page("
", ".a{color:red!important;height:10px}.a{height:20px}#A{color:blue!important}"); var result = Layout(page); Assert.Equal("rgb(255, 255, 0)", result.GetComputedStyle(Node(page))!.GetPropertyValue("color")); Assert.Equal(20, result.GetBoundingClientRect(Node(page, "B")).Height); } [Fact] public void StylesAndLinkedSheetsRespectActualDomOrder() { var page = new HtmlParser().ParseDocument("
"); var result = new BrowserLayoutEngine().Layout(page, new(800, 600), [new("#A{height:20px;color:blue}", Node(page, "L"))]); Assert.Equal(30, result.GetBoundingClientRect(Node(page)).Height); Assert.Equal("rgb(0, 0, 255)", result.GetComputedStyle(Node(page))!.GetPropertyValue("color")); } [Fact] public void InheritedFontsAndVariablesResolveWithoutRepeatedEmScaling() { var page = Page("
Wi
", "#A{font-size:2em;color:#4086B8;--size:100px}#B{width:var(--size);display:block}#C{display:block;height:var(--missing, 7px)}"); var result = Layout(page); var style = result.GetComputedStyle(Node(page, "C"))!; Assert.Equal(32, style.Font.Size); Assert.Equal(700, style.Font.Weight); Assert.Equal(7, result.GetBoundingClientRect(Node(page, "C")).Height); Assert.Equal("rgb(64, 134, 184)", style.GetPropertyValue("color")); Assert.Equal(100, result.GetBoundingClientRect(Node(page, "B")).Width); } [Fact] public void PercentHeightsAndAbsoluteInsetsUsePositionedPaddingBox() { var page = Page("
", "html,body{height:100%}#A{position:relative;width:50%;height:50%;padding:10px;border:2px solid blue}#B{position:absolute;left:5px;right:15px;top:28px;bottom:0}"); var result = Layout(page); Rect(Box(result, Node(page)).ContentBox, 12, 12, 400, 300); Rect(result.GetBoundingClientRect(Node(page, "B")), 7, 30, 400, 292); } [Fact] public void RelativeOffsetsDoNotDisplaceFollowingFlow() { var page = Page("
", "#A{position:relative;left:12px;top:9px;height:20px}#B{height:10px}"); var result = Layout(page); Rect(result.GetBoundingClientRect(Node(page)), 12, 9, 800, 20); Rect(result.GetBoundingClientRect(Node(page, "B")), 0, 20, 800, 10); } [Fact] public void AbsoluteRightBottomAndFixedRectUseCorrectCoordinateSpace() { var page = Page("
", "#A{position:absolute;right:10px;bottom:15px;width:50px;height:20px}#B{position:fixed;left:5px;top:7px;width:9px;height:11px}"); var result = new BrowserLayoutEngine().Layout(page, new(800, 600, 0, 100), textMetrics: new ExactMetrics()); Rect(result.GetBoundingClientRect(Node(page)), 740, 465, 50, 20); Rect(result.GetBoundingClientRect(Node(page, "B")), 5, 7, 9, 11); } [Fact] public void DisplayNoneRetainsComputedStyleButNoRectOrCommands() { var page = Page("
Hidden
Seen
", "#A{display:none;height:200px}"); var result = Layout(page); Assert.Equal(default, result.GetBoundingClientRect(Node(page))); Assert.Equal("none", result.GetComputedStyle(Node(page))!.Display); Assert.DoesNotContain(result.Commands, command => command.Node == Node(page) || command.Text == "Hidden"); Assert.Equal(0, result.GetBoundingClientRect(Node(page, "B")).Y); } [Fact] public void VisibilityHiddenPreservesSpaceAndVisibleChildMayOverride() { var page = Page("
HiddenVisible
", "#A{visibility:hidden;height:30px}#B{visibility:visible}#C{height:5px}"); var result = Layout(page); Assert.DoesNotContain(result.Commands, command => command.Text == "Hidden"); Assert.Contains(result.Commands, command => command.Text == "Visible"); Assert.Equal(30, result.GetBoundingClientRect(Node(page, "C")).Y); } [Fact] public void OverflowProducesBalancedClipAndRestrictsHitTesting() { var page = Page("
", "#A{width:100px;height:50px;overflow:hidden}#B{width:200px;height:100px;background:red}"); var result = Layout(page); Assert.Equal(result.Commands.Count(command => command.Kind == BrowserDrawKind.PushClip), result.Commands.Count(command => command.Kind == BrowserDrawKind.PopClip)); Assert.Same(Node(page, "B"), result.HitTest(20, 20)!.Element); Assert.NotSame(Node(page, "B"), result.HitTest(150, 20)?.Element); } [Fact] public void NestedOpacityAppliedOnceToTextAndColorsPreserveAlpha() { var page = Page("
Alpha
", "#A{opacity:.5}#B{opacity:.5;color:rgba(1,2,3,.5);background:#4086B880}"); var result = Layout(page); var text = Assert.Single(result.Commands, command => command.Text == "Alpha"); Assert.Equal(.125, text.Opacity, 6); Assert.Equal(0x010203u, text.Color); var fill = Assert.Single(result.Commands, command => command.Node == Node(page, "B") && command.Kind == BrowserDrawKind.Fill); Assert.Equal(.25 * 128 / 255, fill.Opacity, 6); } [Fact] public void InlineWordsWrapUsingInjectedRealMetricsAndPreserveNodeIdentity() { var page = Page("
WW ii WW
", "#A{width:60px;font-size:20px;line-height:1}"); var result = Layout(page); var words = result.Commands.Where(command => command.Kind == BrowserDrawKind.Text && command.Text != " ").ToArray(); Assert.Equal(3, words.Length); Assert.Equal(40, words[0].Bounds.Width); Assert.Equal(10, words[1].Bounds.Width); Assert.Equal(20, words[2].Bounds.Y); Assert.Equal(2, result.GetClientRects(Node(page, "B")).Count); Assert.All(words, command => Assert.Equal(words[0].NodeId, command.NodeId)); Assert.Equal("60px", result.GetComputedStyle(Node(page))!.GetPropertyValue("width")); } [Fact] public void PreKeepsNewlinesSpacesAndTabs() { var page = Page("
A\tB\n  C
", "#A{font-size:10px;line-height:20px}"); var text = Layout(page).Commands.Where(command => command.Kind == BrowserDrawKind.Text).ToArray(); Assert.Equal("A B", text[0].Text); Assert.Equal(" C", text[1].Text); Assert.Equal(20, text[1].Bounds.Y); } [Theory][InlineData("right", 75)][InlineData("center", 37.5)] public void TextAlignmentUsesMeasuredWidthIncludingNestedInlineRuns(string alignment, double expectedX) { var page = Page("
WWii
", "#A{width:100px;font-size:10px;text-align:" + alignment + "}"); var result = Layout(page); var first = Assert.Single(result.Commands, command => command.Text == "WW"); Assert.Equal(expectedX, first.Bounds.X, 5); Assert.Equal(first.Bounds.X, result.GetBoundingClientRect(Node(page, "B")).X, 5); } [Fact] public void FlexGrowUsesActualAvailableWidthAndGap() { var page = Page("
", "#A{display:flex;width:300px;height:80px;gap:10px}#B{flex:1 1 0px}#C{flex:2 1 0px}"); var result = Layout(page); Rect(result.GetBoundingClientRect(Node(page, "B")), 0, 0, 290d / 3, 80); Rect(result.GetBoundingClientRect(Node(page, "C")), 290d / 3 + 10, 0, 580d / 3, 80); } [Fact] public void FlexJustificationAlignmentAndShrinkRespectBoxes() { var page = Page("
", "#A{display:flex;width:300px;height:100px;justify-content:space-between;align-items:center}#B,#C{width:50px;height:20px;flex-shrink:0}"); var result = Layout(page); Rect(result.GetBoundingClientRect(Node(page, "B")), 0, 40, 50, 20); Rect(result.GetBoundingClientRect(Node(page, "C")), 250, 40, 50, 20); Node(page).SetAttribute("style", "width:60px"); result = Layout(page); Assert.Equal(100, result.GetBoundingClientRect(Node(page, "C")).Right); } [Fact] public void FlexColumnCanFillHeightWithGrow() { var page = Page("
", "#A{display:flex;flex-direction:column;width:200px;height:100px}#B{height:20px;flex-shrink:0}#C{flex:1}"); var result = Layout(page); Rect(result.GetBoundingClientRect(Node(page, "B")), 0, 0, 200, 20); Rect(result.GetBoundingClientRect(Node(page, "C")), 0, 20, 200, 80); } [Fact] public void FlexBasisUsesContentBoxAndAnonymousTextDoesNotInheritContainerWidth() { var page = Page("
WW
", "#A{display:flex;width:300px;font-size:10px}#B{flex:0 0 100px;padding:10px;border:1px solid red}"); var result = Layout(page); Assert.Equal(122, result.GetBoundingClientRect(Node(page, "B")).Width); Assert.Equal(122, Assert.Single(result.Commands, command => command.Text == "WW").Bounds.X); } [Fact] public void RelayoutReflectsDomMutationAndStableExternalIds() { var page = Page("
", "#A{height:20px}"); var engine = new BrowserLayoutEngine(); var a = Node(page); var first = engine.Layout(page, new(800, 600), nodeIdentity: node => ReferenceEquals(a, node) ? 999 : node.GetHashCode()); a.SetAttribute("style", "width:321px"); var second = engine.Layout(page, new(800, 600), nodeIdentity: _ => 0); Assert.Equal(999, Box(first, a).NodeId); Assert.Equal(999, Box(second, a).NodeId); Assert.Equal(321, second.GetBoundingClientRect(a).Width); } [Fact] public void CspBlockDisablesAllAuthorStylesIncludingLinksAndInline() { var page = Page("", "body{margin:0}#A{display:none}"); var result = new BrowserLayoutEngine().Layout(page, new(800, 600), [new("div{display:none}")], blockAuthorStyles: true); Assert.Equal("block", result.GetComputedStyle(Node(page))!.Display); Assert.Contains(result.Commands, command => command.Text == "Safe"); Assert.Equal(8, result.GetBoundingClientRect(Node(page)).X); Assert.Contains(result.Diagnostics, message => message.Contains("CSP")); } [Fact] public void InputsAndTextareasRenderCurrentValueInsteadOfStaleAttributeOrTextContent() { var page = Page(""); ((IHtmlInputElement)Node(page)).Value = "Typed"; ((IHtmlTextAreaElement)Node(page, "B")).Value = "New\nValue"; ((IHtmlInputElement)Node(page, "C")).Value = "secret"; var result = Layout(page); var text = result.Commands.Where(command => command.Kind == BrowserDrawKind.Text).Select(command => command.Text).ToArray(); Assert.Contains("Typed", text); Assert.Contains("New", text); Assert.Contains("Value", text); Assert.Contains("******", text); Assert.DoesNotContain("Old", text); Assert.DoesNotContain("Original", text); Assert.DoesNotContain("secret", text); Assert.Equal("Old", Node(page).GetAttribute("value")); Assert.Equal("Original", Node(page, "B").TextContent); Assert.Contains(result.Commands, command => command.Node == Node(page) && command.Kind == BrowserDrawKind.Border); } [Fact] public void CssNestingDepthCannotExhaustTheStack() { string css = string.Concat(Enumerable.Repeat("@media screen{", 40)) + "div{height:1px}" + new string('}', 40); Assert.Throws(() => Layout(Page("
", css))); } [Fact] public void CanvasCommandHasActualContentBoxAndFollowsOwnBackgroundBorder() { var page = Page("Fallback", "#A{padding:5px;border:2px solid blue;background:red}"); var result = Layout(page); var command = Assert.Single(result.Commands, command => command.Kind == BrowserDrawKind.Canvas); Rect(command.Bounds, 7, 7, 91, 73); Assert.Same(Node(page), command.Node); Assert.DoesNotContain(result.Commands, command => command.Text == "Fallback"); Assert.Equal(BrowserDrawKind.Border, result.Commands[result.Commands.ToList().IndexOf(command) - 1].Kind); } [Fact] public void NativePseudoStateMatchesHoveredAncestorsActiveAndFocusWithoutDomAttributes() { var page = Page("
", "#A:hover{background:red}button:hover{color:blue}button:active{color:green}#A:focus-within{width:300px}button:focus{height:50px}"); string original = page.DocumentElement!.OuterHtml; var engine = new BrowserLayoutEngine(); engine.SetInteractionState(Node(page, "B"), Node(page, "B"), Node(page, "B")); var result = engine.Layout(page, new(800, 600)); Assert.Equal(0xFF0000u, result.GetComputedStyle(Node(page))!.Background.Rgb); Assert.Equal(0x008000u, result.GetComputedStyle(Node(page, "B"))!.Color.Rgb); Assert.Equal(300, result.GetBoundingClientRect(Node(page)).Width); Assert.Equal(56, result.GetBoundingClientRect(Node(page, "B")).Height); Assert.Equal(original, page.DocumentElement.OuterHtml); engine.SetInteractionState(null, null, null); var cleared = engine.Layout(page, new(800, 600)); Assert.Equal(0, cleared.GetComputedStyle(Node(page))!.Background.Alpha); Assert.Equal(800, cleared.GetBoundingClientRect(Node(page)).Width); } [Theory][InlineData("body")][InlineData("html")] public void CanvasBackgroundCoversViewportWhenAllBodyChildrenAreAbsolutelyPositioned(string backgroundOwner) { var page = Page("
", backgroundOwner + "{background:#F1F6FB}#A{position:absolute;left:24px;top:24px;width:160px;height:64px;background:blue}"); var result = new BrowserLayoutEngine().Layout(page, new(800, 600, 0, 100)); var background = Assert.Single(result.Commands, command => command.Kind == BrowserDrawKind.Fill && command.Color == 0xF1F6FB); Rect(background.Bounds, 0, 100, 800, 600); Assert.Equal(backgroundOwner, ((IElement)background.Node!).LocalName); Assert.Equal(0, result.GetBoundingClientRect(page.Body!).Height); } [Fact] public void Translate3dMovesPaintHitTestsAndPositionedChildrenWithoutChangingFlow() { var page = Page("
", "#A{width:200px;height:40px;transform:translate3d(12px, 7px, 0px)}#B{position:absolute;right:0;top:0;width:10px;height:10px;background:red}#C{height:20px}"); var result = Layout(page); Rect(result.GetBoundingClientRect(Node(page)), 12, 7, 200, 40); Rect(result.GetBoundingClientRect(Node(page, "B")), 202, 7, 10, 10); Rect(result.GetBoundingClientRect(Node(page, "C")), 0, 40, 800, 20); Assert.Same(Node(page, "B"), result.HitTest(205, 10)!.Node); } [Fact] public void FontShorthandComputesActualFontLineHeightAndMetrics() { var page = Page("
WW
", "#A{font:italic bold 20px/30px 'Cascadia Mono'}"); var result = Layout(page); var text = Assert.Single(result.Commands, command => command.Kind == BrowserDrawKind.Text); Assert.Equal(20, text.Font.Size); Assert.Equal(700, text.Font.Weight); Assert.True(text.Font.Italic); Assert.Equal("Cascadia Mono", text.Font.Family); Assert.Equal(40, text.Bounds.Width); Assert.Equal(30, text.Bounds.Height); } [Fact] public void ViewportUnitsCalcMediaAndClientCoordinatesAreComputed() { var page = Page("
", "#A{width:calc(50vw - 20px);height:10vh}@media(min-width:700px){#A{margin-top:20px}}"); var result = new BrowserLayoutEngine().Layout(page, new(800, 600, 0, 10)); Rect(result.GetBoundingClientRect(Node(page)), 0, 10, 380, 60); } [Fact] public void UnsupportedFeaturesAreReportedAndMalformedBlocksBounded() { var page = Page("
Text
", "#A{display:grid;transform:rotate(1deg);background:linear-gradient(red,blue)}@font-face{font-family:x;src:url(x)}#A{width:"); var result = Layout(page); Assert.Contains(result.Diagnostics, message => message.Contains("transform")); Assert.Contains(result.Diagnostics, message => message.Contains("font-face")); Assert.Contains(result.Diagnostics, message => message.Contains("Malformed")); } [Theory] [InlineData("nodes")][InlineData("css")][InlineData("rules")][InlineData("declarations")][InlineData("selectors")][InlineData("commands")] public void InputAndOutputBudgetsFailExplicitly(string bound) { var limits = bound switch { "nodes" => new BrowserCssLimits { MaximumNodes = 2 }, "css" => new() { MaximumCssCharacters = 4 }, "rules" => new() { MaximumRules = 1 }, "declarations" => new() { MaximumDeclarations = 1 }, "selectors" => new() { MaximumSelectorEvaluations = 1 }, _ => new() { MaximumDisplayCommands = 1 } }; Assert.Throws(() => new BrowserLayoutEngine(limits).Layout(Page("
one two three
", "div{background:red;height:30px}"), new(800, 600))); } [Theory][InlineData(0)][InlineData(double.NaN)][InlineData(double.PositiveInfinity)][InlineData(100001)] public void RejectsInvalidViewport(double width) => Assert.Throws(() => new BrowserLayoutEngine().Layout(Page("x"), new(width, 600))); }