using AngleSharp.Dom;
using AngleSharp.Html.Parser;
using XFESystem.Desktop;
namespace XFESystem.Browser.Tests;
public sealed class BrowserPaintSnapshotTests
{
[Theory]
[InlineData(1)]
[InlineData(2)]
public void FrozenPaintMatchesOriginalRasterAndSurvivesWorkerMutations(int scale)
{
var node = new HtmlParser().ParseDocument("<canvas id='Source'></canvas>").GetElementById("Source")!;
var surface = new BrowserCanvasSurface(3, 2);
surface.Fill(0, 0, 3, 2, 0xFF214365);
surface.PutPixel(1, 1, 0x8076ABCD);
var stops = new List<BrowserGradientStop> { new(new(0x234567, 1), 0), new(new(0xAACCEE, .8), 1) };
var gradients = new List<BrowserCssGradient> { new(false, 45, .5, .5, stops) };
var shadows = new List<BrowserBoxShadow> { new(1, 2, 1.5, 0, new(0x112233, .3)) };
var decoration = new BrowserBoxDecoration(new(3, 4, 3, 4), new(1, 2, 1, 2), new(0x203040, .8),
new(0xEEDDAA, 1), new(0xAADDFF, 1), new(0x88AAFF, 1), new(0xABCDEF, 1), shadows, new(3, 3, 29, 22), gradients);
var rounded = new BrowserRoundedClip(new(4, 4, 27, 20), new(3, 3, 3, 3));
var commands = new List<BrowserDrawCommand>
{
new(BrowserDrawKind.Fill, new(0, 0, 40, 32), 1, node, 0x526374),
new(BrowserDrawKind.Fill, decoration.Bounds, 2, node, Decoration: decoration),
new(BrowserDrawKind.PushClip, rounded.Bounds, 3, node, RoundedClip: rounded),
new(BrowserDrawKind.Fill, new(2, 2, 20, 17), 4, node, 0x123456, .4),
new(BrowserDrawKind.Text, new(6, 6, 20, 8), 5, node, 0xAC99EA, Text: "XFE", Font: new(8, "Consolas")),
new(BrowserDrawKind.Canvas, new(8, 12, 15, 9), 6, node, Opacity: .75),
new(BrowserDrawKind.PopClip, default, 7, node)
};
var display = new BrowserDisplayList(new(40, 32, 2.25, 1.5), commands, [], [], 50, 40, textMetrics: new ThrowingMetrics());
int captures = 0;
var snapshot = BrowserPaintSnapshot.Capture(display, _ => { captures++; return surface; });
Assert.Equal(1, captures);
Assert.Equal(24, snapshot.CanvasBytes);
Assert.All(snapshot.Commands, command => Assert.Null(command.Node));
Assert.NotSame(decoration, snapshot.Commands[1].Decoration);
Assert.NotSame(rounded, snapshot.Commands[2].RoundedClip);
Assert.Throws<NotSupportedException>(() => ((IList<BrowserDrawCommand>)snapshot.Commands).Clear());
var copiedDecoration = snapshot.Commands[1].Decoration!;
Assert.Throws<NotSupportedException>(() => ((IList<BrowserBoxShadow>)copiedDecoration.Shadows).Clear());
Assert.Throws<NotSupportedException>(() => ((IList<BrowserGradientStop>)copiedDecoration.Gradients[0].Stops).Clear());
var original = new PixelCanvas(scale); var frozen = new PixelCanvas(scale);
var viewport = new DesktopBounds(2, 3, 40, 32);
BrowserRasterPainter.Paint(original, viewport, display, _ => surface);
snapshot.Paint(frozen, viewport);
Assert.Equal(original.Pixels, frozen.Pixels);
var expected = (uint[])frozen.Pixels.Clone();
commands.Clear(); stops[0] = new(new(0xFF0000, 1), 0); gradients.Clear(); shadows.Clear();
node.TextContent = "worker changed the DOM"; surface.Resize(1, 1); surface.Fill(0, 0, 1, 1, 0xFFFFFFFF);
var afterMutation = new PixelCanvas(scale); snapshot.Paint(afterMutation, viewport);
Assert.Equal(expected, afterMutation.Pixels);
Assert.Equal(1, captures); // UI painting never calls back into the owner.
Assert.Equal(7, snapshot.Commands.Count);
Assert.Equal(0x234567u, copiedDecoration.Gradients[0].Stops[0].Color.Rgb);
}
[Fact]
public void CanvasCopiesAreSharedOnlyWithinFrozenFrameAndLimitsFailExplicitly()
{
var node = new HtmlParser().ParseDocument("<canvas></canvas>").QuerySelector("canvas")!;
var surface = new BrowserCanvasSurface(2, 3);
var commands = new[] { new BrowserDrawCommand(BrowserDrawKind.Canvas, new(0, 0, 2, 3), 1, node),
new BrowserDrawCommand(BrowserDrawKind.Canvas, new(2, 0, 2, 3), 2, node) };
var display = new BrowserDisplayList(new(8, 8), commands, [], [], 8, 8);
Assert.Equal(24, BrowserPaintSnapshot.Capture(display, _ => surface, maximumCanvasPixels: 6).CanvasBytes);
Assert.Throws<BrowserLimitException>(() => BrowserPaintSnapshot.Capture(display, _ => surface, maximumCanvasPixels: 5));
Assert.Throws<BrowserLimitException>(() => BrowserPaintSnapshot.Capture(display, maximumCommands: 1));
Assert.Throws<ArgumentOutOfRangeException>(() => BrowserPaintSnapshot.Capture(display, maximumCommands: 0));
Assert.Throws<ArgumentOutOfRangeException>(() => BrowserPaintSnapshot.Capture(display, maximumCanvasPixels: -1));
}
private sealed class ThrowingMetrics : IBrowserTextMetrics
{ public double Measure(string text, BrowserFont font) => throw new InvalidOperationException("A paint snapshot cannot query old mutable layout metrics."); }
private sealed class PixelCanvas(int scale) : IDesktopCanvas, IDesktopCoverageCanvas
{
private const int NativeWidth = 128, NativeHeight = 96;
public uint[] Pixels { get; } = new uint[NativeWidth * NativeHeight];
public int Width => NativeWidth / scale;
public int Height => NativeHeight / scale;
public int ScalePercent => scale * 100;
public void Fill(uint rgb, int x, int y, int width, int height)
{
for (int row = Math.Max(0, y * scale); row < Math.Min(NativeHeight, (y + height) * scale); row++)
for (int col = Math.Max(0, x * scale); col < Math.Min(NativeWidth, (x + width) * scale); col++) Pixels[row * NativeWidth + col] = 0xFF000000 | rgb;
}
public void Line(uint rgb, int x1, int y1, int x2, int y2) => Fill(rgb, Math.Min(x1, x2), Math.Min(y1, y2), Math.Abs(x2 - x1) + 1, Math.Abs(y2 - y1) + 1);
public void Text(string text, uint rgb, int x, int y) { foreach (char character in text) { Fill(rgb, x, y, 2, 4); x += 3; } }
public void Present() { }
public void BlendCoverage(uint rgb, int x, int y, int width, int height, ReadOnlySpan<byte> alpha)
{
for (int row = 0; row < height; row++) for (int col = 0; col < width; col++)
{
int xx = x + col, yy = y + row;
if (xx < 0 || xx >= NativeWidth || yy < 0 || yy >= NativeHeight) continue;
uint amount = alpha[row * width + col], previous = Pixels[yy * NativeWidth + xx], result = 0xFF000000;
for (int shift = 0; shift < 24; shift += 8)
result |= ((((rgb >> shift) & 255) * amount + ((previous >> shift) & 255) * (255 - amount) + 127) / 255) << shift;
Pixels[yy * NativeWidth + xx] = result;
}
}
}
}
using AngleSharp.Dom;
using AngleSharp.Html.Parser;
using XFESystem.Desktop;
namespace XFESystem.Browser.Tests;
public sealed class BrowserPaintSnapshotTests
{
[Theory]
[InlineData(1)]
[InlineData(2)]
public void FrozenPaintMatchesOriginalRasterAndSurvivesWorkerMutations(int scale)
{
var node = new HtmlParser().ParseDocument("<canvas id='Source'></canvas>").GetElementById("Source")!;
var surface = new BrowserCanvasSurface(3, 2);
surface.Fill(0, 0, 3, 2, 0xFF214365);
surface.PutPixel(1, 1, 0x8076ABCD);
var stops = new List<BrowserGradientStop> { new(new(0x234567, 1), 0), new(new(0xAACCEE, .8), 1) };
var gradients = new List<BrowserCssGradient> { new(false, 45, .5, .5, stops) };
var shadows = new List<BrowserBoxShadow> { new(1, 2, 1.5, 0, new(0x112233, .3)) };
var decoration = new BrowserBoxDecoration(new(3, 4, 3, 4), new(1, 2, 1, 2), new(0x203040, .8),
new(0xEEDDAA, 1), new(0xAADDFF, 1), new(0x88AAFF, 1), new(0xABCDEF, 1), shadows, new(3, 3, 29, 22), gradients);
var rounded = new BrowserRoundedClip(new(4, 4, 27, 20), new(3, 3, 3, 3));
var commands = new List<BrowserDrawCommand>
{
new(BrowserDrawKind.Fill, new(0, 0, 40, 32), 1, node, 0x526374),
new(BrowserDrawKind.Fill, decoration.Bounds, 2, node, Decoration: decoration),
new(BrowserDrawKind.PushClip, rounded.Bounds, 3, node, RoundedClip: rounded),
new(BrowserDrawKind.Fill, new(2, 2, 20, 17), 4, node, 0x123456, .4),
new(BrowserDrawKind.Text, new(6, 6, 20, 8), 5, node, 0xAC99EA, Text: "XFE", Font: new(8, "Consolas")),
new(BrowserDrawKind.Canvas, new(8, 12, 15, 9), 6, node, Opacity: .75),
new(BrowserDrawKind.PopClip, default, 7, node)
};
var display = new BrowserDisplayList(new(40, 32, 2.25, 1.5), commands, [], [], 50, 40, textMetrics: new ThrowingMetrics());
int captures = 0;
var snapshot = BrowserPaintSnapshot.Capture(display, _ => { captures++; return surface; });
Assert.Equal(1, captures);
Assert.Equal(24, snapshot.CanvasBytes);
Assert.All(snapshot.Commands, command => Assert.Null(command.Node));
Assert.NotSame(decoration, snapshot.Commands[1].Decoration);
Assert.NotSame(rounded, snapshot.Commands[2].RoundedClip);
Assert.Throws<NotSupportedException>(() => ((IList<BrowserDrawCommand>)snapshot.Commands).Clear());
var copiedDecoration = snapshot.Commands[1].Decoration!;
Assert.Throws<NotSupportedException>(() => ((IList<BrowserBoxShadow>)copiedDecoration.Shadows).Clear());
Assert.Throws<NotSupportedException>(() => ((IList<BrowserGradientStop>)copiedDecoration.Gradients[0].Stops).Clear());
var original = new PixelCanvas(scale); var frozen = new PixelCanvas(scale);
var viewport = new DesktopBounds(2, 3, 40, 32);
BrowserRasterPainter.Paint(original, viewport, display, _ => surface);
snapshot.Paint(frozen, viewport);
Assert.Equal(original.Pixels, frozen.Pixels);
var expected = (uint[])frozen.Pixels.Clone();
commands.Clear(); stops[0] = new(new(0xFF0000, 1), 0); gradients.Clear(); shadows.Clear();
node.TextContent = "worker changed the DOM"; surface.Resize(1, 1); surface.Fill(0, 0, 1, 1, 0xFFFFFFFF);
var afterMutation = new PixelCanvas(scale); snapshot.Paint(afterMutation, viewport);
Assert.Equal(expected, afterMutation.Pixels);
Assert.Equal(1, captures); // UI painting never calls back into the owner.
Assert.Equal(7, snapshot.Commands.Count);
Assert.Equal(0x234567u, copiedDecoration.Gradients[0].Stops[0].Color.Rgb);
}
[Fact]
public void CanvasCopiesAreSharedOnlyWithinFrozenFrameAndLimitsFailExplicitly()
{
var node = new HtmlParser().ParseDocument("<canvas></canvas>").QuerySelector("canvas")!;
var surface = new BrowserCanvasSurface(2, 3);
var commands = new[] { new BrowserDrawCommand(BrowserDrawKind.Canvas, new(0, 0, 2, 3), 1, node),
new BrowserDrawCommand(BrowserDrawKind.Canvas, new(2, 0, 2, 3), 2, node) };
var display = new BrowserDisplayList(new(8, 8), commands, [], [], 8, 8);
Assert.Equal(24, BrowserPaintSnapshot.Capture(display, _ => surface, maximumCanvasPixels: 6).CanvasBytes);
Assert.Throws<BrowserLimitException>(() => BrowserPaintSnapshot.Capture(display, _ => surface, maximumCanvasPixels: 5));
Assert.Throws<BrowserLimitException>(() => BrowserPaintSnapshot.Capture(display, maximumCommands: 1));
Assert.Throws<ArgumentOutOfRangeException>(() => BrowserPaintSnapshot.Capture(display, maximumCommands: 0));
Assert.Throws<ArgumentOutOfRangeException>(() => BrowserPaintSnapshot.Capture(display, maximumCanvasPixels: -1));
}
private sealed class ThrowingMetrics : IBrowserTextMetrics
{ public double Measure(string text, BrowserFont font) => throw new InvalidOperationException("A paint snapshot cannot query old mutable layout metrics."); }
private sealed class PixelCanvas(int scale) : IDesktopCanvas, IDesktopCoverageCanvas
{
private const int NativeWidth = 128, NativeHeight = 96;
public uint[] Pixels { get; } = new uint[NativeWidth * NativeHeight];
public int Width => NativeWidth / scale;
public int Height => NativeHeight / scale;
public int ScalePercent => scale * 100;
public void Fill(uint rgb, int x, int y, int width, int height)
{
for (int row = Math.Max(0, y * scale); row < Math.Min(NativeHeight, (y + height) * scale); row++)
for (int col = Math.Max(0, x * scale); col < Math.Min(NativeWidth, (x + width) * scale); col++) Pixels[row * NativeWidth + col] = 0xFF000000 | rgb;
}
public void Line(uint rgb, int x1, int y1, int x2, int y2) => Fill(rgb, Math.Min(x1, x2), Math.Min(y1, y2), Math.Abs(x2 - x1) + 1, Math.Abs(y2 - y1) + 1);
public void Text(string text, uint rgb, int x, int y) { foreach (char character in text) { Fill(rgb, x, y, 2, 4); x += 3; } }
public void Present() { }
public void BlendCoverage(uint rgb, int x, int y, int width, int height, ReadOnlySpan<byte> alpha)
{
for (int row = 0; row < height; row++) for (int col = 0; col < width; col++)
{
int xx = x + col, yy = y + row;
if (xx < 0 || xx >= NativeWidth || yy < 0 || yy >= NativeHeight) continue;
uint amount = alpha[row * width + col], previous = Pixels[yy * NativeWidth + xx], result = 0xFF000000;
for (int shift = 0; shift < 24; shift += 8)
result |= ((((rgb >> shift) & 255) * amount + ((previous >> shift) & 255) * (255 - amount) + 127) / 255) << shift;
Pixels[yy * NativeWidth + xx] = result;
}
}
}
}