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
+
+
+