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("
Safe
", "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("", "#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("