using XFESystem.Desktop; namespace XFESystem.Browser.Tests; public sealed class BackgroundPageTests { [Fact] public void EnhancementContinuationDoesNotNeedAnotherUiTimerAndYieldsToQueuedWork() { var worker = new Worker(); var factory = new Factory(worker); long now = 100; var service = new BrowserEditorPreparationService(factory, worker, new Canvases(worker), monotonicMilliseconds: () => now); var operation = service.Begin(new(80, 1, "# Loading", "Loading.md", 500, 300, now, EditorPreparationKind.Document)); worker.Run(); Assert.True(operation.TryTakeResult(out var result)); var actor = Assert.IsType(result.Page); Assert.True(factory.Page!.DocumentEnhancementsPending); Assert.Equal(0, worker.Count); actor.Paint(new Scanout(), new(0, 0, 500, 300), 0); actor.FramePresented(); bool interleaved = false; worker.TryQueue(() => { Assert.True(factory.Page.DocumentEnhancementsPending); interleaved = true; }); worker.Run(); Assert.True(interleaved); Assert.False(factory.Page.DocumentEnhancementsPending); Assert.Equal(100, now); Assert.Equal(0, worker.Count); actor.Cancel(); worker.Run(); Assert.True(actor.IsRetired); } [Fact] public void DocumentParsingGlyphPreparationAndEnhancementStayOnWorker() { var worker = new Worker(); var factory = new Factory(worker); var canvases = new Canvases(worker); long now = 100; var service = new BrowserEditorPreparationService(factory, worker, canvases, threadIdentity: () => worker.Running ? 2 : 1, monotonicMilliseconds: () => now); var operation = service.Begin(new(1, 1, "# Background heading\n\nA **real** Markdown document.", "Guide.md", 796, 404, now, EditorPreparationKind.Document)); Assert.Equal(0, factory.Creates); Assert.Equal(0, canvases.Creates); Assert.False(operation.TryTakeResult(out _)); worker.Run(); Assert.True(operation.TryTakeResult(out var result)); Assert.Null(result.Error); var actor = Assert.IsType(result.Page); Assert.Null(actor.ReadEditorText()); Assert.Contains("Background heading", actor.CurrentPage.Text); Assert.NotNull(factory.Page!.Document); Assert.True(factory.Page.DocumentEnhancementsPending); Assert.False(factory.Page.DocumentFirstFramePresented); Assert.True(canvases.Prepared > 0); Assert.Null(result.PreparedCanvas); var scanout = new Scanout(); actor.Paint(scanout, new(4, 8, 796, 404), 0); Assert.True(scanout.Blends > 0); Assert.Equal(0, worker.Count); actor.FramePresented(); Assert.False(factory.Page.DocumentFirstFramePresented); Assert.Equal(1, worker.Count); now += 100; worker.Run(); Assert.True(factory.Page.DocumentFirstFramePresented); actor.Cancel(); worker.Run(); Assert.True(actor.IsRetired); Assert.Null(factory.Page.Document); } [Fact] public void NetworkWaitUsesIndependentQueueAndFailurePublishesVisibleSnapshot() { var documents = new Worker(); var network = new Worker(); var factory = new Factory(network, failNetwork: true); var canvas = new Canvases(network); var service = new BrowserEditorPreparationService(factory, documents, canvas, navigationScheduler: network); var operation = service.Begin(new(2, 1, "", "https://www.bing.com/", 500, 300, Kind: EditorPreparationKind.Navigation)); Assert.Equal(0, documents.Count); Assert.Equal(1, network.Count); Assert.Equal(0, factory.NetworkCalls); network.Run(); Assert.True(operation.TryTakeResult(out var result)); Assert.Null(result.Error); var actor = Assert.IsType(result.Page); Assert.Equal("https://www.bing.com/", actor.CurrentPage.Address); Assert.Contains("controlled offline failure", actor.CurrentPage.Text); Assert.Null(actor.ReadEditorText()); Assert.Null(factory.Page!.Document); var scanout = new Scanout(); actor.Paint(scanout, new(0, 0, 500, 300), 0); Assert.True(scanout.Blends > 0); actor.Navigate("https://example.test/second"); Assert.Equal(1, factory.NetworkCalls); network.Run(); Assert.Equal(2, factory.NetworkCalls); Assert.Contains("/second", actor.CurrentPage.Address); actor.Release(); network.Run(); Assert.True(actor.IsRetired); } [Fact] public void PreparedGlyphPaintingQueriesNoUiFontsAndHonorsRoundedClipAndScale() { var worker = new Worker(); var canvases = new Canvases(worker); var page = new BrowserService(); worker.TryQueue(() => { page.SetCanvas(canvases.CreateEditorPreparationCanvas()); page.Navigate("about:Start"); }); worker.Run(); BrowserPaintSnapshot? snapshot = null; worker.TryQueue(() => { page.ResizeViewport(796, 404); snapshot = page.CaptureEditorPaintSnapshot(); }); worker.Run(); Assert.NotNull(snapshot); Assert.True(snapshot.PreparedTextCount > 0); var scanout = new Scanout(); snapshot.Paint(scanout, new(0, 0, 796, 404)); Assert.True(scanout.Blends > 0); worker.TryQueue(page.Release); worker.Run(); } [Fact] public void PreviewWheelDefaultRunsOnWorkerAndSurvivesTheNextSnapshotAndResize() { var worker = new Worker(); var factory = new Factory(worker); var service = new BrowserEditorPreparationService(factory, worker, new Canvases(worker)); string source = string.Join("\n\n", Enumerable.Range(1, 60).Select(index => "Paragraph number " + index + ".")); var operation = service.Begin(new(3, 1, source, "Long.md", 500, 300, Kind: EditorPreparationKind.Document)); worker.Run(); Assert.True(operation.TryTakeResult(out var result)); Assert.Null(result.Error); var actor = Assert.IsType(result.Page); actor.Pointer(new("wheel", 40, 40, DeltaY: 120)); Assert.Equal(0, actor.ScrollOffsetY); worker.Run(); Assert.Equal(120, actor.ScrollOffsetY); actor.Pointer(new("mousemove", 40, 40)); worker.Run(); Assert.Equal(120, actor.ScrollOffsetY); actor.ResizeViewport(600, 320); worker.Run(); Assert.Equal(120, actor.ScrollOffsetY); actor.Pointer(new("wheel", 40, 40, DeltaY: -10000)); worker.Run(); Assert.Equal(0, actor.ScrollOffsetY); actor.Cancel(); worker.Run(); Assert.True(actor.IsRetired); } [Fact] public void OldSnapshotAcknowledgementCannotPresentAReplacementDocument() { var worker = new Worker(); var factory = new Factory(worker); var service = new BrowserEditorPreparationService(factory, worker, new Canvases(worker)); var operation = service.Begin(new(4, 1, "# Old", "Old.md", 500, 300, Kind: EditorPreparationKind.Document)); worker.Run(); Assert.True(operation.TryTakeResult(out var result)); var actor = Assert.IsType(result.Page); actor.Paint(new Scanout(), new(0, 0, 500, 300), 0); // The actual owner's navigation gets ahead of the UI's old-frame ack. worker.TryQueue(() => factory.Page!.BeginDocumentPreview("# Replacement", "New.md")); actor.FramePresented(); worker.Run(); Assert.False(factory.Page!.DocumentFirstFramePresented); Assert.True(factory.Page.DocumentEnhancementsPending); Assert.Contains("Replacement", actor.CurrentPage.Text); actor.Paint(new Scanout(), new(0, 0, 500, 300), 0); actor.FramePresented(); worker.Run(); Assert.True(factory.Page.DocumentFirstFramePresented); actor.Cancel(); worker.Run(); } [Fact] public void StaticPagePresentationDoesNotStartASnapshotFeedbackLoop() { var worker = new Worker(); var service = new BrowserEditorPreparationService(new Factory(worker), worker, new Canvases(worker)); var operation = service.Begin(new(5, 1, "", "about:Start", 500, 300, Kind: EditorPreparationKind.Navigation)); worker.Run(); Assert.True(operation.TryTakeResult(out var result)); var actor = Assert.IsType(result.Page); long sequence = actor.Revision; for (int index = 0; index < 20; index++) { actor.Paint(new Scanout(), new(0, 0, 500, 300), 0); actor.FramePresented(); Assert.Equal(0, worker.Count); Assert.Equal(sequence, actor.Revision); } actor.Cancel(); worker.Run(); } [Fact] public void UnchangedMouseMotionAndReadbackPreservePaintAndGlyphSnapshots() { var worker = new Worker(); var canvas = new Canvases(worker); var factory = new Factory(worker, html: "
stationary page
"); var service = new BrowserEditorPreparationService(factory, worker, canvas); var operation = service.Begin(new(6, 1, "", "http://pointer.test/", 500, 300, Kind: EditorPreparationKind.Navigation)); worker.Run(); Assert.True(operation.TryTakeResult(out var result)); var actor = Assert.IsType(result.Page); actor.Pointer(new("mousemove", 20, 20)); worker.Run(); actor.AdvanceTime(0); Assert.True(actor.TextCursor); long paint = actor.Revision, layout = actor.Diagnostics.LayoutPassCount; int glyphs = canvas.Prepared; for (int index = 0; index < 1000; index++) { actor.Pointer(new("mousemove", 30 + index % 20, 20)); worker.Run(); Assert.False(actor.AdvanceTime(0)); } Assert.Equal(paint, actor.Revision); Assert.Equal(layout, actor.Diagnostics.LayoutPassCount); Assert.Equal(glyphs, canvas.Prepared); bool readback = false; Assert.True(actor.RequestEditorReadback((source, error) => { Assert.Null(source); Assert.Null(error); readback = true; })); worker.Run(); actor.AdvanceTime(0); Assert.True(readback); Assert.Equal(paint, actor.Revision); actor.Cancel(); worker.Run(); } [Theory] [InlineData("
hover
")] [InlineData("")] public void ActualHoverAndScriptCanvasChangesStillPublishNewPaint(string html) { var worker = new Worker(); var factory = new Factory(worker, html: html); var service = new BrowserEditorPreparationService(factory, worker, new Canvases(worker)); var operation = service.Begin(new(7, 1, "", "http://pointer.test/", 500, 300, Kind: EditorPreparationKind.Navigation)); worker.Run(); Assert.True(operation.TryTakeResult(out var result)); Assert.Null(result.Error); var actor = Assert.IsType(result.Page); long before = actor.Revision; actor.Pointer(new("mousemove", 30, 30)); worker.Run(); Assert.True(actor.Revision > before); actor.Cancel(); worker.Run(); } private sealed class Worker : IEditorBackgroundWorkScheduler { private readonly Queue work = []; public bool Running; public int Count => work.Count; // The platform FIFO accepts a continuation from its current worker. public bool TryQueue(Action action) { work.Enqueue(action); return true; } public void Run() { int count = 0; while (work.TryDequeue(out var action)) { Assert.True(++count < 20); Running = true; try { action(); } finally { Running = false; } } } } private sealed class Factory(Worker worker, bool failNetwork = false, string? html = null) : IInteractiveBrowserFactory, IWebTransport { public int Creates, NetworkCalls; public BrowserService? Page; public IInteractiveBrowserSession CreatePageSession() { Assert.True(worker.Running); Creates++; return Page = new(failNetwork || html is not null ? this : null); } public WebResponse Get(Uri address, BrowserLimits limits) { Assert.True(worker.Running); NetworkCalls++; if (html is not null) return new(address, 200, "text/html", html); throw new IOException("controlled offline failure"); } } private sealed class Canvases(Worker worker) : IEditorPreparationCanvasFactory { public int Creates, Prepared; public IDesktopCanvas CreateEditorPreparationCanvas() { Assert.True(worker.Running); Creates++; return new Metrics(this, worker); } private sealed class Metrics(Canvases owner, Worker worker) : IDesktopWebCanvas, IDesktopWebTextPreparationCanvas { public int Width => 796; public int Height => 404; public void Fill(uint rgb, int x, int y, int w, int h) => throw new InvalidOperationException(); public void Line(uint rgb, int x1, int y1, int x2, int y2) => throw new InvalidOperationException(); public void Text(string text, uint rgb, int x, int y) => throw new InvalidOperationException(); public void Present() => throw new InvalidOperationException(); public void PushClip(DesktopBounds bounds) => throw new InvalidOperationException(); public void PopClip() => throw new InvalidOperationException(); public double MeasureWebText(string text, double size, string family, int weight) { Assert.True(worker.Running); return new BrowserFallbackTextMetrics().Measure(text, new(size, family, weight)); } public double MeasureWebLineHeight(double size, string family, int weight) { Assert.True(worker.Running); return size * 1.2; } public (int Width, int Height, byte[] Alpha) RasterizeWebText(string text, double size, string family, int weight) => throw new InvalidOperationException(); public void WebText(string text, uint color, double x, double y, double size, string family, int weight, double opacity = 1) => throw new InvalidOperationException(); public IDesktopPreparedWebText PrepareWebText(string text, double size, string family, int weight, double opacity) { Assert.True(worker.Running); owner.Prepared++; return new TextCoverage(size * 1.2); } } } private sealed class TextCoverage(double lineHeight) : IDesktopPreparedWebText { private readonly byte[] alpha = [255, 128, 64, 0]; public int ScalePercent => 100; public double LineHeight => lineHeight; public void Paint(IDesktopCoverageCanvas target, uint rgb, double x, double y) => target.BlendCoverage(rgb, (int)Math.Round(x), (int)Math.Round(y), 2, 2, alpha); } private sealed class Scanout : IDesktopWebCanvas, IDesktopCoverageCanvas { public int Blends; public int Width => 1000; public int Height => 700; public void Fill(uint rgb, int x, int y, int w, int h) { } public void Line(uint rgb, int x1, int y1, int x2, int y2) { } public void Text(string text, uint rgb, int x, int y) => throw new InvalidOperationException("UI text fallback"); public void Present() { } public void PushClip(DesktopBounds bounds) { } public void PopClip() { } public void BlendCoverage(uint rgb, int x, int y, int width, int height, ReadOnlySpan alpha) { Blends++; } public double MeasureWebText(string text, double size, string family, int weight) => throw new InvalidOperationException("UI measurement"); public double MeasureWebLineHeight(double size, string family, int weight) => throw new InvalidOperationException("UI line metrics"); public (int Width, int Height, byte[] Alpha) RasterizeWebText(string text, double size, string family, int weight) => throw new InvalidOperationException("UI rasterization"); public void WebText(string text, uint rgb, double x, double y, double size, string family, int weight, double opacity = 1) => throw new InvalidOperationException("UI font painting"); } }