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<BackgroundBrowserEditorSession>(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<BackgroundBrowserEditorSession>(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<BackgroundBrowserEditorSession>(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<BackgroundBrowserEditorSession>(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<BackgroundBrowserEditorSession>(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<BackgroundBrowserEditorSession>(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: "<body style='margin:0'><div style='width:450px;height:280px;cursor:text'>stationary page</div></body>");
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<BackgroundBrowserEditorSession>(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("<style>div:hover{background-color:#FF0000}</style><div style='width:400px;height:200px'>hover</div>")]
[InlineData("<canvas id='c' width='400' height='200'></canvas><script>const c=document.getElementById('c');c.addEventListener('mousemove',()=>{c.getContext('2d').fillRect(0,0,10,10)});</script>")]
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<BackgroundBrowserEditorSession>(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<Action> 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<byte> 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");
}
}
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<BackgroundBrowserEditorSession>(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<BackgroundBrowserEditorSession>(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<BackgroundBrowserEditorSession>(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<BackgroundBrowserEditorSession>(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<BackgroundBrowserEditorSession>(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<BackgroundBrowserEditorSession>(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: "<body style='margin:0'><div style='width:450px;height:280px;cursor:text'>stationary page</div></body>");
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<BackgroundBrowserEditorSession>(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("<style>div:hover{background-color:#FF0000}</style><div style='width:400px;height:200px'>hover</div>")]
[InlineData("<canvas id='c' width='400' height='200'></canvas><script>const c=document.getElementById('c');c.addEventListener('mousemove',()=>{c.getContext('2d').fillRect(0,0,10,10)});</script>")]
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<BackgroundBrowserEditorSession>(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<Action> 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<byte> 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");
}
}