diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..a976b61 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,80 @@ +name: Tests + +on: + push: + branches: [ master ] + pull_request: + workflow_dispatch: + +concurrency: + group: tests-${{ github.ref }} + cancel-in-progress: true + +env: + DOTNET_NOLOGO: 'true' + DOTNET_CLI_TELEMETRY_OPTOUT: 'true' + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + # Software OpenGL ES (Mesa/llvmpipe) + SDL2 + a virtual display. The tests render offscreen + # on llvmpipe (forced by tests/NImpeller.Tests/test.runsettings) but SDL still needs a video + # driver, so we provide one with Xvfb. + - name: Install GL + SDL runtime dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + xvfb \ + libsdl2-2.0-0 \ + libegl1 libgles2 libglvnd0 \ + libegl-mesa0 libgl1-mesa-dri libglx-mesa0 + # Silk.NET.SDL dlopens "libSDL2-2.0.so", but the runtime package only ships + # "libSDL2-2.0.so.0" (the unversioned symlink lives in libsdl2-dev). Create it. + SO=$(ldconfig -p | awk '/libSDL2-2.0.so.0/{print $NF; exit}') + echo "Linking libSDL2-2.0.so -> $SO" + sudo ln -sf "$SO" "$(dirname "$SO")/libSDL2-2.0.so" + sudo ldconfig + + # Bindings (src/NImpeller/Generated/Bindings.g.cs) and the native libimpeller.so are not + # committed; download the Linux SDK at the latest Flutter engine commit and generate them. + - name: Download Impeller SDK (linux-x64) and generate bindings + run: | + chmod +x ./build.sh + ./build.sh DownloadLatestImpeller --platform linux-x64 + ./build.sh GenerateBindings --platform linux-x64 + + # Prints what GL/EGL stack the runner actually exposes (helps diagnose context failures). + - name: GL diagnostics + continue-on-error: true + env: + LIBGL_ALWAYS_SOFTWARE: '1' + GALLIUM_DRIVER: llvmpipe + __EGL_VENDOR_LIBRARY_FILENAMES: /usr/share/glvnd/egl_vendor.d/50_mesa.json + run: | + sudo apt-get install -y --no-install-recommends mesa-utils mesa-utils-extra || true + ls -l /usr/share/glvnd/egl_vendor.d/ || true + echo "== eglinfo =="; xvfb-run -a eglinfo 2>&1 | head -60 || true + echo "== glxinfo -B =="; xvfb-run -a glxinfo -B 2>&1 | head -25 || true + + - name: Test + run: xvfb-run -a dotnet test tests/NImpeller.Tests/NImpeller.Tests.csproj -c Release + + # On a golden mismatch the tests write actual/golden PNGs here for inspection. + - name: Upload golden diffs on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: golden-failures + path: tests/NImpeller.Tests/bin/Release/net8.0/TestFailures/ + if-no-files-found: ignore diff --git a/NImpeller.sln b/NImpeller.sln index a4c3682..4f48dfa 100644 --- a/NImpeller.sln +++ b/NImpeller.sln @@ -22,6 +22,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sandbox.SDL", "samples\Sand EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sandbox.Metal", "samples\Sandbox.Metal\Sandbox.Metal.csproj", "{554505AE-9958-4740-91B1-FA285716F5E5}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NImpeller.Tests", "tests\NImpeller.Tests\NImpeller.Tests.csproj", "{5CDD2905-02B5-4A31-AA07-E45D45A1A5B8}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -126,6 +128,18 @@ Global {554505AE-9958-4740-91B1-FA285716F5E5}.Release|x64.Build.0 = Release|Any CPU {554505AE-9958-4740-91B1-FA285716F5E5}.Release|x86.ActiveCfg = Release|Any CPU {554505AE-9958-4740-91B1-FA285716F5E5}.Release|x86.Build.0 = Release|Any CPU + {5CDD2905-02B5-4A31-AA07-E45D45A1A5B8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5CDD2905-02B5-4A31-AA07-E45D45A1A5B8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5CDD2905-02B5-4A31-AA07-E45D45A1A5B8}.Debug|x64.ActiveCfg = Debug|Any CPU + {5CDD2905-02B5-4A31-AA07-E45D45A1A5B8}.Debug|x64.Build.0 = Debug|Any CPU + {5CDD2905-02B5-4A31-AA07-E45D45A1A5B8}.Debug|x86.ActiveCfg = Debug|Any CPU + {5CDD2905-02B5-4A31-AA07-E45D45A1A5B8}.Debug|x86.Build.0 = Debug|Any CPU + {5CDD2905-02B5-4A31-AA07-E45D45A1A5B8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5CDD2905-02B5-4A31-AA07-E45D45A1A5B8}.Release|Any CPU.Build.0 = Release|Any CPU + {5CDD2905-02B5-4A31-AA07-E45D45A1A5B8}.Release|x64.ActiveCfg = Release|Any CPU + {5CDD2905-02B5-4A31-AA07-E45D45A1A5B8}.Release|x64.Build.0 = Release|Any CPU + {5CDD2905-02B5-4A31-AA07-E45D45A1A5B8}.Release|x86.ActiveCfg = Release|Any CPU + {5CDD2905-02B5-4A31-AA07-E45D45A1A5B8}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/README.md b/README.md index 9284d6e..29d1bef 100644 --- a/README.md +++ b/README.md @@ -79,3 +79,15 @@ mkdir -p ide #jq 'map(select(.command | startswith("vpython") or startswith("rm") or startswith("ln") | not))' ``` + +### Tests + +Tests are included in `tests/NImpeller.Tests` and can be run with + +```sh +dotnet test tests/NImpeller.Tests/NImpeller.Tests.csproj +``` + +or run `./test-headless.sh` + +At the moment, they assume you're using `llvmpipe` for a software GL context. diff --git a/test-headless.sh b/test-headless.sh new file mode 100755 index 0000000..8beacdf --- /dev/null +++ b/test-headless.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# +# Usage: +# ./test-headless.sh # one run +# ./test-headless.sh -n 5 # run the suite 5 times (catch nondeterministic failures) +# ./test-headless.sh --filter Golden # pass extra args straight through to `dotnet test` +# RUNS=5 ./test-headless.sh # same as -n 5 +# +# UPDATE_GOLDENS=1 ./test-headless.sh # regenerate baselines (honored by the tests) + +set -euo pipefail + +cd "$(dirname "$0")" + +RUNS="${RUNS:-1}" + +# Peel a leading -n/--runs off the arg list; everything else is forwarded to `dotnet test`. +if [[ "${1:-}" == "-n" || "${1:-}" == "--runs" ]]; then + RUNS="${2:?missing count after $1}" + shift 2 +fi + +if ! command -v xvfb-run >/dev/null 2>&1; then + echo "error: xvfb-run not found. Install it (Arch: xorg-server-xvfb, Debian/Ubuntu: xvfb)." >&2 + exit 1 +fi + +# Force the software renderer and the EGL path (a GLES context on X11/Xvfb must come through EGL). +export LIBGL_ALWAYS_SOFTWARE=1 +export GALLIUM_DRIVER=llvmpipe +export MESA_LOADER_DRIVER_OVERRIDE=llvmpipe +export SDL_VIDEO_X11_FORCE_EGL=1 +export __EGL_VENDOR_LIBRARY_FILENAMES=/usr/share/glvnd/egl_vendor.d/50_mesa.json +# Make RenderGate treat missing/non-software GL as a hard failure instead of a skip, like CI. +export CI=true + +status=0 +for ((i = 1; i <= RUNS; i++)); do + if [[ "$RUNS" -gt 1 ]]; then + echo "===== run $i / $RUNS =====" + fi + if ! xvfb-run -a dotnet test tests/NImpeller.Tests/NImpeller.Tests.csproj -c Release "$@"; then + status=1 + echo "----- run $i FAILED -----" >&2 + fi +done + +exit "$status" diff --git a/tests/NImpeller.Tests/AssemblyInfo.cs b/tests/NImpeller.Tests/AssemblyInfo.cs new file mode 100644 index 0000000..c730dd5 --- /dev/null +++ b/tests/NImpeller.Tests/AssemblyInfo.cs @@ -0,0 +1,5 @@ +using Xunit; + +// A GL context is thread-affine and we share a single one across the suite, so tests must not +// run in parallel. (Renders are still marshalled onto the context's own worker thread.) +[assembly: CollectionBehavior(DisableTestParallelization = true)] diff --git a/tests/NImpeller.Tests/GoldenImageTests.cs b/tests/NImpeller.Tests/GoldenImageTests.cs new file mode 100644 index 0000000..d5c282a --- /dev/null +++ b/tests/NImpeller.Tests/GoldenImageTests.cs @@ -0,0 +1,124 @@ +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using NImpeller.Tests.Headless; +using NImpeller.Tests.Scenes; +using Xunit; + +namespace NImpeller.Tests; + +/// +/// Golden-image regression tests: render a deterministic scene offscreen and compare the pixels +/// against a committed baseline. +/// +/// Generate or refresh baselines by setting UPDATE_GOLDENS=1: +/// UPDATE_GOLDENS=1 dotnet test +/// A missing baseline is written automatically and the test is skipped (so first run is green and +/// the new baseline can be reviewed before committing). +/// +[Collection(ImpellerGLCollection.Name)] +public sealed class GoldenImageTests +{ + private const int PerChannelTolerance = 8; + private const double MaxDiffFraction = 0.0001; // 0.01% of pixels may differ + + private readonly ImpellerGLFixture _gl; + + public GoldenImageTests(ImpellerGLFixture gl) => _gl = gl; + + private sealed record SceneCase(IScene Scene, int Width, int Height); + + private static readonly Dictionary Cases = BuildCases( + new SolidShapesScene(), 320, 280, + new PrimitivesScene(), 360, 280, + new StrokesScene(), 360, 300, + new TransformsScene(), 320, 300, + new ClipsScene(), 320, 300, + new BlendModesScene(), 372, 312, + new ColorFiltersScene(), 340, 190, + new MaskBlurScene(), 360, 300, + new ShadowsScene(), 340, 180, + new GroupOpacityScene(), 300, 260, + new ImageFiltersScene(), 260, 240); + + public static TheoryData SceneNames() + { + var data = new TheoryData(); + foreach (var name in Cases.Keys) + { + data.Add(name); + } + return data; + } + + [Theory] + [MemberData(nameof(SceneNames))] + public void Scene_matches_golden(string sceneName) + { + RenderGate.Require(_gl); + + var @case = Cases[sceneName]; + var actual = _gl.Render(@case.Scene, @case.Width, @case.Height); + Assert.True(actual.HasContent(), $"Scene '{sceneName}' produced an empty (all-zero) image."); + + GoldenAssert.Matches(sceneName, actual, PerChannelTolerance, MaxDiffFraction); + } + + private static Dictionary BuildCases(params object[] entries) + { + var dict = new Dictionary(); + for (int i = 0; i < entries.Length; i += 3) + { + var scene = (IScene)entries[i]; + dict[scene.TestName] = new SceneCase(scene, (int)entries[i + 1], (int)entries[i + 2]); + } + return dict; + } +} + +internal static class GoldenAssert +{ + public static void Matches(string name, RawImage actual, int perChannelTolerance, double maxDiffFraction) + { + string rawPath = Path.Combine(GoldensDir(), name + ".rimg"); + bool update = Environment.GetEnvironmentVariable("UPDATE_GOLDENS") == "1"; + + if (update || !File.Exists(rawPath)) + { + if (!update && RenderGate.IsCI) + { + Assert.Fail($"Golden baseline not found in CI: {Path.GetFullPath(rawPath)} — it must be committed."); + } + + actual.SaveRaw(rawPath); + actual.SavePng(Path.Combine(GoldensDir(), name + ".png")); + Assert.Skip(update + ? $"Baseline updated: {rawPath}" + : $"Baseline created (review before committing): {rawPath}"); + } + + var golden = RawImage.LoadRaw(rawPath); + var diff = golden.Compare(actual, perChannelTolerance); + + if (!diff.SizeMatches || diff.DiffFraction > maxDiffFraction) + { + string outDir = FailureDir(); + actual.SavePng(Path.Combine(outDir, name + ".actual.png")); + golden.SavePng(Path.Combine(outDir, name + ".golden.png")); + Assert.Fail( + $"'{name}' differs from golden: sizeMatches={diff.SizeMatches}, " + + $"diffPixels={diff.DiffPixels}/{diff.TotalPixels} ({diff.DiffFraction:P3}), " + + $"maxChannelDiff={diff.MaxChannelDiff}. Wrote actual/golden PNGs to {outDir}. " + + $"If this change is intended, regenerate with UPDATE_GOLDENS=1."); + } + } + + public static string GoldensDir([CallerFilePath] string thisFile = "") => + Path.Combine(Path.GetDirectoryName(thisFile)!, "Goldens"); + + private static string FailureDir() + { + string dir = Path.Combine(AppContext.BaseDirectory, "TestFailures"); + Directory.CreateDirectory(dir); + return dir; + } +} diff --git a/tests/NImpeller.Tests/Goldens/.gitkeep b/tests/NImpeller.Tests/Goldens/.gitkeep new file mode 100644 index 0000000..5c22b65 --- /dev/null +++ b/tests/NImpeller.Tests/Goldens/.gitkeep @@ -0,0 +1,2 @@ +# Golden baselines (.rimg + .png) live here and ARE committed. +# Generate/refresh with: UPDATE_GOLDENS=1 dotnet test diff --git a/tests/NImpeller.Tests/Goldens/blendmodes.png b/tests/NImpeller.Tests/Goldens/blendmodes.png new file mode 100644 index 0000000..029d5b0 Binary files /dev/null and b/tests/NImpeller.Tests/Goldens/blendmodes.png differ diff --git a/tests/NImpeller.Tests/Goldens/blendmodes.rimg b/tests/NImpeller.Tests/Goldens/blendmodes.rimg new file mode 100644 index 0000000..69b265d Binary files /dev/null and b/tests/NImpeller.Tests/Goldens/blendmodes.rimg differ diff --git a/tests/NImpeller.Tests/Goldens/clips.png b/tests/NImpeller.Tests/Goldens/clips.png new file mode 100644 index 0000000..0b7d187 Binary files /dev/null and b/tests/NImpeller.Tests/Goldens/clips.png differ diff --git a/tests/NImpeller.Tests/Goldens/clips.rimg b/tests/NImpeller.Tests/Goldens/clips.rimg new file mode 100644 index 0000000..5d9ccea Binary files /dev/null and b/tests/NImpeller.Tests/Goldens/clips.rimg differ diff --git a/tests/NImpeller.Tests/Goldens/colorfilters.png b/tests/NImpeller.Tests/Goldens/colorfilters.png new file mode 100644 index 0000000..d2a54bd Binary files /dev/null and b/tests/NImpeller.Tests/Goldens/colorfilters.png differ diff --git a/tests/NImpeller.Tests/Goldens/colorfilters.rimg b/tests/NImpeller.Tests/Goldens/colorfilters.rimg new file mode 100644 index 0000000..34dd135 Binary files /dev/null and b/tests/NImpeller.Tests/Goldens/colorfilters.rimg differ diff --git a/tests/NImpeller.Tests/Goldens/groupopacity.png b/tests/NImpeller.Tests/Goldens/groupopacity.png new file mode 100644 index 0000000..e0f9bbd Binary files /dev/null and b/tests/NImpeller.Tests/Goldens/groupopacity.png differ diff --git a/tests/NImpeller.Tests/Goldens/groupopacity.rimg b/tests/NImpeller.Tests/Goldens/groupopacity.rimg new file mode 100644 index 0000000..23b659c Binary files /dev/null and b/tests/NImpeller.Tests/Goldens/groupopacity.rimg differ diff --git a/tests/NImpeller.Tests/Goldens/imagefilters.png b/tests/NImpeller.Tests/Goldens/imagefilters.png new file mode 100644 index 0000000..0ce261a Binary files /dev/null and b/tests/NImpeller.Tests/Goldens/imagefilters.png differ diff --git a/tests/NImpeller.Tests/Goldens/imagefilters.rimg b/tests/NImpeller.Tests/Goldens/imagefilters.rimg new file mode 100644 index 0000000..046a80a Binary files /dev/null and b/tests/NImpeller.Tests/Goldens/imagefilters.rimg differ diff --git a/tests/NImpeller.Tests/Goldens/maskblur.png b/tests/NImpeller.Tests/Goldens/maskblur.png new file mode 100644 index 0000000..2d2a7da Binary files /dev/null and b/tests/NImpeller.Tests/Goldens/maskblur.png differ diff --git a/tests/NImpeller.Tests/Goldens/maskblur.rimg b/tests/NImpeller.Tests/Goldens/maskblur.rimg new file mode 100644 index 0000000..5cdae04 Binary files /dev/null and b/tests/NImpeller.Tests/Goldens/maskblur.rimg differ diff --git a/tests/NImpeller.Tests/Goldens/primitives.png b/tests/NImpeller.Tests/Goldens/primitives.png new file mode 100644 index 0000000..8cd9d42 Binary files /dev/null and b/tests/NImpeller.Tests/Goldens/primitives.png differ diff --git a/tests/NImpeller.Tests/Goldens/primitives.rimg b/tests/NImpeller.Tests/Goldens/primitives.rimg new file mode 100644 index 0000000..aace41b Binary files /dev/null and b/tests/NImpeller.Tests/Goldens/primitives.rimg differ diff --git a/tests/NImpeller.Tests/Goldens/shadows.png b/tests/NImpeller.Tests/Goldens/shadows.png new file mode 100644 index 0000000..f4ea867 Binary files /dev/null and b/tests/NImpeller.Tests/Goldens/shadows.png differ diff --git a/tests/NImpeller.Tests/Goldens/shadows.rimg b/tests/NImpeller.Tests/Goldens/shadows.rimg new file mode 100644 index 0000000..01c0223 Binary files /dev/null and b/tests/NImpeller.Tests/Goldens/shadows.rimg differ diff --git a/tests/NImpeller.Tests/Goldens/solidshapes.png b/tests/NImpeller.Tests/Goldens/solidshapes.png new file mode 100644 index 0000000..54295e0 Binary files /dev/null and b/tests/NImpeller.Tests/Goldens/solidshapes.png differ diff --git a/tests/NImpeller.Tests/Goldens/solidshapes.rimg b/tests/NImpeller.Tests/Goldens/solidshapes.rimg new file mode 100644 index 0000000..60df06d Binary files /dev/null and b/tests/NImpeller.Tests/Goldens/solidshapes.rimg differ diff --git a/tests/NImpeller.Tests/Goldens/strokes.png b/tests/NImpeller.Tests/Goldens/strokes.png new file mode 100644 index 0000000..999fcc6 Binary files /dev/null and b/tests/NImpeller.Tests/Goldens/strokes.png differ diff --git a/tests/NImpeller.Tests/Goldens/strokes.rimg b/tests/NImpeller.Tests/Goldens/strokes.rimg new file mode 100644 index 0000000..6a7cdd0 Binary files /dev/null and b/tests/NImpeller.Tests/Goldens/strokes.rimg differ diff --git a/tests/NImpeller.Tests/Goldens/textureinterop.png b/tests/NImpeller.Tests/Goldens/textureinterop.png new file mode 100644 index 0000000..8a46b59 Binary files /dev/null and b/tests/NImpeller.Tests/Goldens/textureinterop.png differ diff --git a/tests/NImpeller.Tests/Goldens/textureinterop.rimg b/tests/NImpeller.Tests/Goldens/textureinterop.rimg new file mode 100644 index 0000000..00ed51d Binary files /dev/null and b/tests/NImpeller.Tests/Goldens/textureinterop.rimg differ diff --git a/tests/NImpeller.Tests/Goldens/transforms.png b/tests/NImpeller.Tests/Goldens/transforms.png new file mode 100644 index 0000000..bd4905c Binary files /dev/null and b/tests/NImpeller.Tests/Goldens/transforms.png differ diff --git a/tests/NImpeller.Tests/Goldens/transforms.rimg b/tests/NImpeller.Tests/Goldens/transforms.rimg new file mode 100644 index 0000000..7d8b7ff Binary files /dev/null and b/tests/NImpeller.Tests/Goldens/transforms.rimg differ diff --git a/tests/NImpeller.Tests/Headless/HeadlessImpellerContext.cs b/tests/NImpeller.Tests/Headless/HeadlessImpellerContext.cs new file mode 100644 index 0000000..a5da4fa --- /dev/null +++ b/tests/NImpeller.Tests/Headless/HeadlessImpellerContext.cs @@ -0,0 +1,301 @@ +using System.Collections.Concurrent; +using NImpeller; +using NImpeller.Tests.Scenes; +using Silk.NET.Core.Contexts; +using Silk.NET.OpenGLES; +using Silk.NET.SDL; +using Thread = System.Threading.Thread; +using PixelFormat = Silk.NET.OpenGLES.PixelFormat; +using PixelType = Silk.NET.OpenGLES.PixelType; + +namespace NImpeller.Tests.Headless; + +internal sealed unsafe class HeadlessImpellerContext : IDisposable +{ + private readonly BlockingCollection _queue = new(); + private readonly Thread _thread; + + private Sdl _sdl = null!; + private GL _gl = null!; + private Window* _window; + private void* _glContext; + private ImpellerContext _impeller = null!; + + public bool Available { get; private set; } + + public string? Error { get; private set; } + + public string? RendererName { get; private set; } + + public bool IsSoftwareRenderer + { + get + { + var r = RendererName?.ToLowerInvariant() ?? ""; + return r.Contains("llvmpipe") || r.Contains("softpipe") || r.Contains("swrast") || r.Contains("software"); + } + } + + public HeadlessImpellerContext() + { + _thread = new Thread(ThreadMain) { IsBackground = true, Name = "Impeller-GL" }; + using var ready = new ManualResetEventSlim(false); + _thread.Start(ready); + ready.Wait(); + } + + private void ThreadMain(object? arg) + { + var ready = (ManualResetEventSlim)arg!; + try + { + InitializeGL(); + Available = true; + } + catch (Exception ex) + { + Error = ex.Message; + Available = false; + } + finally + { + ready.Set(); + } + + if (!Available) + { + return; + } + + foreach (var action in _queue.GetConsumingEnumerable()) + { + action(); + } + + TeardownGL(); + } + + private void InitializeGL() + { + _sdl = Sdl.GetApi(); + if (_sdl.Init(Sdl.InitVideo) < 0) + { + throw new InvalidOperationException($"SDL_Init(VIDEO) failed: {_sdl.GetErrorS()}"); + } + + // GLES 3.0. + _sdl.GLSetAttribute(GLattr.ContextMajorVersion, 3); + _sdl.GLSetAttribute(GLattr.ContextMinorVersion, 0); + _sdl.GLSetAttribute(GLattr.ContextProfileMask, (int)GLprofile.ES); + + _window = _sdl.CreateWindow( + "NImpeller.Tests", + Sdl.WindowposUndefined, + Sdl.WindowposUndefined, + 16, + 16, + (uint)(WindowFlags.Hidden | WindowFlags.Opengl)); + if (_window == null) + { + throw new InvalidOperationException($"SDL_CreateWindow failed: {_sdl.GetErrorS()}"); + } + + _glContext = _sdl.GLCreateContext(_window); + if (_glContext == null) + { + throw new InvalidOperationException($"SDL_GL_CreateContext failed: {_sdl.GetErrorS()}"); + } + + _sdl.GLMakeCurrent(_window, _glContext); + _gl = GL.GetApi(new LamdaNativeContext(s => (IntPtr)_sdl.GLGetProcAddress(s))); + RendererName = _gl.GetStringS(StringName.Renderer); + + _impeller = ImpellerContext.CreateOpenGLESNew(name => (IntPtr)_sdl.GLGetProcAddress(name)) + ?? throw new InvalidOperationException("ImpellerContext.CreateOpenGLESNew returned null"); + } + + private void TeardownGL() + { + try { _impeller?.Dispose(); } catch { /* best effort */ } + if (_glContext != null) { _sdl.GLDeleteContext(_glContext); } + if (_window != null) { _sdl.DestroyWindow(_window); } + _sdl?.Quit(); + } + + public byte[] Render(IScene scene, int width, int height) => + Invoke(() => RenderToFbo(width, height, + builder => scene.Render(_impeller, builder, new SceneParameters { Width = width, Height = height }))); + + public TextureInteropResult RenderTextureInterop(int width, int height) => + Invoke(() => RenderTextureInteropCore(width, height)); + + private byte[] RenderToFbo(int width, int height, Action record) + { + uint fbo = _gl.GenFramebuffer(); + uint tex = _gl.GenTexture(); + try + { + _gl.BindFramebuffer(FramebufferTarget.Framebuffer, fbo); + _gl.BindTexture(TextureTarget.Texture2D, tex); + _gl.TexImage2D(TextureTarget.Texture2D, 0, (int)InternalFormat.Rgba8, + (uint)width, (uint)height, 0, PixelFormat.Rgba, PixelType.UnsignedByte, null); + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)GLEnum.Nearest); + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)GLEnum.Nearest); + _gl.FramebufferTexture2D(FramebufferTarget.Framebuffer, FramebufferAttachment.ColorAttachment0, + TextureTarget.Texture2D, tex, 0); + + var status = _gl.CheckFramebufferStatus(FramebufferTarget.Framebuffer); + if (status != GLEnum.FramebufferComplete) + { + throw new InvalidOperationException($"Offscreen framebuffer incomplete: {status}"); + } + + using var surface = _impeller.SurfaceCreateWrappedFBONew((ulong)fbo, + ImpellerPixelFormat.kImpellerPixelFormatRGBA8888, new ImpellerISize(width, height)) + ?? throw new InvalidOperationException("SurfaceCreateWrappedFBONew returned null"); + + ImpellerDisplayList displayList; + using (var builder = ImpellerDisplayListBuilder.New(new ImpellerRect(0, 0, width, height))!) + { + record(builder); + displayList = builder.CreateDisplayListNew()!; + } + + using (displayList) + { + surface.DrawDisplayList(displayList); + } + + // Make sure Impeller's GL commands have completed before we read the texels back. + _gl.BindFramebuffer(FramebufferTarget.Framebuffer, fbo); + _gl.Finish(); + + var pixels = new byte[width * height * 4]; + fixed (byte* p = pixels) + { + _gl.ReadPixels(0, 0, (uint)width, (uint)height, PixelFormat.Rgba, PixelType.UnsignedByte, p); + } + + return FlipVertically(pixels, width, height); + } + finally + { + _gl.BindFramebuffer(FramebufferTarget.Framebuffer, 0); + _gl.DeleteFramebuffer(fbo); + _gl.DeleteTexture(tex); + } + } + + private TextureInteropResult RenderTextureInteropCore(int width, int height) + { + byte[] checker = + { + 255, 255, 255, 255, 0, 0, 0, 255, + 0, 0, 0, 255, 255, 255, 255, 255, + }; + + // impeller.h documents the handle param as "transfer-in ownership": Impeller adopts glTex + // and deletes it when the ImpellerTexture is released. Do NOT glDeleteTexture(glTex) here — + // that double-frees the name, and because Impeller defers GL deletes, it later kills a name + // recycled for another render's target, blanking that scene (e.g. groupopacity → empty). + uint glTex = _gl.GenTexture(); + _gl.BindTexture(TextureTarget.Texture2D, glTex); + fixed (byte* p = checker) + { + _gl.TexImage2D(TextureTarget.Texture2D, 0, (int)InternalFormat.Rgba8, + 2, 2, 0, PixelFormat.Rgba, PixelType.UnsignedByte, p); + } + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)GLEnum.Nearest); + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)GLEnum.Nearest); + + var descriptor = new ImpellerTextureDescriptor + { + Pixel_format = ImpellerPixelFormat.kImpellerPixelFormatRGBA8888, + Size = new ImpellerISize(2, 2), + Mip_count = 1, + }; + + ulong reportedHandle; + byte[] pixels; + using (var texture = _impeller.TextureCreateWithOpenGLTextureHandleNew(descriptor, glTex) + ?? throw new InvalidOperationException("TextureCreateWithOpenGLTextureHandleNew returned null")) + { + reportedHandle = texture.GetOpenGLHandle(); + + pixels = RenderToFbo(width, height, builder => + { + using var background = ImpellerPaint.New()!; + background.SetColor(ImpellerColor.FromRgb(30, 30, 30)); + builder.DrawPaint(background); + + using var paint = ImpellerPaint.New()!; + builder.DrawTextureRect( + texture, + new ImpellerRect(0, 0, 2, 2), + new ImpellerRect(20, 20, width - 40, height - 40), + ImpellerTextureSampling.kImpellerTextureSamplingNearestNeighbor, + paint); + }); + } + + return new TextureInteropResult(pixels, glTex, reportedHandle); + } + + // glReadPixels returns rows bottom-to-top; images are top-to-bottom. + private static byte[] FlipVertically(byte[] src, int width, int height) + { + int stride = width * 4; + var dst = new byte[src.Length]; + for (int y = 0; y < height; y++) + { + Array.Copy(src, (height - 1 - y) * stride, dst, y * stride, stride); + } + return dst; + } + + // Avoid hangs by enforcing a timeout. If it doesn't complete, bail and fail. + private static readonly TimeSpan InvokeTimeout = TimeSpan.FromSeconds(30); + + private T Invoke(Func fn, [System.Runtime.CompilerServices.CallerMemberName] string op = "") + { + if (!Available) + { + throw new InvalidOperationException(Error ?? "Headless GL context is unavailable."); + } + + Exception? error = null; + T result = default!; + var done = new ManualResetEventSlim(false); + _queue.Add(() => + { + try { result = fn(); } + catch (Exception ex) { error = ex; } + finally { done.Set(); } + }); + + if (!done.Wait(InvokeTimeout)) + { + throw new TimeoutException( + $"Headless GL operation '{op}' did not complete within {InvokeTimeout.TotalSeconds:0}s " + + "(a native call likely deadlocked)."); + } + + done.Dispose(); + + if (error != null) + { + throw new InvalidOperationException("Headless render failed: " + error.Message, error); + } + return result; + } + + public void Dispose() + { + _queue.CompleteAdding(); + if (_thread.IsAlive) + { + _thread.Join(TimeSpan.FromSeconds(10)); + } + _queue.Dispose(); + } +} diff --git a/tests/NImpeller.Tests/Headless/ImpellerGLFixture.cs b/tests/NImpeller.Tests/Headless/ImpellerGLFixture.cs new file mode 100644 index 0000000..356e12b --- /dev/null +++ b/tests/NImpeller.Tests/Headless/ImpellerGLFixture.cs @@ -0,0 +1,36 @@ +using NImpeller.Tests.Scenes; +using Xunit; + +namespace NImpeller.Tests.Headless; + +public sealed class ImpellerGLFixture : IDisposable +{ + private readonly HeadlessImpellerContext _context = new(); + + public bool Available => _context.Available; + + public string? Error => _context.Error; + + public string? RendererName => _context.RendererName; + + public bool IsSoftwareRenderer => _context.IsSoftwareRenderer; + + public RawImage Render(IScene scene, int width, int height) => + new(width, height, _context.Render(scene, width, height)); + + public (RawImage Image, ulong CreatedHandle, ulong ReportedHandle) RenderTextureInterop(int width, int height) + { + var result = _context.RenderTextureInterop(width, height); + return (new RawImage(width, height, result.Pixels), result.CreatedHandle, result.ReportedHandle); + } + + public void Dispose() => _context.Dispose(); +} + +internal readonly record struct TextureInteropResult(byte[] Pixels, ulong CreatedHandle, ulong ReportedHandle); + +[CollectionDefinition(Name)] +public sealed class ImpellerGLCollection : ICollectionFixture +{ + public const string Name = "Impeller GL"; +} diff --git a/tests/NImpeller.Tests/Headless/RawImage.cs b/tests/NImpeller.Tests/Headless/RawImage.cs new file mode 100644 index 0000000..6b4f9ee --- /dev/null +++ b/tests/NImpeller.Tests/Headless/RawImage.cs @@ -0,0 +1,215 @@ +using System.IO.Compression; + +namespace NImpeller.Tests.Headless; + +/// +/// Clankered Image format for storing RGBA pixel data in a compact, lossless way. Used for baseline images in headless tests. +/// Also outputs to PNG. +/// +public sealed class RawImage +{ + private const uint RawMagic = 0x474D4952; // "RIMG" + + public int Width { get; } + public int Height { get; } + + /// Row-major RGBA, 4 bytes/pixel, top-left origin. + public byte[] Pixels { get; } + + public RawImage(int width, int height, byte[] pixels) + { + if (pixels.Length != width * height * 4) + { + throw new ArgumentException($"Expected {width * height * 4} bytes, got {pixels.Length}."); + } + Width = width; + Height = height; + Pixels = pixels; + } + + /// True if any pixel is not fully transparent black — i.e. something was drawn. + public bool HasContent() + { + foreach (var b in Pixels) + { + if (b != 0) return true; + } + return false; + } + + public ImageDiff Compare(RawImage other, int perChannelTolerance) + { + if (Width != other.Width || Height != other.Height) + { + return new ImageDiff(false, long.MaxValue, 255, (long)Width * Height); + } + + long differing = 0; + int maxChannelDiff = 0; + for (int i = 0; i < Pixels.Length; i += 4) + { + bool pixelDiffers = false; + for (int c = 0; c < 4; c++) + { + int d = Math.Abs(Pixels[i + c] - other.Pixels[i + c]); + if (d > maxChannelDiff) maxChannelDiff = d; + if (d > perChannelTolerance) pixelDiffers = true; + } + if (pixelDiffers) differing++; + } + + return new ImageDiff(true, differing, maxChannelDiff, (long)Width * Height); + } + + public void SaveRaw(string path) + { + Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!); + using var fs = File.Create(path); + using var w = new BinaryWriter(fs); + w.Write(RawMagic); + w.Write(Width); + w.Write(Height); + // Pixels are deflate-compressed so committed baselines stay small. + using var deflate = new DeflateStream(fs, CompressionLevel.Optimal); + deflate.Write(Pixels, 0, Pixels.Length); + } + + public static RawImage LoadRaw(string path) + { + using var fs = File.OpenRead(path); + using var r = new BinaryReader(fs); + if (r.ReadUInt32() != RawMagic) + { + throw new InvalidDataException($"{path} is not a RIMG file."); + } + int w = r.ReadInt32(); + int h = r.ReadInt32(); + var px = new byte[w * h * 4]; + using var deflate = new DeflateStream(fs, CompressionMode.Decompress); + int read = 0; + while (read < px.Length) + { + int n = deflate.Read(px, read, px.Length - read); + if (n == 0) break; + read += n; + } + if (read != px.Length) + { + throw new InvalidDataException($"{path}: expected {px.Length} pixel bytes, got {read}."); + } + return new RawImage(w, h, px); + } + + /// Write a standard 8-bit RGBA PNG. Used only for eyeballing results; never read back. + public void SavePng(string path) + { + Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!); + using var fs = File.Create(path); + Span sig = stackalloc byte[] { 137, 80, 78, 71, 13, 10, 26, 10 }; + fs.Write(sig); + + // IHDR + var ihdr = new byte[13]; + WriteBigEndian(ihdr, 0, (uint)Width); + WriteBigEndian(ihdr, 4, (uint)Height); + ihdr[8] = 8; // bit depth + ihdr[9] = 6; // color type: RGBA + WriteChunk(fs, "IHDR", ihdr); + + // IDAT: zlib-wrapped deflate of filtered scanlines (filter byte 0 per row). + int stride = Width * 4; + var raw = new byte[(stride + 1) * Height]; + for (int y = 0; y < Height; y++) + { + raw[y * (stride + 1)] = 0; // no filter + Array.Copy(Pixels, y * stride, raw, y * (stride + 1) + 1, stride); + } + WriteChunk(fs, "IDAT", ZlibCompress(raw)); + + WriteChunk(fs, "IEND", Array.Empty()); + } + + private static byte[] ZlibCompress(byte[] data) + { + using var ms = new MemoryStream(); + ms.WriteByte(0x78); + ms.WriteByte(0x01); // zlib header: deflate, 32K window, no dict + using (var deflate = new DeflateStream(ms, CompressionLevel.Optimal, leaveOpen: true)) + { + deflate.Write(data, 0, data.Length); + } + var adler = Adler32(data); + ms.WriteByte((byte)(adler >> 24)); + ms.WriteByte((byte)(adler >> 16)); + ms.WriteByte((byte)(adler >> 8)); + ms.WriteByte((byte)adler); + return ms.ToArray(); + } + + private static void WriteChunk(Stream s, string type, byte[] data) + { + Span len = stackalloc byte[4]; + WriteBigEndian(len, 0, (uint)data.Length); + s.Write(len); + + var typeBytes = new byte[4]; + for (int i = 0; i < 4; i++) typeBytes[i] = (byte)type[i]; + s.Write(typeBytes); + s.Write(data); + + uint crc = Crc32(typeBytes, data); + Span crcBytes = stackalloc byte[4]; + WriteBigEndian(crcBytes, 0, crc); + s.Write(crcBytes); + } + + private static void WriteBigEndian(Span dst, int offset, uint value) + { + dst[offset + 0] = (byte)(value >> 24); + dst[offset + 1] = (byte)(value >> 16); + dst[offset + 2] = (byte)(value >> 8); + dst[offset + 3] = (byte)value; + } + + private static uint Adler32(byte[] data) + { + const uint mod = 65521; + uint a = 1, b = 0; + foreach (var x in data) + { + a = (a + x) % mod; + b = (b + a) % mod; + } + return (b << 16) | a; + } + + private static uint Crc32(byte[] type, byte[] data) + { + uint crc = 0xFFFFFFFF; + crc = Crc32Update(crc, type); + crc = Crc32Update(crc, data); + return crc ^ 0xFFFFFFFF; + } + + private static uint Crc32Update(uint crc, byte[] data) + { + foreach (var b in data) + { + crc ^= b; + for (int k = 0; k < 8; k++) + { + crc = (crc & 1) != 0 ? (crc >> 1) ^ 0xEDB88320 : crc >> 1; + } + } + return crc; + } +} + +/// False when dimensions differ; other fields are meaningless then. +/// Pixels whose per-channel delta exceeded the tolerance. +/// Largest single-channel delta seen (0-255). +/// Total pixel count. +public readonly record struct ImageDiff(bool SizeMatches, long DiffPixels, int MaxChannelDiff, long TotalPixels) +{ + public double DiffFraction => TotalPixels == 0 ? 0 : (double)DiffPixels / TotalPixels; +} diff --git a/tests/NImpeller.Tests/Impeller.targets b/tests/NImpeller.Tests/Impeller.targets new file mode 100644 index 0000000..0e0fe9a --- /dev/null +++ b/tests/NImpeller.Tests/Impeller.targets @@ -0,0 +1,87 @@ + + + WINDOWS + LINUX + MACOS + $([MSBuild]::IsOSPlatform('Windows')) + $([MSBuild]::IsOSPlatform('Linux')) + $([MSBuild]::IsOSPlatform('OSX')) + $(MSBuildThisFileDirectory)..\..\external\impeller_sdk + false + true + false + true + + + + + true + true + PreserveNewest + impeller.dll + + + + + + true + true + PreserveNewest + impeller.dll + + + + + + true + true + PreserveNewest + impeller.dll + + + + + + true + true + PreserveNewest + impeller.dll + + + + + + true + true + PreserveNewest + libimpeller.dylib + + + + + + true + true + PreserveNewest + libimpeller.dylib + + + + + + true + true + PreserveNewest + libimpeller.so + + + + + + true + true + PreserveNewest + libimpeller.so + + + \ No newline at end of file diff --git a/tests/NImpeller.Tests/NImpeller.Tests.csproj b/tests/NImpeller.Tests/NImpeller.Tests.csproj new file mode 100644 index 0000000..396af92 --- /dev/null +++ b/tests/NImpeller.Tests/NImpeller.Tests.csproj @@ -0,0 +1,46 @@ + + + + + + + net8.0 + enable + enable + true + NImpeller.Tests + + + Exe + false + true + + + $(MSBuildThisFileDirectory)test.runsettings + + + + + + + + + + + + + + + + + + + + + + Never + + + + diff --git a/tests/NImpeller.Tests/RenderGate.cs b/tests/NImpeller.Tests/RenderGate.cs new file mode 100644 index 0000000..6a7ff88 --- /dev/null +++ b/tests/NImpeller.Tests/RenderGate.cs @@ -0,0 +1,32 @@ +using NImpeller.Tests.Headless; +using Xunit; + +namespace NImpeller.Tests; + +/// +/// Decides whether a rendering test should run, skip, or fail when the headless GL context isn't +/// usable. +/// +internal static class RenderGate +{ + // GitHub Actions and most CI providers set CI=true. + public static bool IsCI => + string.Equals(Environment.GetEnvironmentVariable("CI"), "true", StringComparison.OrdinalIgnoreCase); + + public static void Require(ImpellerGLFixture gl) + { + string reason = $"No headless GL context. Reason: {gl.Error}"; + if (IsCI) + { + Assert.True(gl.Available, reason); + } + else + { + Assert.SkipUnless(gl.Available, reason); + } + + Assert.True(gl.IsSoftwareRenderer, + $"Expected a software renderer (llvmpipe) but got '{gl.RendererName}'. " + + "Ensure test.runsettings is applied (it is by default via dotnet test). See README."); + } +} diff --git a/tests/NImpeller.Tests/Scenes/GoldenScenes.cs b/tests/NImpeller.Tests/Scenes/GoldenScenes.cs new file mode 100644 index 0000000..bef7397 --- /dev/null +++ b/tests/NImpeller.Tests/Scenes/GoldenScenes.cs @@ -0,0 +1,443 @@ +using System.Numerics; +using NImpeller; + +namespace NImpeller.Tests.Scenes; + +internal static class Draw +{ + public static ImpellerPoint P(float x, float y) => new() { X = x, Y = y }; + + // ImpellerRect's only ctor takes ints; this builds one from floats for computed coordinates. + // TODO: ImpellerRect should have a float ctor. + public static ImpellerRect Rect(float x, float y, float w, float h) => + new() { X = x, Y = y, Width = w, Height = h }; + + public static ImpellerRoundingRadii UniformRadii(float r) + { + var p = P(r, r); + return new ImpellerRoundingRadii { Top_left = p, Top_right = p, Bottom_left = p, Bottom_right = p }; + } + + public static void Background(ImpellerDisplayListBuilder b, ImpellerColor color) + { + using var paint = ImpellerPaint.New()!; + paint.SetColor(color); + b.DrawPaint(paint); + } +} + +/// Every primitive draw op, filled and stroked. Mirrors aiks_dl primitive shape tests. +public sealed class PrimitivesScene : IScene +{ + public string TestName => "primitives"; + public string Description => "All primitive draw ops, filled and stroked."; + + public void Render(ImpellerContext context, ImpellerDisplayListBuilder b, SceneParameters p) + { + Draw.Background(b, ImpellerColor.FromRgb(18, 20, 28)); + using var paint = ImpellerPaint.New()!; + + paint.SetColor(ImpellerColor.FromRgb(220, 70, 70)); + b.DrawRect(new ImpellerRect(20, 20, 90, 60), paint); + + paint.SetColor(ImpellerColor.FromRgb(70, 200, 120)); + b.DrawOval(new ImpellerRect(130, 20, 90, 60), paint); + + paint.SetColor(ImpellerColor.FromRgb(80, 140, 240)); + b.DrawRoundedRect(new ImpellerRect(240, 20, 100, 60), Draw.UniformRadii(16), paint); + + paint.SetColor(ImpellerColor.FromRgb(240, 200, 70)); + b.DrawRoundedRectDifference( + new ImpellerRect(20, 100, 100, 80), Draw.UniformRadii(18), + new ImpellerRect(40, 120, 60, 40), Draw.UniformRadii(8), + paint); + + // Stroked line + dashed line. + paint.SetColor(ImpellerColor.FromRgb(230, 230, 230)); + paint.SetDrawStyle(ImpellerDrawStyle.kImpellerDrawStyleStroke); + paint.SetStrokeWidth(5); + b.DrawLine(Draw.P(150, 110), Draw.P(330, 110), paint); + b.DrawDashedLine(Draw.P(150, 140), Draw.P(330, 140), 14, 8, paint); + + // Filled path (triangle). + paint.SetDrawStyle(ImpellerDrawStyle.kImpellerDrawStyleFill); + paint.SetColor(ImpellerColor.FromRgb(200, 120, 230)); + using var pb = ImpellerPathBuilder.New()!; + pb.MoveTo(Draw.P(180, 250)); + pb.LineTo(Draw.P(240, 180)); + pb.LineTo(Draw.P(300, 250)); + pb.Close(); + using var path = pb.TakePathNew(ImpellerFillType.kImpellerFillTypeNonZero)!; + b.DrawPath(path, paint); + } +} + +/// Stroke caps × joins grid. Mirrors aiks_dl_path StrokeCapsAndJoins. +public sealed class StrokesScene : IScene +{ + public string Name => "Strokes"; + public string TestName => "strokes"; + public string Description => "Stroke caps and joins across a grid."; + + public void Render(ImpellerContext context, ImpellerDisplayListBuilder b, SceneParameters p) + { + Draw.Background(b, ImpellerColor.FromRgb(22, 24, 30)); + + var caps = new[] + { + ImpellerStrokeCap.kImpellerStrokeCapButt, + ImpellerStrokeCap.kImpellerStrokeCapRound, + ImpellerStrokeCap.kImpellerStrokeCapSquare, + }; + var joins = new[] + { + ImpellerStrokeJoin.kImpellerStrokeJoinMiter, + ImpellerStrokeJoin.kImpellerStrokeJoinRound, + ImpellerStrokeJoin.kImpellerStrokeJoinBevel, + }; + + using var paint = ImpellerPaint.New()!; + paint.SetColor(ImpellerColor.FromRgb(120, 200, 255)); + paint.SetDrawStyle(ImpellerDrawStyle.kImpellerDrawStyleStroke); + paint.SetStrokeWidth(12); + + for (int ci = 0; ci < caps.Length; ci++) + { + for (int ji = 0; ji < joins.Length; ji++) + { + paint.SetStrokeCap(caps[ci]); + paint.SetStrokeJoin(joins[ji]); + + float ox = 30 + ci * 110; + float oy = 30 + ji * 90; + + // An open "V" shows both caps (the open ends) and a join (the corner). + using var pb = ImpellerPathBuilder.New()!; + pb.MoveTo(Draw.P(ox, oy)); + pb.LineTo(Draw.P(ox + 35, oy + 50)); + pb.LineTo(Draw.P(ox + 70, oy)); + using var path = pb.TakePathNew(ImpellerFillType.kImpellerFillTypeNonZero)!; + b.DrawPath(path, paint); + } + } + } +} + +/// Nested transforms (translate/scale/rotate/Transform) with save/restore. +public sealed class TransformsScene : IScene +{ + public string Name => "Transforms"; + public string TestName => "transforms"; + public string Description => "Translate/scale/rotate/matrix transforms with save/restore nesting."; + + public void Render(ImpellerContext context, ImpellerDisplayListBuilder b, SceneParameters p) + { + Draw.Background(b, ImpellerColor.FromRgb(16, 18, 24)); + using var paint = ImpellerPaint.New()!; + + b.Save(); + b.Translate(90, 90); + b.Rotate(25); + paint.SetColor(ImpellerColor.FromRgb(220, 80, 80)); + b.DrawRect(new ImpellerRect(-40, -40, 80, 80), paint); + b.Restore(); + + b.Save(); + b.Translate(230, 90); + b.Scale(1.6f, 0.7f); + paint.SetColor(ImpellerColor.FromRgb(80, 210, 130)); + b.DrawRect(new ImpellerRect(-40, -40, 80, 80), paint); + b.Restore(); + + b.Save(); + b.SetTransform(Matrix4x4.CreateRotationZ(0.5f) * Matrix4x4.CreateTranslation(160, 220, 0)); + paint.SetColor(ImpellerColor.FromRgb(90, 150, 240)); + b.DrawOval(new ImpellerRect(-50, -30, 100, 60), paint); + b.Restore(); + } +} + +/// Clip ops (rect/oval/roundedRect/path), intersect vs difference, nested. +public sealed class ClipsScene : IScene +{ + public string Name => "Clips"; + public string TestName => "clips"; + public string Description => "Rect/oval/rounded/difference clips applied to a fill."; + + public void Render(ImpellerContext context, ImpellerDisplayListBuilder b, SceneParameters p) + { + Draw.Background(b, ImpellerColor.FromRgb(20, 22, 28)); + using var fill = ImpellerPaint.New()!; + fill.SetColor(ImpellerColor.FromRgb(230, 150, 60)); + + b.Save(); + b.ClipRect(new ImpellerRect(20, 20, 130, 130), ImpellerClipOperation.kImpellerClipOperationIntersect); + b.DrawPaint(fill); + b.Restore(); + + b.Save(); + b.ClipOval(new ImpellerRect(170, 20, 130, 130), ImpellerClipOperation.kImpellerClipOperationIntersect); + fill.SetColor(ImpellerColor.FromRgb(90, 200, 210)); + b.DrawPaint(fill); + b.Restore(); + + // Difference clip: a rect with an oval punched out (a ring/frame). + b.Save(); + b.ClipRect(new ImpellerRect(20, 170, 130, 110), ImpellerClipOperation.kImpellerClipOperationIntersect); + b.ClipOval(new ImpellerRect(50, 190, 70, 70), ImpellerClipOperation.kImpellerClipOperationDifference); + fill.SetColor(ImpellerColor.FromRgb(200, 110, 220)); + b.DrawPaint(fill); + b.Restore(); + + b.Save(); + b.ClipRoundedRect(new ImpellerRect(170, 170, 130, 110), Draw.UniformRadii(28), + ImpellerClipOperation.kImpellerClipOperationIntersect); + fill.SetColor(ImpellerColor.FromRgb(150, 210, 90)); + b.DrawPaint(fill); + b.Restore(); + } +} + +// NOTE on offscreen-layer ops (group opacity, image filters, advanced blend modes): these render +// correctly, but on the NVIDIA proprietary driver the GL context SIGSEGVs at *teardown* after +// using them (a driver bug — the same code runs cleanly on Mesa/llvmpipe). The golden tests +// therefore gate on a software renderer (see GoldenImageTests / README), and run everything there. + +/// A grid of all 29 blend modes: a red oval drawn over a blue rect per mode. +public sealed class BlendModesScene : IScene +{ + public string Name => "Blend Modes"; + public string TestName => "blendmodes"; + public string Description => "All 29 blend modes in a grid (src oval over dst rect)."; + + private static readonly ImpellerBlendMode[] Modes = + (ImpellerBlendMode[])System.Enum.GetValues(typeof(ImpellerBlendMode)); + + public void Render(ImpellerContext context, ImpellerDisplayListBuilder b, SceneParameters p) + { + Draw.Background(b, ImpellerColor.FromRgb(40, 40, 48)); + + using var dst = ImpellerPaint.New()!; + using var src = ImpellerPaint.New()!; + const int cols = 6, cell = 60; + + for (int i = 0; i < Modes.Length; i++) + { + int col = i % cols, row = i / cols; + float x = 6 + col * cell, y = 6 + row * cell; + + dst.SetColor(ImpellerColor.FromArgb(255, 60, 120, 220)); + dst.SetBlendMode(ImpellerBlendMode.kImpellerBlendModeSourceOver); + b.DrawRect(Draw.Rect(x, y, 40, 40), dst); + + src.SetColor(ImpellerColor.FromArgb(220, 230, 90, 60)); + src.SetBlendMode(Modes[i]); + b.DrawOval(Draw.Rect(x + 12, y + 12, 36, 36), src); + } + } +} + +/// Blend color filter and a color-matrix (invert) color filter. +public sealed class ColorFiltersScene : IScene +{ + public string Name => "Color Filters"; + public string TestName => "colorfilters"; + public string Description => "Blend color filter and an invert color-matrix filter."; + + public void Render(ImpellerContext context, ImpellerDisplayListBuilder b, SceneParameters p) + { + Draw.Background(b, ImpellerColor.FromRgb(20, 22, 30)); + + using (var blend = ImpellerColorFilter.CreateBlendNew( + ImpellerColor.FromArgb(180, 0, 140, 255), ImpellerBlendMode.kImpellerBlendModeModulate)!) + { + using var paint = ImpellerPaint.New()!; + paint.SetColor(ImpellerColor.FromRgb(220, 200, 80)); + paint.SetColorFilter(blend); + b.DrawRect(new ImpellerRect(30, 40, 130, 110), paint); + } + + // Invert color matrix: negate RGB (bias +1), keep alpha. + var invert = new ImpellerColorMatrix(); + float[] values = + { + -1, 0, 0, 0, 1, + 0, -1, 0, 0, 1, + 0, 0, -1, 0, 1, + 0, 0, 0, 1, 0, + }; + unsafe + { + for (int i = 0; i < 20; i++) invert.m[i] = values[i]; + } + using (var matrixFilter = ImpellerColorFilter.CreateColorMatrixNew(invert)!) + { + using var paint = ImpellerPaint.New()!; + paint.SetColor(ImpellerColor.FromRgb(60, 200, 130)); + paint.SetColorFilter(matrixFilter); + b.DrawOval(new ImpellerRect(190, 40, 120, 110), paint); + } + } +} + +/// Mask-filter blur in all four blur styles. +public sealed class MaskBlurScene : IScene +{ + public string Name => "Mask Blur"; + public string TestName => "maskblur"; + public string Description => "Mask-filter blur in Normal/Solid/Outer/Inner styles."; + + public void Render(ImpellerContext context, ImpellerDisplayListBuilder b, SceneParameters p) + { + Draw.Background(b, ImpellerColor.FromRgb(28, 28, 34)); + + var styles = new[] + { + ImpellerBlurStyle.kImpellerBlurStyleNormal, + ImpellerBlurStyle.kImpellerBlurStyleSolid, + ImpellerBlurStyle.kImpellerBlurStyleOuter, + ImpellerBlurStyle.kImpellerBlurStyleInner, + }; + + for (int i = 0; i < styles.Length; i++) + { + using var mask = ImpellerMaskFilter.CreateBlurNew(styles[i], 8)!; + using var paint = ImpellerPaint.New()!; + paint.SetColor(ImpellerColor.FromRgb(240, 230, 120)); + paint.SetMaskFilter(mask); + float x = 40 + (i % 2) * 160; + float y = 40 + (i / 2) * 130; + b.DrawOval(Draw.Rect(x, y, 90, 90), paint); + } + } +} + +/// DrawShadow under an opaque occluder. Mirrors aiks_dl_shadow / CanRenderShadows. +public sealed class ShadowsScene : IScene +{ + public string Name => "Shadows"; + public string TestName => "shadows"; + public string Description => "Elevation shadows cast by an oval and a rounded rect."; + + public void Render(ImpellerContext context, ImpellerDisplayListBuilder b, SceneParameters p) + { + Draw.Background(b, ImpellerColor.FromRgb(210, 212, 220)); + var shadowColor = ImpellerColor.FromArgb(160, 0, 0, 0); + + using (var pb = ImpellerPathBuilder.New()!) + { + pb.AddOval(new ImpellerRect(40, 40, 110, 110)); + using var path = pb.TakePathNew(ImpellerFillType.kImpellerFillTypeNonZero)!; + b.DrawShadow(path, shadowColor, 8f, 0, 1f); + using var paint = ImpellerPaint.New()!; + paint.SetColor(ImpellerColor.FromRgb(220, 90, 90)); + b.DrawPath(path, paint); + } + + using (var pb = ImpellerPathBuilder.New()!) + { + pb.AddRoundedRect(new ImpellerRect(190, 50, 120, 90), Draw.UniformRadii(20)); + using var path = pb.TakePathNew(ImpellerFillType.kImpellerFillTypeNonZero)!; + b.DrawShadow(path, shadowColor, 14f, 0, 1f); + using var paint = ImpellerPaint.New()!; + paint.SetColor(ImpellerColor.FromRgb(90, 140, 230)); + b.DrawPath(path, paint); + } + } +} + +/// Group opacity: a nested display list composited at 50% alpha (needs an offscreen layer). +public sealed class GroupOpacityScene : IScene +{ + public string Name => "Group Opacity"; + public string TestName => "groupopacity"; + public string Description => "Two overlapping rects composited as a group at 50% opacity."; + + public void Render(ImpellerContext context, ImpellerDisplayListBuilder b, SceneParameters p) + { + Draw.Background(b, ImpellerColor.FromRgb(24, 26, 34)); + + ImpellerDisplayList child; + using (var cb = ImpellerDisplayListBuilder.New(new ImpellerRect(0, 0, p.Width, p.Height))!) + { + using var cp = ImpellerPaint.New()!; + cp.SetColor(ImpellerColor.FromRgb(230, 80, 80)); + cb.DrawRect(new ImpellerRect(30, 30, 150, 150), cp); + cp.SetColor(ImpellerColor.FromRgb(80, 130, 230)); + cb.DrawRect(new ImpellerRect(120, 80, 150, 150), cp); + child = cb.CreateDisplayListNew()!; + } + using (child) + { + b.DrawDisplayList(child, 0.5f); + } + } +} + +/// Image filters applied via paint: blur, dilate, erode, matrix (each needs an offscreen layer). +public sealed class ImageFiltersScene : IScene +{ + public string Name => "Image Filters"; + public string TestName => "imagefilters"; + public string Description => "Blur, dilate, erode, and matrix image filters on shapes."; + + public void Render(ImpellerContext context, ImpellerDisplayListBuilder b, SceneParameters p) + { + Draw.Background(b, ImpellerColor.FromRgb(18, 20, 26)); + + using (var blur = ImpellerImageFilter.CreateBlurNew(5, 5, ImpellerTileMode.kImpellerTileModeClamp)!) + { + using var paint = ImpellerPaint.New()!; + paint.SetColor(ImpellerColor.FromRgb(230, 90, 90)); + paint.SetImageFilter(blur); + b.DrawRect(new ImpellerRect(30, 30, 80, 80), paint); + } + + using (var dilate = ImpellerImageFilter.CreateDilateNew(4, 4)!) + { + using var paint = ImpellerPaint.New()!; + paint.SetColor(ImpellerColor.FromRgb(90, 210, 120)); + paint.SetImageFilter(dilate); + b.DrawRect(new ImpellerRect(150, 30, 80, 80), paint); + } + + using (var erode = ImpellerImageFilter.CreateErodeNew(2, 2)!) + { + using var paint = ImpellerPaint.New()!; + paint.SetColor(ImpellerColor.FromRgb(90, 150, 240)); + paint.SetImageFilter(erode); + b.DrawOval(new ImpellerRect(30, 140, 80, 80), paint); + } + } +} + +public sealed class SolidShapesScene : IScene +{ + public string TestName => "solidshapes"; + + public void Render(ImpellerContext context, ImpellerDisplayListBuilder scene, SceneParameters sceneParameters) + { + using var paint = ImpellerPaint.New()!; + + // Opaque background so every pixel is defined. + paint.SetColor(ImpellerColor.FromRgb(20, 24, 40)); + scene.DrawPaint(paint); + + paint.SetColor(ImpellerColor.FromRgb(220, 60, 60)); + scene.DrawRect(new ImpellerRect(40, 40, 120, 80), paint); + + paint.SetColor(ImpellerColor.FromRgb(60, 200, 120)); + scene.DrawOval(new ImpellerRect(180, 50, 110, 110), paint); + + paint.SetColor(ImpellerColor.FromRgb(240, 200, 60)); + scene.DrawRect(new ImpellerRect(40, 160, 90, 90), paint); + + // A rotated rect to exercise the transform stack deterministically. + paint.SetColor(ImpellerColor.FromRgb(90, 140, 240)); + scene.SetTransform( + Matrix4x4.CreateRotationZ(0.4f) * + Matrix4x4.CreateTranslation(220, 210, 0)); + scene.DrawRect(new ImpellerRect(-35, -35, 70, 70), paint); + } +} + diff --git a/tests/NImpeller.Tests/Scenes/IScene.cs b/tests/NImpeller.Tests/Scenes/IScene.cs new file mode 100644 index 0000000..e3853f2 --- /dev/null +++ b/tests/NImpeller.Tests/Scenes/IScene.cs @@ -0,0 +1,16 @@ +using NImpeller; + +namespace NImpeller.Tests.Scenes; + +public sealed class SceneParameters +{ + public int Width { get; set; } + public int Height { get; set; } +} + +public interface IScene +{ + string TestName { get; } + + void Render(ImpellerContext context, ImpellerDisplayListBuilder builder, SceneParameters parameters); +} diff --git a/tests/NImpeller.Tests/TextureInteropTests.cs b/tests/NImpeller.Tests/TextureInteropTests.cs new file mode 100644 index 0000000..4a920a3 --- /dev/null +++ b/tests/NImpeller.Tests/TextureInteropTests.cs @@ -0,0 +1,29 @@ +using NImpeller.Tests.Headless; +using Xunit; + +namespace NImpeller.Tests; + +[Collection(ImpellerGLCollection.Name)] +public sealed class TextureInteropTests +{ + private const int PerChannelTolerance = 8; + private const double MaxDiffFraction = 0.0001; + + private readonly ImpellerGLFixture _gl; + + public TextureInteropTests(ImpellerGLFixture gl) => _gl = gl; + + [Fact] + public void GL_texture_round_trips_and_draws() + { + RenderGate.Require(_gl); + + var (image, createdHandle, reportedHandle) = _gl.RenderTextureInterop(320, 240); + + // GetOpenGLHandle must round-trip the adopted texture name. + Assert.Equal(createdHandle, reportedHandle); + Assert.True(image.HasContent(), "Texture draw produced an empty image."); + + GoldenAssert.Matches("textureinterop", image, PerChannelTolerance, MaxDiffFraction); + } +} diff --git a/tests/NImpeller.Tests/Unit/BuilderStateTests.cs b/tests/NImpeller.Tests/Unit/BuilderStateTests.cs new file mode 100644 index 0000000..a69807f --- /dev/null +++ b/tests/NImpeller.Tests/Unit/BuilderStateTests.cs @@ -0,0 +1,46 @@ +using NImpeller; +using Xunit; + +namespace NImpeller.Tests.Unit; + +public sealed class BuilderStateTests +{ + [Fact] + public void New_returns_a_builder() + { + using var builder = ImpellerDisplayListBuilder.New(new ImpellerRect(0, 0, 100, 100)); + Assert.NotNull(builder); + } + + [Fact] + public void Save_and_restore_adjust_the_save_count() + { + using var builder = ImpellerDisplayListBuilder.New(new ImpellerRect(0, 0, 100, 100))!; + + Assert.Equal(1u, builder.GetSaveCount()); // starts at 1 + + builder.Save(); + Assert.Equal(2u, builder.GetSaveCount()); + + builder.Save(); + Assert.Equal(3u, builder.GetSaveCount()); + + builder.Restore(); + Assert.Equal(2u, builder.GetSaveCount()); + + builder.RestoreToCount(1); + Assert.Equal(1u, builder.GetSaveCount()); + } + + [Fact] + public void CreateDisplayListNew_produces_a_display_list() + { + using var builder = ImpellerDisplayListBuilder.New(new ImpellerRect(0, 0, 100, 100))!; + using var paint = ImpellerPaint.New()!; + paint.SetColor(ImpellerColor.FromRgb(255, 255, 255)); + builder.DrawRect(new ImpellerRect(0, 0, 10, 10), paint); + + using var displayList = builder.CreateDisplayListNew(); + Assert.NotNull(displayList); + } +} diff --git a/tests/NImpeller.Tests/Unit/LifetimeTests.cs b/tests/NImpeller.Tests/Unit/LifetimeTests.cs new file mode 100644 index 0000000..b658824 --- /dev/null +++ b/tests/NImpeller.Tests/Unit/LifetimeTests.cs @@ -0,0 +1,46 @@ +using System; +using NImpeller; +using Xunit; + +namespace NImpeller.Tests.Unit; + +/// +/// Handle/dispose semantics of the wrapper objects. CPU-only. Conceptually mirrors Flutter's +/// object_unittests.cc (refcount/lifetime), adapted to the .NET SafeHandle/IDisposable model +/// this binding uses. +/// +public sealed class LifetimeTests +{ + [Fact] + public void Dispose_is_idempotent() + { + var paint = ImpellerPaint.New()!; + paint.Dispose(); + paint.Dispose(); // must not throw + } + + [Fact] + public void Builder_dispose_is_idempotent() + { + var builder = ImpellerDisplayListBuilder.New(new ImpellerRect(0, 0, 1, 1))!; + builder.Dispose(); + builder.Dispose(); + } + + [Fact] + public void ParagraphBuilder_AddText_rejects_null() + { + using var typography = ImpellerTypographyContext.New()!; + using var builder = typography.ParagraphBuilderNew()!; + Assert.Throws(() => builder.AddText(null!)); + } + + [Fact] + public void ParagraphBuilder_AddText_after_dispose_throws_ObjectDisposed() + { + using var typography = ImpellerTypographyContext.New()!; + var builder = typography.ParagraphBuilderNew()!; + builder.Dispose(); + Assert.Throws(() => builder.AddText("hello")); + } +} diff --git a/tests/NImpeller.Tests/Unit/PathBuilderTests.cs b/tests/NImpeller.Tests/Unit/PathBuilderTests.cs new file mode 100644 index 0000000..75231d6 --- /dev/null +++ b/tests/NImpeller.Tests/Unit/PathBuilderTests.cs @@ -0,0 +1,43 @@ +using NImpeller; +using Xunit; + +namespace NImpeller.Tests.Unit; + +public sealed class PathBuilderTests +{ + [Fact] + public void New_returns_a_path_builder() + { + using var builder = ImpellerPathBuilder.New(); + Assert.NotNull(builder); + } + + [Fact] + public void CopyPathNew_yields_a_path_from_recorded_segments() + { + using var builder = ImpellerPathBuilder.New()!; + builder.MoveTo(new ImpellerPoint { X = 100, Y = 100 }); + builder.LineTo(new ImpellerPoint { X = 200, Y = 200 }); + builder.Close(); + + using var path = builder.CopyPathNew(ImpellerFillType.kImpellerFillTypeNonZero); + Assert.NotNull(path); + } + + [Fact] + public void Builder_supports_curves_rects_and_ovals() + { + using var builder = ImpellerPathBuilder.New()!; + builder.MoveTo(new ImpellerPoint { X = 0, Y = 0 }); + builder.QuadraticCurveTo(new ImpellerPoint { X = 10, Y = 10 }, new ImpellerPoint { X = 20, Y = 0 }); + builder.CubicCurveTo( + new ImpellerPoint { X = 30, Y = 10 }, + new ImpellerPoint { X = 40, Y = -10 }, + new ImpellerPoint { X = 50, Y = 0 }); + builder.AddRect(new ImpellerRect(0, 0, 10, 10)); + builder.AddOval(new ImpellerRect(0, 0, 10, 10)); + + using var path = builder.TakePathNew(ImpellerFillType.kImpellerFillTypeOdd); + Assert.NotNull(path); + } +} diff --git a/tests/NImpeller.Tests/Unit/StructHelperTests.cs b/tests/NImpeller.Tests/Unit/StructHelperTests.cs new file mode 100644 index 0000000..8c13800 --- /dev/null +++ b/tests/NImpeller.Tests/Unit/StructHelperTests.cs @@ -0,0 +1,69 @@ +using System.Numerics; +using NImpeller; +using Xunit; + +namespace NImpeller.Tests.Unit; + +public sealed class StructHelperTests +{ + [Fact] + public void FromRgb_sets_opaque_normalized_channels() + { + var c = ImpellerColor.FromRgb(255, 0, 0); + Assert.Equal(1.0f, c.Red); + Assert.Equal(0.0f, c.Green); + Assert.Equal(0.0f, c.Blue); + Assert.Equal(1.0f, c.Alpha); // FromRgb implies fully opaque + } + + [Fact] + public void FromArgb_normalizes_all_four_channels() + { + var c = ImpellerColor.FromArgb(128, 10, 20, 30); + Assert.Equal(128 / 255.0f, c.Alpha); + Assert.Equal(10 / 255.0f, c.Red); + Assert.Equal(20 / 255.0f, c.Green); + Assert.Equal(30 / 255.0f, c.Blue); + } + + [Fact] + public void Rect_int_ctor_assigns_fields() + { + var r = new ImpellerRect(1, 2, 3, 4); + Assert.Equal(1f, r.X); + Assert.Equal(2f, r.Y); + Assert.Equal(3f, r.Width); + Assert.Equal(4f, r.Height); + } + + [Fact] + public void ISize_int_ctor_assigns_fields() + { + var s = new ImpellerISize(640, 480); + Assert.Equal(640L, s.Width); + Assert.Equal(480L, s.Height); + } + + [Fact] + public void Matrix_implicitly_converts_from_Matrix4x4() + { + var m = Matrix4x4.CreateScale(2, 3, 1); + ImpellerMatrix im = m; // implicit operator + Assert.Equal(m, im.Matrix); + } + + [Fact] + public void TextDecoration_factories_set_type_and_solid_style() + { + var color = ImpellerColor.FromRgb(0, 0, 255); + + var underline = ImpellerTextDecoration.Underline(color, 2.0f); + Assert.Equal((int)ImpellerTextDecorationType.kImpellerTextDecorationTypeUnderline, underline.Types); + Assert.Equal(ImpellerTextDecorationStyle.kImpellerTextDecorationStyleSolid, underline.Style); + Assert.Equal(2.0f, underline.Thickness_multiplier); + + var lineThrough = ImpellerTextDecoration.LineThrough(color); + Assert.Equal((int)ImpellerTextDecorationType.kImpellerTextDecorationTypeLineThrough, lineThrough.Types); + Assert.Equal(1.0f, lineThrough.Thickness_multiplier); // default + } +} diff --git a/tests/NImpeller.Tests/test.runsettings b/tests/NImpeller.Tests/test.runsettings new file mode 100644 index 0000000..08d8210 --- /dev/null +++ b/tests/NImpeller.Tests/test.runsettings @@ -0,0 +1,12 @@ + + + + + <__EGL_VENDOR_LIBRARY_FILENAMES>/usr/share/glvnd/egl_vendor.d/50_mesa.json + 1 + llvmpipe + llvmpipe + 1 + + +