using AngleSharp.Dom; using AngleSharp.Html.Parser; namespace XFESystem.Browser.Tests; public sealed class BrowserCssCacheTests { private static IDocument Page(string css, string content = "
Code
") => new HtmlParser().ParseDocument("" + content); private static BrowserDisplayList Layout(BrowserLayoutEngine engine, IDocument page, double width = 800, double height = 600) => engine.Layout(page, new(width, height)); private static IElement A(IDocument page) => page.GetElementById("A")!; [Fact] public void RepeatedLayoutReusesAuthorRulesWhileCurrentDomStylesStillChange() { var page = Page("#A{width:100px;height:20px}"); var engine = new BrowserLayoutEngine(); Layout(engine, page); A(page).SetAttribute("style", "width:180px"); var next = Layout(engine, page); Assert.Equal(180, next.GetBoundingClientRect(A(page)).Width); Assert.Equal(1, engine.CssParseGeneration); Assert.Equal(1, engine.CssReuseCount); } [Fact] public void TextDataMutationInvalidatesCachedCascadeWithoutElementTextContentConcatenation() { var page = Page("#A{width:100px}"); var engine = new BrowserLayoutEngine(); Layout(engine, page); ((IText)page.GetElementById("Css")!.FirstChild!).Data = "#A{width:177px}"; Assert.Equal(177, Layout(engine, page).GetBoundingClientRect(A(page)).Width); Assert.Equal(2, engine.CssParseGeneration); } [Fact] public void MultiNodeStyleTextPreservesConcatenationAndInvalidatesChangesAndOrder() { var page = Page(""); var css = page.GetElementById("Css")!; css.AppendChild(page.CreateTextNode("#A{width:")); var value = page.CreateTextNode("100px"); css.AppendChild(value); css.AppendChild(page.CreateComment("ignored")); css.AppendChild(page.CreateTextNode("}")); var engine = new BrowserLayoutEngine(); Assert.Equal(100, Layout(engine, page).GetBoundingClientRect(A(page)).Width); Assert.Equal(100, Layout(engine, page).GetBoundingClientRect(A(page)).Width); Assert.Equal(1, engine.CssReuseCount); value.Data = "188px"; Assert.Equal(188, Layout(engine, page).GetBoundingClientRect(A(page)).Width); Assert.Equal(2, engine.CssParseGeneration); } [Fact] public void LinkedOwnerRemovalAndAuthorOrderChangeRebuildActualCascade() { var page = new HtmlParser().ParseDocument("
"); var link = page.GetElementById("Link")!; var last = page.GetElementById("Last")!; var sheets = new[] { new BrowserStyleSheet("#A{width:200px}", link) }; var engine = new BrowserLayoutEngine(); Assert.Equal(300, engine.Layout(page, new(800, 600), sheets).GetBoundingClientRect(A(page)).Width); page.Head!.InsertBefore(last, link); Assert.Equal(200, engine.Layout(page, new(800, 600), sheets).GetBoundingClientRect(A(page)).Width); link.Remove(); Assert.Equal(300, engine.Layout(page, new(800, 600), sheets).GetBoundingClientRect(A(page)).Width); Assert.Equal(3, engine.CssParseGeneration); } [Fact] public void WidthAndHeightMediaConditionsAndViewportUnitsUseCurrentViewport() { var page = Page("#A{width:100px;height:10vh}@media(min-width:600px){#A{width:200px}}@media(min-height:500px){#A{width:300px}}"); var engine = new BrowserLayoutEngine(); Assert.Equal(300, Layout(engine, page).GetBoundingClientRect(A(page)).Width); var shortPage = Layout(engine, page, 800, 400); Assert.Equal(200, shortPage.GetBoundingClientRect(A(page)).Width); Assert.Equal(40, shortPage.GetBoundingClientRect(A(page)).Height); Assert.Equal(100, Layout(engine, page, 400, 400).GetBoundingClientRect(A(page)).Width); Assert.Equal(3, engine.CssParseGeneration); } [Fact] public void CachedSelectorsReadLiveHoverActiveAndFocusStateWithoutReparsing() { var page = Page("#A{width:100px}#A:hover{width:150px}#A:active{width:180px}#A:focus{height:70px}"); var engine = new BrowserLayoutEngine(); Layout(engine, page); engine.SetInteractionState(A(page), null, null); Assert.Equal(150, Layout(engine, page).GetBoundingClientRect(A(page)).Width); engine.SetInteractionState(A(page), A(page), A(page)); var active = Layout(engine, page); Assert.Equal(180, active.GetBoundingClientRect(A(page)).Width); Assert.Equal(70, active.GetBoundingClientRect(A(page)).Height); engine.SetInteractionState(null, null, null); Assert.Equal(100, Layout(engine, page).GetBoundingClientRect(A(page)).Width); Assert.Equal(1, engine.CssParseGeneration); Assert.Equal(3, engine.CssReuseCount); } [Fact] public void NavigationClearsPreviousDocumentOwnersAndInteractionState() { var page = Page("#A:hover{width:100px}"); var engine = new BrowserLayoutEngine(); engine.SetInteractionState(A(page), null, null); Layout(engine, page); var second = Page("#A{width:180px}"); Assert.Equal(180, Layout(engine, second).GetBoundingClientRect(A(second)).Width); Assert.Equal(2, engine.CssParseGeneration); A(second).SetAttribute("style", "height:30px"); Assert.Equal(30, Layout(engine, second).GetBoundingClientRect(A(second)).Height); Assert.Equal(1, engine.CssReuseCount); } [Fact] public void CspToggleCannotReuseAuthorRulesAcrossDisabledStyles() { var page = Page("#A{width:100px}"); var engine = new BrowserLayoutEngine(); Layout(engine, page); var blocked = engine.Layout(page, new(800, 600), blockAuthorStyles: true); Assert.Equal(784, blocked.GetBoundingClientRect(A(page)).Width); Assert.Contains(blocked.Diagnostics, message => message.Contains("CSP")); Assert.Equal(100, Layout(engine, page).GetBoundingClientRect(A(page)).Width); } [Fact] public void CacheResetsInlineDeclarationAndSelectorBudgetsPerLayoutButStillRejectsExcess() { var page = Page("#A{width:100px}", "
X
"); var engine = new BrowserLayoutEngine(new() { MaximumDeclarations = 2, MaximumSelectorEvaluations = 5 }); for (int count = 0; count < 10; count++) Assert.Equal(20, Layout(engine, page).GetBoundingClientRect(A(page)).Height); Assert.Equal(1, engine.CssParseGeneration); A(page).SetAttribute("style", "height:20px;color:red"); Assert.Throws(() => Layout(engine, page)); // The subject index correctly skips #A for other elements. Use a // universal rule to exercise the actual matcher-work budget. page.QuerySelector("style")!.TextContent = "*{width:100px}"; A(page).SetAttribute("style", "height:20px"); A(page).AppendChild(page.CreateElement("span")); Assert.Throws(() => Layout(engine, page)); } [Fact] public void ChangedCssStillChecksSourceCharacterLimitAndParseDiagnosticsReplay() { var page = Page("#A{filter:none;width:100px}"); var engine = new BrowserLayoutEngine(new() { MaximumCssCharacters = 60 }); var first = Layout(engine, page); var cached = Layout(engine, page); Assert.Contains(first.Diagnostics, message => message.Contains("filter")); Assert.Contains(cached.Diagnostics, message => message.Contains("filter")); ((IText)page.GetElementById("Css")!.FirstChild!).Data = "/*" + new string('x', 100) + "*/"; Assert.Throws(() => Layout(engine, page)); } [Fact] public void CachedLayoutDoesNotReallocateLargeUnchangedStylesheetCopies() { // Keep substantial real parse work after comment scanning no longer // copies the entire source: cache reuse must avoid compiling these rules. string rules=string.Concat(Enumerable.Range(0,512).Select(index=>$".Unused{index}{{width:100px;height:20px}}")); var page = Page("/*" + new string('x', 150000) + "*/"+rules+"#A{width:100px}"); var engine = new BrowserLayoutEngine(); Layout(engine, page); Layout(engine, page); long before = GC.GetAllocatedBytesForCurrentThread(); Layout(engine, page); long cached = GC.GetAllocatedBytesForCurrentThread() - before; before = GC.GetAllocatedBytesForCurrentThread(); Layout(new BrowserLayoutEngine(), page); long fresh = GC.GetAllocatedBytesForCurrentThread() - before; Assert.True(cached < fresh / 4, $"Unchanged cache allocated {cached}; fresh parse allocated {fresh}."); Assert.Equal(1, engine.CssParseGeneration); } [Theory] [InlineData(false)] [InlineData(true)] public void ReplacedNodesAreNotRetainedByPersistentEngineAndEarlierGeometryStillWorks(bool suppliedIdentity) { var page = Page("#A{width:100px;height:20px}"); var engine = new BrowserLayoutEngine(); int next = 100; Func? identity = suppliedIdentity ? _ => ++next : null; var first = engine.Layout(page, new(800, 600), nodeIdentity: identity); var previous = A(page); int expected = engine.RetainedNodeIdentityCount; int bodyId = Assert.Single(first.Boxes, box => ReferenceEquals(box.Node, page.Body)).NodeId; for (int iteration = 0; iteration < 100; iteration++) { var replacement = page.CreateElement("div"); replacement.Id = "A"; replacement.TextContent = "Replacement"; A(page).Remove(); page.Body!.AppendChild(replacement); var current = engine.Layout(page, new(800, 600), nodeIdentity: identity); Assert.Equal(expected, engine.RetainedNodeIdentityCount); Assert.Equal(bodyId, Assert.Single(current.Boxes, box => ReferenceEquals(box.Node, page.Body)).NodeId); Assert.Equal(default, current.GetBoundingClientRect(previous)); } Assert.Equal(100, first.GetBoundingClientRect(previous).Width); Assert.Equal(1, engine.CssParseGeneration); } [Fact] public void FailedDomBudgetAlsoPrunesReplacedNodeIdentityReferences() { var page = Page("#A{width:100px}"); var engine = new BrowserLayoutEngine(new() { MaximumNodes = 8 }); Layout(engine, page); var previous = A(page); page.Body!.InnerHtml = string.Concat(Enumerable.Repeat("
New
", 20)); Assert.Throws(() => Layout(engine, page)); Assert.InRange(engine.RetainedNodeIdentityCount, 0, 8); page.Body.InnerHtml = "
Recovered
"; var current = Layout(engine, page); Assert.Equal(default, current.GetBoundingClientRect(previous)); Assert.Equal(100, current.GetBoundingClientRect(A(page)).Width); Assert.Equal(7, engine.RetainedNodeIdentityCount); } }