From ba7405aead93353b582a691c77243da277916238 Mon Sep 17 00:00:00 2001 From: Nikita Tsukanov Date: Sat, 30 Mar 2024 05:03:49 +0500 Subject: [PATCH] Initial support for cross-testing against WPF (#15150) * Initial support for cross-testing against WPF * More cross-tests --- Avalonia.Desktop.slnf | 1 + Avalonia.sln | 7 + .../Avalonia.Direct2D1.RenderTests.csproj | 2 + .../Avalonia.RenderTests.WpfCompare.csproj | 23 ++ .../CrossFact.cs | 13 + .../CrossTestBase.cs | 53 ++++ .../CrossUI.Wpf.cs | 188 +++++++++++++++ .../Properties/AssemblyInfo.cs | 3 + .../CrossTests/Brushes/CrossTileBrushTests.cs | 54 +++++ .../Brushes/RadialGradientBrushTests.cs | 63 +++++ .../CrossUI/CrossUI.Avalonia.cs | 172 +++++++++++++ tests/Avalonia.RenderTests/CrossUI/CrossUI.cs | 203 ++++++++++++++++ tests/Avalonia.RenderTests/TestBase.cs | 185 +------------- .../Avalonia.RenderTests/TestRenderHelper.cs | 227 ++++++++++++++++++ .../WpfCompareTestBase.cs | 9 + .../CrossTestBase.cs | 60 +++++ .../Transform_Should_Work_As_Expected.wpf.png | Bin 0 -> 9566 bytes ...rd_Pattern_Is_Rendered_Identically.wpf.png | Bin 0 -> 491 bytes 18 files changed, 1088 insertions(+), 175 deletions(-) create mode 100644 tests/Avalonia.RenderTests.WpfCompare/Avalonia.RenderTests.WpfCompare.csproj create mode 100644 tests/Avalonia.RenderTests.WpfCompare/CrossFact.cs create mode 100644 tests/Avalonia.RenderTests.WpfCompare/CrossTestBase.cs create mode 100644 tests/Avalonia.RenderTests.WpfCompare/CrossUI.Wpf.cs create mode 100644 tests/Avalonia.RenderTests.WpfCompare/Properties/AssemblyInfo.cs create mode 100644 tests/Avalonia.RenderTests/CrossTests/Brushes/CrossTileBrushTests.cs create mode 100644 tests/Avalonia.RenderTests/CrossTests/Brushes/RadialGradientBrushTests.cs create mode 100644 tests/Avalonia.RenderTests/CrossUI/CrossUI.Avalonia.cs create mode 100644 tests/Avalonia.RenderTests/CrossUI/CrossUI.cs create mode 100644 tests/Avalonia.RenderTests/TestRenderHelper.cs create mode 100644 tests/Avalonia.RenderTests/WpfCompareTestBase.cs create mode 100644 tests/Avalonia.Skia.RenderTests/CrossTestBase.cs create mode 100644 tests/TestFiles/CrossTests/Media/RadialGradientBrush/Transform_Should_Work_As_Expected.wpf.png create mode 100644 tests/TestFiles/CrossTests/Media/TileBrushes/Simple_Checkboard_Pattern_Is_Rendered_Identically.wpf.png diff --git a/Avalonia.Desktop.slnf b/Avalonia.Desktop.slnf index ffff140b5d..40a3802a59 100644 --- a/Avalonia.Desktop.slnf +++ b/Avalonia.Desktop.slnf @@ -62,6 +62,7 @@ "tests\\Avalonia.Markup.UnitTests\\Avalonia.Markup.UnitTests.csproj", "tests\\Avalonia.Markup.Xaml.UnitTests\\Avalonia.Markup.Xaml.UnitTests.csproj", "tests\\Avalonia.ReactiveUI.UnitTests\\Avalonia.ReactiveUI.UnitTests.csproj", + "tests\\Avalonia.RenderTests.WpfCompare\\Avalonia.RenderTests.WpfCompare.csproj", "tests\\Avalonia.Skia.RenderTests\\Avalonia.Skia.RenderTests.csproj", "tests\\Avalonia.Skia.UnitTests\\Avalonia.Skia.UnitTests.csproj", "tests\\Avalonia.UnitTests\\Avalonia.UnitTests.csproj", diff --git a/Avalonia.sln b/Avalonia.sln index 5dbd99adaf..1ac6b77e4e 100644 --- a/Avalonia.sln +++ b/Avalonia.sln @@ -291,6 +291,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "BuildTasks", "BuildTasks", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PInvoke", "tests\TestFiles\BuildTasks\PInvoke\PInvoke.csproj", "{0A948D71-99C5-43E9-BACB-B0BA59EA25B4}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Avalonia.RenderTests.WpfCompare", "tests\Avalonia.RenderTests.WpfCompare\Avalonia.RenderTests.WpfCompare.csproj", "{9AE1B827-21AC-4063-AB22-C8804B7F931E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -675,6 +677,10 @@ Global {0A948D71-99C5-43E9-BACB-B0BA59EA25B4}.Debug|Any CPU.Build.0 = Debug|Any CPU {0A948D71-99C5-43E9-BACB-B0BA59EA25B4}.Release|Any CPU.ActiveCfg = Release|Any CPU {0A948D71-99C5-43E9-BACB-B0BA59EA25B4}.Release|Any CPU.Build.0 = Release|Any CPU + {9AE1B827-21AC-4063-AB22-C8804B7F931E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9AE1B827-21AC-4063-AB22-C8804B7F931E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9AE1B827-21AC-4063-AB22-C8804B7F931E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9AE1B827-21AC-4063-AB22-C8804B7F931E}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -758,6 +764,7 @@ Global {9D6AEF22-221F-4F4B-B335-A4BA510F002C} = {C5A00AC3-B34C-4564-9BDD-2DA473EF4D8B} {5BF0C3B8-E595-4940-AB30-2DA206C2F085} = {9D6AEF22-221F-4F4B-B335-A4BA510F002C} {0A948D71-99C5-43E9-BACB-B0BA59EA25B4} = {5BF0C3B8-E595-4940-AB30-2DA206C2F085} + {9AE1B827-21AC-4063-AB22-C8804B7F931E} = {C5A00AC3-B34C-4564-9BDD-2DA473EF4D8B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {87366D66-1391-4D90-8999-95A620AD786A} diff --git a/tests/Avalonia.Direct2D1.RenderTests/Avalonia.Direct2D1.RenderTests.csproj b/tests/Avalonia.Direct2D1.RenderTests/Avalonia.Direct2D1.RenderTests.csproj index febe87630e..e6cf735b2b 100644 --- a/tests/Avalonia.Direct2D1.RenderTests/Avalonia.Direct2D1.RenderTests.csproj +++ b/tests/Avalonia.Direct2D1.RenderTests/Avalonia.Direct2D1.RenderTests.csproj @@ -1,10 +1,12 @@  $(AvsCurrentTargetFramework) + $(DefineConstants);AVALONIA_D2D true + diff --git a/tests/Avalonia.RenderTests.WpfCompare/Avalonia.RenderTests.WpfCompare.csproj b/tests/Avalonia.RenderTests.WpfCompare/Avalonia.RenderTests.WpfCompare.csproj new file mode 100644 index 0000000000..d20f1b35d3 --- /dev/null +++ b/tests/Avalonia.RenderTests.WpfCompare/Avalonia.RenderTests.WpfCompare.csproj @@ -0,0 +1,23 @@ + + + $(AvsCurrentWindowsTargetFramework) + true + enable + true + + + + + + + + + + + + + + + + + diff --git a/tests/Avalonia.RenderTests.WpfCompare/CrossFact.cs b/tests/Avalonia.RenderTests.WpfCompare/CrossFact.cs new file mode 100644 index 0000000000..8b002eb52e --- /dev/null +++ b/tests/Avalonia.RenderTests.WpfCompare/CrossFact.cs @@ -0,0 +1,13 @@ +using Xunit; + +namespace Avalonia.RenderTests.WpfCompare; + +public class CrossFactAttribute : StaFactAttribute +{ + +} + +public class CrossTheoryAttribute : StaTheoryAttribute +{ + +} diff --git a/tests/Avalonia.RenderTests.WpfCompare/CrossTestBase.cs b/tests/Avalonia.RenderTests.WpfCompare/CrossTestBase.cs new file mode 100644 index 0000000000..e7d6b27213 --- /dev/null +++ b/tests/Avalonia.RenderTests.WpfCompare/CrossTestBase.cs @@ -0,0 +1,53 @@ +using System; +using System.IO; +using System.Runtime.CompilerServices; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using CrossUI; + +namespace Avalonia.RenderTests.WpfCompare; + +public class CrossTestBase +{ + private readonly string _groupName; + public CrossTestBase(string groupName) + { + _groupName = groupName; + } + + protected void RenderAndCompare(CrossControl root, [CallerMemberName] string? testName = null, double dpi = 96) + { + var dir = Path.Combine(GetTestsDirectory(), "TestFiles", "CrossTests", _groupName); + if (!Directory.Exists(dir)) + Directory.CreateDirectory(dir); + var path = Path.Combine(dir, testName + ".wpf.png"); + + var w = root.Width; + var h = root.Height; + var pw = (int)Math.Ceiling(w * dpi / 96); + var ph = (int)Math.Ceiling(h * dpi / 96); + + var control = new WpfCrossControl(root); + control.Measure(new System.Windows.Size(w, h)); + control.Arrange(new System.Windows.Rect(0, 0, w, h)); + var bmp = new RenderTargetBitmap(pw, ph, dpi, dpi, PixelFormats.Default); + bmp.Render(control); + var encoder = new PngBitmapEncoder(); + encoder.Frames.Add(BitmapFrame.Create(bmp)); + using (var f = File.Create(path)) + encoder.Save(f); + } + + static string GetTestsDirectory() + { + var path = Directory.GetCurrentDirectory(); + + while (path.Length > 0 && Path.GetFileName(path) != "tests") + { + path = Path.GetDirectoryName(path); + } + + return path; + } + +} diff --git a/tests/Avalonia.RenderTests.WpfCompare/CrossUI.Wpf.cs b/tests/Avalonia.RenderTests.WpfCompare/CrossUI.Wpf.cs new file mode 100644 index 0000000000..b450663248 --- /dev/null +++ b/tests/Avalonia.RenderTests.WpfCompare/CrossUI.Wpf.cs @@ -0,0 +1,188 @@ +using System; +using System.Windows.Controls; +using CrossUI; +using System.Collections.Generic; +using System.Linq; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using AlignmentX = System.Windows.Media.AlignmentX; +using AlignmentY = System.Windows.Media.AlignmentY; +using Brush = System.Windows.Media.Brush; +using BrushMappingMode = System.Windows.Media.BrushMappingMode; +using Color = Avalonia.Media.Color; +using Drawing = System.Windows.Media.Drawing; +using DrawingBrush = System.Windows.Media.DrawingBrush; +using DrawingCollection = System.Windows.Media.DrawingCollection; +using DrawingContext = System.Windows.Media.DrawingContext; +using DrawingGroup = System.Windows.Media.DrawingGroup; +using DrawingImage = System.Windows.Media.DrawingImage; +using Geometry = System.Windows.Media.Geometry; +using GeometryDrawing = System.Windows.Media.GeometryDrawing; +using MatrixTransform = System.Windows.Media.MatrixTransform; +using Pen = System.Windows.Media.Pen; +using RectangleGeometry = System.Windows.Media.RectangleGeometry; +using SolidColorBrush = System.Windows.Media.SolidColorBrush; +using Stretch = System.Windows.Media.Stretch; +using TileBrush = System.Windows.Media.TileBrush; +using TileMode = System.Windows.Media.TileMode; +using Transform = System.Windows.Media.Transform; +using WPoint = System.Windows.Point; +using WSize = System.Windows.Size; +using WRect = System.Windows.Rect; +using WColor = System.Windows.Media.Color; +using WMatrix = System.Windows.Media.Matrix; +namespace Avalonia.RenderTests.WpfCompare; + +internal static class WpfConvertExtensions +{ + public static WPoint ToWpf(this Point pt) => new(pt.X, pt.Y); + public static WSize ToWpf(this Size size) => new(size.Width, size.Height); + public static WRect ToWpf(this Rect rect) => new(rect.Left, rect.Top, rect.Width, rect.Height); + public static WColor ToWpf(this Color color) => WColor.FromArgb(color.A, color.R, color.G, color.B); + public static WMatrix ToWpf(this Matrix m) => new WMatrix(m.M11, m.M12, m.M21, m.M22, m.M31, m.M32); +} + +internal class WpfCrossControl : Panel +{ + private readonly CrossControl _src; + private readonly Dictionary _children; + + public WpfCrossControl(CrossControl src) + { + _src = src; + _children = src.Children.ToDictionary(x => x, x => new WpfCrossControl(x)); + Width = src.Bounds.Width; + Height = src.Bounds.Height; + RenderTransform = new MatrixTransform(src.RenderTransform.ToWpf()); + foreach (var ch in src.Children) + { + var c = _children[ch]; + this.Children.Add(c); + } + } + + protected override WSize MeasureOverride(WSize availableSize) + { + foreach (var ch in _children) + ch.Value.Measure(ch.Key.Bounds.Size.ToWpf()); + return _src.Bounds.Size.ToWpf(); + } + + protected override WSize ArrangeOverride(WSize finalSize) + { + foreach (var ch in _children) + ch.Value.Arrange(ch.Key.Bounds.ToWpf()); + return base.ArrangeOverride(finalSize); + } + + protected override void OnRender(DrawingContext context) + { + _src.Render(new WpfCrossDrawingContext(context)); + } +} + +internal class WpfCrossDrawingContext : ICrossDrawingContext +{ + private readonly DrawingContext _ctx; + + public WpfCrossDrawingContext(DrawingContext ctx) + { + _ctx = ctx; + } + + private static Transform? ConvertTransform(Matrix? m) => m == null ? null : new MatrixTransform(m.Value.ToWpf()); + + private static Geometry ConvertGeometry(CrossGeometry g) + { + if (g is CrossRectangleGeometry rg) + return new RectangleGeometry(rg.Rect.ToWpf()); + else if (g is CrossSvgGeometry svg) + return Geometry.Parse(svg.Path); + else if (g is CrossEllipseGeometry ellipse) + return new EllipseGeometry(ellipse.Rect.ToWpf()); + throw new NotSupportedException(); + } + + private static Drawing ConvertDrawing(CrossDrawing src) + { + if (src is CrossDrawingGroup g) + return new DrawingGroup() { Children = new DrawingCollection(g.Children.Select(ConvertDrawing)) }; + if (src is CrossGeometryDrawing geo) + return new GeometryDrawing() + { + Geometry = ConvertGeometry(geo.Geometry), Brush = ConvertBrush(geo.Brush), Pen = ConvertPen(geo.Pen) + }; + throw new NotSupportedException(); + } + + private static Brush? ConvertBrush(CrossBrush? brush) + { + if (brush == null) + return null; + static Brush Sync(Brush dst, CrossBrush src) + { + dst.Opacity = src.Opacity; + dst.Transform = ConvertTransform(src.Transform); + dst.RelativeTransform = ConvertTransform(src.RelativeTransform); + return dst; + } + + static Brush SyncTile(TileBrush dst, CrossTileBrush src) + { + dst.Stretch = (Stretch)src.Stretch; + dst.AlignmentX = (AlignmentX)src.AlignmentX; + dst.AlignmentY = (AlignmentY)src.AlignmentY; + dst.TileMode = (TileMode)src.TileMode; + dst.Viewbox = src.Viewbox.ToWpf(); + dst.ViewboxUnits = (BrushMappingMode)src.ViewboxUnits; + dst.Viewport = src.Viewport.ToWpf(); + dst.ViewportUnits = (BrushMappingMode)src.ViewportUnits; + return Sync(dst, src); + } + + static Brush SyncGradient(GradientBrush dst, CrossGradientBrush src) + { + dst.MappingMode = (BrushMappingMode)src.MappingMode; + dst.SpreadMethod = (GradientSpreadMethod)src.SpreadMethod; + dst.GradientStops = + new GradientStopCollection(src.GradientStops.Select(s => new GradientStop(s.Color.ToWpf(), s.Offset))); + return Sync(dst, src); + } + + if (brush is CrossSolidColorBrush br) + return Sync(new SolidColorBrush(br.Color.ToWpf()), brush); + if (brush is CrossDrawingBrush db) + return SyncTile(new DrawingBrush(ConvertDrawing(db.Drawing)), db); + if (brush is CrossRadialGradientBrush radial) + return SyncGradient(new RadialGradientBrush() + { + RadiusX = radial.RadiusX, + RadiusY = radial.RadiusY, + Center = radial.Center.ToWpf(), + GradientOrigin = radial.GradientOrigin.ToWpf() + }, radial); + throw new NotSupportedException(); + } + + private static Pen? ConvertPen(CrossPen? pen) + { + if (pen == null) + return null; + return new Pen(ConvertBrush(pen.Brush), pen.Thickness); + } + + private static ImageSource ConvertImage(CrossImage image) + { + if (image is CrossBitmapImage bi) + return new BitmapImage(new Uri(bi.Path, UriKind.Absolute)); + if (image is CrossDrawingImage di) + return new DrawingImage(ConvertDrawing(di.Drawing)); + throw new NotSupportedException(); + } + + public void DrawRectangle(CrossBrush? brush, CrossPen? pen, Rect rc) => _ctx.DrawRectangle(ConvertBrush(brush), ConvertPen(pen), rc.ToWpf()); + public void DrawGeometry(CrossBrush? brush, CrossPen? pen, CrossGeometry geo) => + _ctx.DrawGeometry(ConvertBrush(brush), ConvertPen(pen), ConvertGeometry(geo)); + + public void DrawImage(CrossImage image, Rect rc) => _ctx.DrawImage(ConvertImage(image), rc.ToWpf()); +} diff --git a/tests/Avalonia.RenderTests.WpfCompare/Properties/AssemblyInfo.cs b/tests/Avalonia.RenderTests.WpfCompare/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..a4bcec543f --- /dev/null +++ b/tests/Avalonia.RenderTests.WpfCompare/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using Xunit; + +[assembly: CollectionBehavior(DisableTestParallelization = true)] diff --git a/tests/Avalonia.RenderTests/CrossTests/Brushes/CrossTileBrushTests.cs b/tests/Avalonia.RenderTests/CrossTests/Brushes/CrossTileBrushTests.cs new file mode 100644 index 0000000000..d7283cf9d9 --- /dev/null +++ b/tests/Avalonia.RenderTests/CrossTests/Brushes/CrossTileBrushTests.cs @@ -0,0 +1,54 @@ +using Avalonia.Media; +using CrossUI; +using Xunit; + +#if AVALONIA_SKIA +namespace Avalonia.Skia.RenderTests; +#elif AVALONIA_D2D +namespace Avalonia.Direct2D1.RenderTests; +#else +namespace Avalonia.RenderTests.WpfCompare; +#endif + + +public class CrossTileBrushTests : CrossTestBase +{ + public CrossTileBrushTests() : base("Media/TileBrushes") + { + } + + [CrossFact] + public void Simple_Checkboard_Pattern_Is_Rendered_Identically() + { + RenderAndCompare(new CrossControl() + { + Width = 100, + Height = 100, + Background = new CrossDrawingBrush() + { + Drawing = new CrossDrawingGroup() + { + Children = + { + new CrossGeometryDrawing(new CrossRectangleGeometry(new(0, 0, 20, 20))) + { + Brush = new CrossSolidColorBrush(Colors.White) + }, + new CrossGeometryDrawing(new CrossRectangleGeometry(new(0, 0, 10, 10))) + { + Brush = new CrossSolidColorBrush(Colors.Black) + }, + new CrossGeometryDrawing(new CrossRectangleGeometry(new(10, 10, 10, 10))) + { + Brush = new CrossSolidColorBrush(Colors.Black) + }, + } + }, + Viewport = new Rect(0, 0, 10, 10), + ViewportUnits = BrushMappingMode.Absolute, + TileMode = TileMode.Tile + } + }); + + } +} diff --git a/tests/Avalonia.RenderTests/CrossTests/Brushes/RadialGradientBrushTests.cs b/tests/Avalonia.RenderTests/CrossTests/Brushes/RadialGradientBrushTests.cs new file mode 100644 index 0000000000..98529dcbee --- /dev/null +++ b/tests/Avalonia.RenderTests/CrossTests/Brushes/RadialGradientBrushTests.cs @@ -0,0 +1,63 @@ +using Avalonia.Media; +using CrossUI; +using Xunit; + +#if AVALONIA_SKIA +namespace Avalonia.Skia.RenderTests; +#elif AVALONIA_D2D +namespace Avalonia.Direct2D1.RenderTests; +#else +namespace Avalonia.RenderTests.WpfCompare; +#endif + + +public class CrossRadialGradientBrushTests : CrossTestBase +{ + public CrossRadialGradientBrushTests() : base("Media/RadialGradientBrush") + { + } + + [CrossFact] + public void Transform_Should_Work_As_Expected() + { + RenderAndCompare( + new CrossControl() + { + Children = + { + new CrossFuncControl(ctx => + { + var geo = new CrossEllipseGeometry(new Rect(3.430200000000003, 29.019099999999998, 42.7692, + 19.6732)); + ctx.DrawGeometry(new CrossSolidColorBrush(Colors.Magenta), null, geo); + ctx.DrawGeometry( + new CrossRadialGradientBrush() + { + RadiusX = 12.289, + RadiusY = 12.289, + GradientOrigin = new Point(15.116, 63.965), + Center = new Point(15.116, 63.965), + MappingMode = BrushMappingMode.Absolute, + SpreadMethod = GradientSpreadMethod.Pad, + GradientStops = + { + new GradientStop(Colors.Black, 0), new GradientStop(Colors.Transparent, 1) + }, + Transform = new Matrix(1.664, 0, + 0, 0.75621371, + -0.06567275, -10.272) + }, null, geo); + }) + { + Width = 48, + Height = 48, + RenderTransform = Matrix.CreateScale(4, 4) + } + }, + Width = 256, + Height = 256, + Background = new CrossSolidColorBrush(Colors.White) + }); + + } +} diff --git a/tests/Avalonia.RenderTests/CrossUI/CrossUI.Avalonia.cs b/tests/Avalonia.RenderTests/CrossUI/CrossUI.Avalonia.cs new file mode 100644 index 0000000000..29a772b7ae --- /dev/null +++ b/tests/Avalonia.RenderTests/CrossUI/CrossUI.Avalonia.cs @@ -0,0 +1,172 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Media.Immutable; +using CrossUI; + +#if AVALONIA_SKIA +namespace Avalonia.Skia.RenderTests.CrossUI; +#else +namespace Avalonia.Direct2D1.RenderTests.CrossUI; +#endif + +class AvaloniaCrossControl : Control +{ + private readonly CrossControl _src; + private readonly Dictionary _children; + + public AvaloniaCrossControl(CrossControl src) + { + _src = src; + _children = src.Children.ToDictionary(x => x, x => new AvaloniaCrossControl(x)); + Width = src.Bounds.Width; + Height = src.Bounds.Height; + RenderTransform = new MatrixTransform(src.RenderTransform); + RenderTransformOrigin = new RelativePoint(default, RelativeUnit.Relative); + foreach (var ch in src.Children) + { + var c = _children[ch]; + VisualChildren.Add(c); + LogicalChildren.Add(c); + } + } + + protected override Size MeasureOverride(Size availableSize) + { + foreach (var ch in _children) + ch.Value.Measure(ch.Key.Bounds.Size); + return _src.Bounds.Size; + } + + protected override Size ArrangeOverride(Size finalSize) + { + foreach (var ch in _children) + ch.Value.Arrange(ch.Key.Bounds); + return finalSize; + } + + public override void Render(DrawingContext context) + { + _src.Render(new AvaloniaCrossDrawingContext(context)); + } +} + +class AvaloniaCrossDrawingContext : ICrossDrawingContext +{ + private readonly DrawingContext _ctx; + + public AvaloniaCrossDrawingContext(DrawingContext ctx) + { + _ctx = ctx; + } + + static Transform? ConvertTransform(Matrix? m) => m == null ? null : new MatrixTransform(m.Value); + + static RelativeRect ConvertRect(Rect rc, BrushMappingMode mode) + => new RelativeRect(rc, + mode == BrushMappingMode.RelativeToBoundingBox ? RelativeUnit.Relative : RelativeUnit.Absolute); + + static RelativePoint ConvertPoint(Point pt, BrushMappingMode mode) + => new(pt, mode == BrushMappingMode.RelativeToBoundingBox ? RelativeUnit.Relative : RelativeUnit.Absolute); + + static RelativeScalar ConvertScalar(double scalar, BrushMappingMode mode) + => new(scalar, mode == BrushMappingMode.RelativeToBoundingBox ? RelativeUnit.Relative : RelativeUnit.Absolute); + + static Geometry ConvertGeometry(CrossGeometry g) + { + if (g is CrossRectangleGeometry rg) + return new RectangleGeometry(rg.Rect); + else if (g is CrossSvgGeometry svg) + return PathGeometry.Parse(svg.Path); + else if (g is CrossEllipseGeometry ellipse) + return new EllipseGeometry(ellipse.Rect); + throw new NotSupportedException(); + } + + static Drawing ConvertDrawing(CrossDrawing src) + { + if (src is CrossDrawingGroup g) + return new DrawingGroup() { Children = new DrawingCollection(g.Children.Select(ConvertDrawing)) }; + if (src is CrossGeometryDrawing geo) + return new GeometryDrawing() + { + Geometry = ConvertGeometry(geo.Geometry), Brush = ConvertBrush(geo.Brush), Pen = ConvertPen(geo.Pen) + }; + throw new NotSupportedException(); + } + + static IBrush? ConvertBrush(CrossBrush? brush) + { + if (brush == null) + return null; + static Brush Sync(Brush dst, CrossBrush src) + { + dst.Opacity = src.Opacity; + dst.Transform = ConvertTransform(src.Transform); + dst.TransformOrigin = new RelativePoint(default, RelativeUnit.Absolute); + if (src.RelativeTransform != null) + throw new PlatformNotSupportedException(); + return dst; + } + + static Brush SyncTile(TileBrush dst, CrossTileBrush src) + { + dst.Stretch = src.Stretch; + dst.AlignmentX = src.AlignmentX; + dst.AlignmentY = src.AlignmentY; + dst.TileMode = src.TileMode; + dst.SourceRect = ConvertRect(src.Viewbox, src.ViewboxUnits); + dst.DestinationRect = ConvertRect(src.Viewport, src.ViewportUnits); + return Sync(dst, src); + } + + static Brush SyncGradient(GradientBrush dst, CrossGradientBrush src) + { + dst.GradientStops = new GradientStops(); + dst.GradientStops.AddRange(src.GradientStops); + dst.SpreadMethod = src.SpreadMethod; + return Sync(dst, src); + } + + if (brush is CrossSolidColorBrush br) + return Sync(new SolidColorBrush(br.Color), brush); + if (brush is CrossDrawingBrush db) + return SyncTile(new DrawingBrush(ConvertDrawing(db.Drawing)), db); + if (brush is CrossRadialGradientBrush radial) + return SyncGradient( + new RadialGradientBrush() + { + Center = ConvertPoint(radial.Center, radial.MappingMode), + GradientOrigin = ConvertPoint(radial.GradientOrigin, radial.MappingMode), + RadiusX = ConvertScalar(radial.RadiusX, radial.MappingMode), + RadiusY = ConvertScalar(radial.RadiusY, radial.MappingMode) + }, radial); + throw new NotSupportedException(); + } + + static IPen? ConvertPen(CrossPen? pen) + { + if (pen == null) + return null; + return new Pen(ConvertBrush(pen.Brush), pen.Thickness); + } + + static IImage ConvertImage(CrossImage image) + { + if (image is CrossBitmapImage bi) + return new Bitmap(bi.Path); + if (image is CrossDrawingImage di) + return new DrawingImage(ConvertDrawing(di.Drawing)); + throw new NotSupportedException(); + } + + public void DrawRectangle(CrossBrush? brush, CrossPen? pen, Rect rc) => _ctx.DrawRectangle(ConvertBrush(brush), ConvertPen(pen), rc); + public void DrawGeometry(CrossBrush? brush, CrossPen? pen, CrossGeometry geometry) => + _ctx.DrawGeometry(ConvertBrush(brush), ConvertPen(pen), ConvertGeometry(geometry)); + + public void DrawImage(CrossImage image, Rect rc) => _ctx.DrawImage(ConvertImage(image), rc); +} diff --git a/tests/Avalonia.RenderTests/CrossUI/CrossUI.cs b/tests/Avalonia.RenderTests/CrossUI/CrossUI.cs new file mode 100644 index 0000000000..449f75b0c6 --- /dev/null +++ b/tests/Avalonia.RenderTests/CrossUI/CrossUI.cs @@ -0,0 +1,203 @@ +// ReSharper disable RedundantNameQualifier + +#nullable enable +using System; +using System.Collections.Generic; +using Avalonia.Media; +using Avalonia; + +namespace CrossUI; + +public class CrossBrush +{ + public double Opacity = 1; + public Avalonia.Matrix? Transform; + public Avalonia.Matrix? RelativeTransform; +} + +public class CrossSolidColorBrush : CrossBrush +{ + public Avalonia.Media.Color Color = Avalonia.Media.Colors.Black; + + public CrossSolidColorBrush() + { + + } + + public CrossSolidColorBrush(Avalonia.Media.Color color) + { + Color = color; + } +} + +public class CrossGradientBrush : CrossBrush +{ + public List GradientStops = new(); + public Avalonia.Media.GradientSpreadMethod SpreadMethod; + public BrushMappingMode MappingMode; +} + +public class CrossRadialGradientBrush : CrossGradientBrush +{ + public Avalonia.Point Center; + public Avalonia.Point GradientOrigin; + public double RadiusX, RadiusY; +} + +public class CrossTileBrush : CrossBrush +{ + public Avalonia.Media.AlignmentX AlignmentX = AlignmentX.Center; + public Avalonia.Media.AlignmentY AlignmentY = AlignmentY.Center; + public Avalonia.Media.Stretch Stretch = Stretch.Fill; + public Avalonia.Media.TileMode TileMode = TileMode.None; + public Rect Viewbox = new Rect(0, 0, 1, 1); + public Avalonia.Media.BrushMappingMode ViewboxUnits = BrushMappingMode.RelativeToBoundingBox; + public Rect Viewport = new Rect(0, 0, 1, 1); + public Avalonia.Media.BrushMappingMode ViewportUnits = BrushMappingMode.RelativeToBoundingBox; +} + + +public abstract class CrossDrawing +{ +} + + +public class CrossGeometryDrawing : CrossDrawing +{ + public CrossGeometry Geometry; + public CrossBrush? Brush; + public CrossPen? Pen; + public CrossGeometryDrawing(CrossGeometry geometry) + { + Geometry = geometry; + } +} + +public class CrossDrawingGroup : CrossDrawing +{ + public List Children = new(); +} + +public abstract class CrossGeometry +{ + +} + +public class CrossSvgGeometry : CrossGeometry +{ + public string Path; + + public CrossSvgGeometry(string path) + { + Path = path; + } +} + +public class CrossEllipseGeometry : CrossGeometry +{ + public CrossEllipseGeometry(Rect rect) + { + Rect = rect; + } + + public CrossEllipseGeometry() + { + + } + + public Rect Rect { get; set; } +} + +public class CrossRectangleGeometry : CrossGeometry +{ + public Rect Rect; + + public CrossRectangleGeometry(Rect rect) + { + Rect = rect; + } +} + +public class CrossDrawingBrush : CrossTileBrush +{ + public CrossDrawing Drawing; +} + +public class CrossPen +{ + public CrossBrush Brush; + public double Thickness = 1; +} + +public interface ICrossDrawingContext +{ + void DrawRectangle(CrossBrush? brush, CrossPen? pen, Rect rc); + void DrawGeometry(CrossBrush? brush, CrossPen? pen, CrossGeometry geometry); + void DrawImage(CrossImage image, Rect rc); +} + +public abstract class CrossImage +{ + +} + +public class CrossBitmapImage : CrossImage +{ + public string Path; + public CrossBitmapImage(string path) + { + Path = path; + } +} + +public class CrossDrawingImage : CrossImage +{ + public CrossDrawing Drawing; +} + + +public class CrossControl +{ + public Rect Bounds => new Rect(Left, Top, Width, Height); + public double Left, Top, Width, Height; + public CrossBrush? Background; + public CrossPen? Outline; + public List Children = new(); + public Matrix RenderTransform = Matrix.Identity; + + public virtual void Render(ICrossDrawingContext ctx) + { + var rc = new Rect(Bounds.Size); + if (Background != null || Outline != null) + ctx.DrawRectangle(Background, Outline, rc); + } +} + +public class CrossFuncControl : CrossControl +{ + private readonly Action _render; + + public CrossFuncControl(Action render) + { + _render = render; + } + + public override void Render(ICrossDrawingContext ctx) + { + base.Render(ctx); + _render(ctx); + } +} + +public class CrossImageControl : CrossControl +{ + public CrossImage Image; + public override void Render(ICrossDrawingContext ctx) + { + base.Render(ctx); + var rc = new Rect(Bounds.Size); + ctx.DrawImage(Image, rc); + } +} + + diff --git a/tests/Avalonia.RenderTests/TestBase.cs b/tests/Avalonia.RenderTests/TestBase.cs index 0d9f526a4b..1bec00649a 100644 --- a/tests/Avalonia.RenderTests/TestBase.cs +++ b/tests/Avalonia.RenderTests/TestBase.cs @@ -42,27 +42,6 @@ namespace Avalonia.Direct2D1.RenderTests #endif public static FontFamily TestFontFamily = new FontFamily(s_fontUri); - private static readonly TestDispatcherImpl threadingInterface = - new TestDispatcherImpl(); - - private static readonly IAssetLoader assetLoader = new StandardAssetLoader(); - - static TestBase() - { -#if AVALONIA_SKIA - SkiaPlatform.Initialize(); -#else - Direct2D1Platform.Initialize(); -#endif - AvaloniaLocator.CurrentMutable - .Bind() - .ToConstant(threadingInterface); - - AvaloniaLocator.CurrentMutable - .Bind() - .ToConstant(assetLoader); - } - public TestBase(string outputPath) { outputPath = outputPath.Replace('\\', Path.DirectorySeparatorChar); @@ -75,7 +54,7 @@ namespace Avalonia.Direct2D1.RenderTests #endif OutputPath = Path.Combine(testFiles, platform, outputPath); - threadingInterface.MainThread = Thread.CurrentThread; + TestRenderHelper.BeginTest(); } public string OutputPath @@ -83,64 +62,17 @@ namespace Avalonia.Direct2D1.RenderTests get; } - protected Task RenderToFile(Control target, [CallerMemberName] string testName = "", double dpi = 96) + protected async Task RenderToFile(Control target, [CallerMemberName] string testName = "", double dpi = 96) { if (!Directory.Exists(OutputPath)) { Directory.CreateDirectory(OutputPath); } - + var immediatePath = Path.Combine(OutputPath, testName + ".immediate.out.png"); var compositedPath = Path.Combine(OutputPath, testName + ".composited.out.png"); - var factory = AvaloniaLocator.Current.GetRequiredService(); - var pixelSize = new PixelSize((int)target.Width, (int)target.Height); - var size = new Size(target.Width, target.Height); - var dpiVector = new Vector(dpi, dpi); - - using (RenderTargetBitmap bitmap = new RenderTargetBitmap(pixelSize, dpiVector)) - { - target.Measure(size); - target.Arrange(new Rect(size)); - bitmap.Render(target); - bitmap.Save(immediatePath); - } - - var timer = new ManualRenderTimer(); - - var compositor = new Compositor(new RenderLoop(timer), null, true, - new DispatcherCompositorScheduler(), true, Dispatcher.UIThread); - using (var writableBitmap = factory.CreateWriteableBitmap(pixelSize, dpiVector, factory.DefaultPixelFormat, factory.DefaultAlphaFormat)) - { - var root = new TestRenderRoot(dpiVector.X / 96, null!); - using (var renderer = new CompositingRenderer(root, compositor, () => new[] - { - new BitmapFramebufferSurface(writableBitmap) - })) - { - root.Initialize(renderer, target); - renderer.Start(); - Dispatcher.UIThread.RunJobs(); - renderer.Paint(new Rect(root.Bounds.Size), false); - } - writableBitmap.Save(compositedPath); - } - - return Task.CompletedTask; - } - - class BitmapFramebufferSurface : IFramebufferPlatformSurface - { - private readonly IWriteableBitmapImpl _bitmap; - - public BitmapFramebufferSurface(IWriteableBitmapImpl bitmap) - { - _bitmap = bitmap; - } - - public IFramebufferRenderTarget CreateFramebufferRenderTarget() - { - return new FuncFramebufferRenderTarget(() => _bitmap.Lock()); - } + await TestRenderHelper.RenderToFile(target, immediatePath, true, dpi); + await TestRenderHelper.RenderToFile(target, compositedPath, false, dpi); } protected void CompareImages([CallerMemberName] string testName = "", @@ -156,7 +88,7 @@ namespace Avalonia.Direct2D1.RenderTests { if (!skipImmediate) { - var immediateError = CompareImages(immediate!, expected); + var immediateError = TestRenderHelper.CompareImages(immediate!, expected); if (immediateError > 0.022) { Assert.True(false, immediatePath + ": Error = " + immediateError); @@ -165,7 +97,7 @@ namespace Avalonia.Direct2D1.RenderTests if (!skipCompositor) { - var compositedError = CompareImages(composited!, expected); + var compositedError = TestRenderHelper.CompareImages(composited!, expected); if (compositedError > 0.022) { Assert.True(false, compositedPath + ": Error = " + compositedError); @@ -178,108 +110,11 @@ namespace Avalonia.Direct2D1.RenderTests { var expectedPath = Path.Combine(OutputPath, (expectedName ?? testName) + ".expected.png"); var actualPath = Path.Combine(OutputPath, testName + ".out.png"); - - using (var expected = Image.Load(expectedPath)) - using (var actual = Image.Load(actualPath)) - { - double immediateError = CompareImages(actual, expected); - - if (immediateError > 0.022) - { - Assert.True(false, actualPath + ": Error = " + immediateError); - } - } + TestRenderHelper.AssertCompareImages(actualPath, expectedPath); } - /// - /// Calculates root mean square error for given two images. - /// Based roughly on ImageMagick implementation to ensure consistency. - /// - private static double CompareImages(Image actual, Image expected) - { - if (actual.Width != expected.Width || actual.Height != expected.Height) - { - throw new ArgumentException("Images have different resolutions"); - } - - var quantity = actual.Width * actual.Height; - double squaresError = 0; - - const double scale = 1 / 255d; - - for (var x = 0; x < actual.Width; x++) - { - double localError = 0; - - for (var y = 0; y < actual.Height; y++) - { - var expectedAlpha = expected[x, y].A * scale; - var actualAlpha = actual[x, y].A * scale; - - var r = scale * (expectedAlpha * expected[x, y].R - actualAlpha * actual[x, y].R); - var g = scale * (expectedAlpha * expected[x, y].G - actualAlpha * actual[x, y].G); - var b = scale * (expectedAlpha * expected[x, y].B - actualAlpha * actual[x, y].B); - var a = expectedAlpha - actualAlpha; - - var error = r * r + g * g + b * b + a * a; - - localError += error; - } - - squaresError += localError; - } - - var meanSquaresError = squaresError / quantity; - - const int channelCount = 4; - - meanSquaresError = meanSquaresError / channelCount; - - return Math.Sqrt(meanSquaresError); - } - - private static string GetTestsDirectory() - { - var path = Directory.GetCurrentDirectory(); + private static string GetTestsDirectory() => TestRenderHelper.GetTestsDirectory(); - while (path.Length > 0 && Path.GetFileName(path) != "tests") - { - path = Path.GetDirectoryName(path); - } - - return path; - } - - private class TestDispatcherImpl : IDispatcherImpl - { - public bool CurrentThreadIsLoopThread => MainThread.ManagedThreadId == Thread.CurrentThread.ManagedThreadId; - - public Thread MainThread { get; set; } - -#pragma warning disable 67 - public event Action Signaled; - public event Action Timer; -#pragma warning restore 67 - - public void Signal() - { - // No-op - } - - public long Now => 0; - - public void UpdateTimer(long? dueTimeInMs) - { - // No-op - } - } - - public void Dispose() - { - if (Dispatcher.UIThread.CheckAccess()) - { - Dispatcher.UIThread.RunJobs(); - } - } + public void Dispose() => TestRenderHelper.EndTest(); } } diff --git a/tests/Avalonia.RenderTests/TestRenderHelper.cs b/tests/Avalonia.RenderTests/TestRenderHelper.cs new file mode 100644 index 0000000000..81e225f83d --- /dev/null +++ b/tests/Avalonia.RenderTests/TestRenderHelper.cs @@ -0,0 +1,227 @@ +using System.IO; +using System.Runtime.CompilerServices; +using Avalonia.Controls; +using Avalonia.Media.Imaging; +using Avalonia.Rendering; +using SixLabors.ImageSharp; +using Xunit; +using Avalonia.Platform; +using System.Threading.Tasks; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Reactive.Disposables; +using System.Threading; +using Avalonia.Controls.Platform.Surfaces; +using Avalonia.Media; +using Avalonia.Rendering.Composition; +using Avalonia.Threading; +using Avalonia.UnitTests; +using Avalonia.Utilities; +using SixLabors.ImageSharp.PixelFormats; +using Image = SixLabors.ImageSharp.Image; +#if AVALONIA_SKIA +using Avalonia.Skia; +#else +using Avalonia.Direct2D1; +#endif + +#if AVALONIA_SKIA +namespace Avalonia.Skia.RenderTests; +#else +namespace Avalonia.Direct2D1.RenderTests; +#endif + +static class TestRenderHelper +{ + private static readonly TestDispatcherImpl s_dispatcherImpl = + new TestDispatcherImpl(); + + static TestRenderHelper() + { +#if AVALONIA_SKIA + SkiaPlatform.Initialize(); +#else + Direct2D1Platform.Initialize(); +#endif + AvaloniaLocator.CurrentMutable + .Bind() + .ToConstant(s_dispatcherImpl); + + AvaloniaLocator.CurrentMutable.Bind().ToConstant(new StandardAssetLoader()); + } + + + public static Task RenderToFile(Control target, string path, bool immediate, double dpi = 96) + { + var dir = Path.GetDirectoryName(path); + if (!Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + var factory = AvaloniaLocator.Current.GetRequiredService(); + var pixelSize = new PixelSize((int)target.Width, (int)target.Height); + var size = new Size(target.Width, target.Height); + var dpiVector = new Vector(dpi, dpi); + + if (immediate) + { + using (RenderTargetBitmap bitmap = new RenderTargetBitmap(pixelSize, dpiVector)) + { + target.Measure(size); + target.Arrange(new Rect(size)); + bitmap.Render(target); + bitmap.Save(path); + } + } + else + { + var timer = new ManualRenderTimer(); + + var compositor = new Compositor(new RenderLoop(timer), null, true, + new DispatcherCompositorScheduler(), true, Dispatcher.UIThread); + using (var writableBitmap = factory.CreateWriteableBitmap(pixelSize, dpiVector, factory.DefaultPixelFormat, + factory.DefaultAlphaFormat)) + { + var root = new TestRenderRoot(dpiVector.X / 96, null!); + using (var renderer = new CompositingRenderer(root, compositor, + () => new[] { new BitmapFramebufferSurface(writableBitmap) })) + { + root.Initialize(renderer, target); + renderer.Start(); + Dispatcher.UIThread.RunJobs(); + renderer.Paint(new Rect(root.Bounds.Size), false); + } + + writableBitmap.Save(path); + } + } + + return Task.CompletedTask; + } + + class BitmapFramebufferSurface : IFramebufferPlatformSurface + { + private readonly IWriteableBitmapImpl _bitmap; + + public BitmapFramebufferSurface(IWriteableBitmapImpl bitmap) + { + _bitmap = bitmap; + } + + public IFramebufferRenderTarget CreateFramebufferRenderTarget() + { + return new FuncFramebufferRenderTarget(() => _bitmap.Lock()); + } + } + + + public static void BeginTest() + { + s_dispatcherImpl.MainThread = Thread.CurrentThread; + } + + public static void EndTest() + { + if (Dispatcher.UIThread.CheckAccess()) + Dispatcher.UIThread.RunJobs(); + } + + public static string GetTestsDirectory() + { + var path = Directory.GetCurrentDirectory(); + + while (path.Length > 0 && Path.GetFileName(path) != "tests") + { + path = Path.GetDirectoryName(path); + } + + return path; + } + + private class TestDispatcherImpl : IDispatcherImpl + { + public bool CurrentThreadIsLoopThread => MainThread.ManagedThreadId == Thread.CurrentThread.ManagedThreadId; + + public Thread MainThread { get; set; } + +#pragma warning disable 67 + public event Action Signaled; + public event Action Timer; +#pragma warning restore 67 + + public void Signal() + { + // No-op + } + + public long Now => 0; + + public void UpdateTimer(long? dueTimeInMs) + { + // No-op + } + } + + public static void AssertCompareImages(string actualPath, string expectedPath) + { + using (var expected = Image.Load(expectedPath)) + using (var actual = Image.Load(actualPath)) + { + double immediateError = TestRenderHelper.CompareImages(actual, expected); + + if (immediateError > 0.022) + { + Assert.True(false, actualPath + ": Error = " + immediateError); + } + } + } + + /// + /// Calculates root mean square error for given two images. + /// Based roughly on ImageMagick implementation to ensure consistency. + /// + public static double CompareImages(Image actual, Image expected) + { + if (actual.Width != expected.Width || actual.Height != expected.Height) + { + throw new ArgumentException("Images have different resolutions"); + } + + var quantity = actual.Width * actual.Height; + double squaresError = 0; + + const double scale = 1 / 255d; + + for (var x = 0; x < actual.Width; x++) + { + double localError = 0; + + for (var y = 0; y < actual.Height; y++) + { + var expectedAlpha = expected[x, y].A * scale; + var actualAlpha = actual[x, y].A * scale; + + var r = scale * (expectedAlpha * expected[x, y].R - actualAlpha * actual[x, y].R); + var g = scale * (expectedAlpha * expected[x, y].G - actualAlpha * actual[x, y].G); + var b = scale * (expectedAlpha * expected[x, y].B - actualAlpha * actual[x, y].B); + var a = expectedAlpha - actualAlpha; + + var error = r * r + g * g + b * b + a * a; + + localError += error; + } + + squaresError += localError; + } + + var meanSquaresError = squaresError / quantity; + + const int channelCount = 4; + + meanSquaresError = meanSquaresError / channelCount; + + return Math.Sqrt(meanSquaresError); + } + +} diff --git a/tests/Avalonia.RenderTests/WpfCompareTestBase.cs b/tests/Avalonia.RenderTests/WpfCompareTestBase.cs new file mode 100644 index 0000000000..36db8b3da5 --- /dev/null +++ b/tests/Avalonia.RenderTests/WpfCompareTestBase.cs @@ -0,0 +1,9 @@ +#if AVALONIA_SKIA +namespace Avalonia.Skia.RenderTests; +#else +namespace Avalonia.Direct2D1.RenderTests; +#endif +class WpfCompareTestBase +{ + +} diff --git a/tests/Avalonia.Skia.RenderTests/CrossTestBase.cs b/tests/Avalonia.Skia.RenderTests/CrossTestBase.cs new file mode 100644 index 0000000000..215d5b4826 --- /dev/null +++ b/tests/Avalonia.Skia.RenderTests/CrossTestBase.cs @@ -0,0 +1,60 @@ +using System; +using System.IO; +using System.Runtime.CompilerServices; +using Avalonia.Skia.RenderTests; +using Avalonia.Skia.RenderTests.CrossUI; +using CrossUI; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using Xunit; + +#if AVALONIA_SKIA +namespace Avalonia.Skia.RenderTests; +#else +namespace Avalonia.Direct2D1.RenderTests; +#endif + +class CrossFactAttribute : FactAttribute +{ + +} + +class CrossThreoryAttribute : TheoryAttribute +{ + +} + +public class CrossTestBase : IDisposable +{ + private readonly string _groupName; + public CrossTestBase(string groupName) + { + TestRenderHelper.BeginTest(); + _groupName = groupName; + } + + protected void RenderAndCompare(CrossControl root, [CallerMemberName] string? testName = null, double dpi = 96) + { + var dir = Path.Combine(TestRenderHelper.GetTestsDirectory(), "TestFiles", "CrossTests", _groupName); + if (!Directory.Exists(dir)) + Directory.CreateDirectory(dir); + var flavor = +#if AVALONIA_SKIA + "skia"; +#else + "d2d"; +#endif + var pathBase = Path.Combine(dir, testName); + var renderPath = pathBase + "." + flavor + ".out.png"; + var compareWith = pathBase + ".wpf.png"; + var control = new AvaloniaCrossControl(root); + TestRenderHelper.RenderToFile(control, renderPath, false, dpi); + + TestRenderHelper.AssertCompareImages(renderPath, compareWith); + } + + public void Dispose() + { + TestRenderHelper.EndTest(); + } +} diff --git a/tests/TestFiles/CrossTests/Media/RadialGradientBrush/Transform_Should_Work_As_Expected.wpf.png b/tests/TestFiles/CrossTests/Media/RadialGradientBrush/Transform_Should_Work_As_Expected.wpf.png new file mode 100644 index 0000000000000000000000000000000000000000..0c2d53389656c3443c9bfa851cd82bc0c8c6d986 GIT binary patch literal 9566 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 FngEv+!7Km( literal 0 HcmV?d00001