XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFESystem

【Rust】XFE系统,基于Rust和汇编

公开
关注 0 Fork 0 Star 0
UTF-8
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(
        "<html><head><style>body{margin:0}" + css + "</style></head><body>" + body + "</body></html>");
    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("<div id=A></div><div id=B></div>", "#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 TextIndentAffectsOnlyTheFirstLineAndInheritsAResolvedEmLength()
    {
        var page = Page("<div style='font-size:20px;text-indent:2em'><p id=A style='font-size:10px'>first<br>second</p></div>", "p{margin:0;width:200px}");
        var result = Layout(page); var box = Box(result, Node(page));
        var lines = result.Commands.Where(command => command.Kind == BrowserDrawKind.Text && command.Text is "first" or "second").ToArray();
        Assert.Equal(40, lines.Single(command => command.Text == "first").Bounds.X - box.ContentBox.X, 5);
        Assert.Equal(0, lines.Single(command => command.Text == "second").Bounds.X - box.ContentBox.X, 5);
        Assert.Equal("40px", box.Style.GetPropertyValue("text-indent"));
    }
    [Fact] public void PercentageTextIndentUsesTheReceivingBlockWidth()
    {
        var page = Page("<main style='width:400px;text-indent:10%'><p id=A>first</p></main>", "#A{margin:0;width:200px}");
        var result = Layout(page); var box = Box(result, Node(page));
        var text = result.Commands.Single(command => command.Kind == BrowserDrawKind.Text && command.Text == "first");
        Assert.Equal(20, text.Bounds.X - box.ContentBox.X, 5);
    }
    [Fact] public void NegativeTextIndentMovesASubmitLabelOutsideItsControlClipWithoutChangingItsValue()
    {
        var page = Page("<input id=A type=submit value='Search'>", "#A{width:40px;height:40px;text-indent:-99em;overflow:hidden}");
        var result = Layout(page); var box = Box(result, Node(page));
        var text = result.Commands.Single(command => command.Kind == BrowserDrawKind.Text && command.Text == "Search");
        Assert.True(text.Bounds.Right < box.ContentBox.Left);
        Assert.Contains(result.Commands, command => command.Kind == BrowserDrawKind.PushClip && command.NodeId == box.NodeId && command.Bounds == box.ContentBox);
        Assert.Equal("Search", Node(page).GetAttribute("value"));
    }
    [Fact] public void BorderBoxAndMinMaxConstrainActualContent()
    {
        var page = Page("<div id=A></div>", "#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("<div id=A></div>", "#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("<main><div id=A class=card data-kind=code style='color:yellow;width:17px'></div></main>",
            "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("<div id=A class=a style='color:yellow!important'></div><div id=B class=a></div>", ".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("<style>body{margin:0}#A{height:10px;color:red}</style><link id=L rel=stylesheet><style>#A{height:30px}</style><div id=A></div>");
        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("<div id=A><span id=B><b id=C>Wi</b></span></div>", "#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("<main id=A><div id=B></div></main>", "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("<div id=A></div><div id=B></div>", "#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("<div id=A></div><div id=B></div>", "#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("<div id=A>Hidden</div><div id=B>Seen</div>", "#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("<div id=A>Hidden<span id=B>Visible</span></div><div id=C></div>", "#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("<div id=A><div id=B></div></div>", "#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("<div id=A><span id=B>Alpha</span></div>", "#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("<div id=A><span id=B>WW ii WW</span></div>", "#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("<pre id=A>A\tB\n  C</pre>", "#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("<div id=A><span id=B>WW</span><span>ii</span></div>", "#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("<div id=A><div id=B></div><div id=C></div></div>", "#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("<div id=A><div id=B></div><div id=C></div></div>", "#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("<div id=A><div id=B></div><div id=C></div></div>", "#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("<div id=A><div id=B></div>WW</div>", "#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("<div id=A></div>", "#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("<div id=A style='display:none'>Safe</div>", "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("<input id=A value=Old><textarea id=B>Original</textarea><input id=C type=password>");
        ((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<BrowserLimitException>(() => Layout(Page("<div></div>", css)));
    }
    [Fact] public void CanvasCommandHasActualContentBoxAndFollowsOwnBackgroundBorder()
    {
        var page = Page("<canvas id=A width=91 height=73>Fallback</canvas>", "#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("<div id=A><button id=B>Button</button></div>", "#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("<div id=A></div>", 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("<div id=A><div id=B></div></div><div id=C></div>", "#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("<div id=A>WW</div>", "#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("<div id=A></div>", "#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("<div id=A>Text</div>", "#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<BrowserLimitException>(() => new BrowserLayoutEngine(limits).Layout(Page("<div id=A>one two three</div>", "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<ArgumentOutOfRangeException>(() => new BrowserLayoutEngine().Layout(Page("x"), new(width, 600)));
}