chore: add uniffi tests (#520)

* chore: add uniffi tests

* fix permissions in pipelines

* fix tests
This commit is contained in:
stefan-gorules
2026-08-24 12:45:09 +02:00
committed by GitHub
parent a1f8c3e470
commit adf7cdf44a
10 changed files with 1254 additions and 12 deletions
+127 -3
View File
@@ -102,10 +102,134 @@ jobs:
path: target/${{ matrix.settings.target }}/release/${{ format(matrix.settings.output, env.OUTPUT_NAME) }}
if-no-files-found: error
test-jvm-dotnet:
name: Test (Java, Kotlin, C#)
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
defaults:
run:
working-directory: ${{ env.UNIFFI_DIRECTORY }}
steps:
- uses: actions/checkout@v3
- name: Set up JDK 23
uses: actions/setup-java@v4
with:
java-version: '23'
distribution: 'temurin'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
- name: Setup .NET SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Setup Rust Toolchain
uses: dtolnay/rust-toolchain@stable
- uses: ./.github/actions/setup-rust
with:
shared-key: uniffi-test
sccache-bucket: ${{ secrets.SCCACHE_S3_BUCKET }}
sccache-region: ${{ secrets.SCCACHE_AWS_REGION }}
sccache-role-arn: ${{ secrets.SCCACHE_AWS_ROLE_ARN }}
- name: Build native library
env:
CARGO_PROFILE_RELEASE_STRIP: 'none'
run: cargo build --lib --release --locked
- name: Stage native library
run: |
mkdir -p build/generated/resources/linux-x86-64
cp ../../target/release/libzen_uniffi.so build/generated/resources/
cp ../../target/release/libzen_uniffi.so build/generated/resources/linux-x86-64/
- name: Install uniffi-bindgen-java
run: cargo install uniffi-bindgen-java --version 0.4.2 --locked
- name: Install uniffi-bindgen-cs
run: cargo install uniffi-bindgen-cs --git https://github.com/gorules/uniffi-bindgen-cs --rev d080b41
- name: Generate Java sources
run: |
uniffi-bindgen-java generate \
--out-dir build/generated/java \
build/generated/resources/libzen_uniffi.so
test -f build/generated/java/io/gorules/zen_engine/ZenEngine.java
- name: Generate Kotlin sources
run: |
cargo run --locked --bin uniffi-bindgen -- generate \
--library build/generated/resources/libzen_uniffi.so \
--language kotlin \
--out-dir build/generated/kotlin
test -f build/generated/kotlin/io/gorules/zen_engine/kotlin/zen_uniffi.kt
- name: Generate C# sources
run: |
uniffi-bindgen-cs \
--library build/generated/resources/libzen_uniffi.so \
--config uniffi.toml \
--out-dir build/generated/csharp
test -f build/generated/csharp/zen_uniffi.cs
- name: Run Java and Kotlin tests
run: ./gradlew testJava testKotlin
- name: Run C# tests
run: dotnet test tests/csharp
test-swift:
name: Test (Swift)
runs-on: macos-latest
permissions:
contents: read
id-token: write
defaults:
run:
working-directory: ${{ env.UNIFFI_DIRECTORY }}
steps:
- uses: actions/checkout@v3
- name: Setup Rust Toolchain
uses: dtolnay/rust-toolchain@stable
- uses: ./.github/actions/setup-rust
with:
shared-key: uniffi-test-swift
sccache-bucket: ${{ secrets.SCCACHE_S3_BUCKET }}
sccache-region: ${{ secrets.SCCACHE_AWS_REGION }}
sccache-role-arn: ${{ secrets.SCCACHE_AWS_ROLE_ARN }}
- name: Build native library
run: cargo build --lib --release --locked
- name: Stage native library
run: |
mkdir -p build/generated/resources
cp ../../target/release/libzen_uniffi.dylib build/generated/resources/
- name: Generate Swift sources
run: |
cargo run --locked --bin uniffi-bindgen -- generate \
--library build/generated/resources/libzen_uniffi.dylib \
--language swift \
--out-dir build/generated/swift
- name: Run Swift tests
run: make test-swift
release-java-kotlin:
runs-on: ubuntu-latest
environment: release
needs: [ build ]
needs: [ build, test-jvm-dotnet ]
if: "github.ref_type == 'tag'"
defaults:
run:
@@ -179,7 +303,7 @@ jobs:
release-csharp:
runs-on: ubuntu-latest
environment: release
needs: [ build ]
needs: [ build, test-jvm-dotnet ]
permissions:
id-token: write # Required for OIDC
if: "github.ref_type == 'tag'"
@@ -416,7 +540,7 @@ jobs:
name: Release iOS
runs-on: macos-latest
environment: release
needs: [ create-xcframework ]
needs: [ create-xcframework, test-swift ]
if: "github.ref_type == 'tag'"
steps:
+7 -1
View File
@@ -1,5 +1,11 @@
.idea
.gradle
.kotlin
build/
build-android/
jniLibs/
jniLibs/
tests/csharp/bin/
tests/csharp/obj/
tests/swift/.build/
tests/swift/Sources/ZenUniffi/
tests/swift/Sources/zen_uniffiFFI/
+62 -8
View File
@@ -1,25 +1,79 @@
# Just for local testing, at the moment it relies on .dylib (Mac), feel free to change below
TARGET_DIR ?= $(shell cargo metadata --format-version=1 --no-deps 2>/dev/null | sed -n 's/.*"target_directory":"\([^"]*\)".*/\1/p')
UNAME_S := $(shell uname -s)
UNAME_M := $(shell uname -m)
ifeq ($(UNAME_S),Darwin)
LIB_NAME := libzen_uniffi.dylib
ifeq ($(UNAME_M),arm64)
PLATFORM_DIR := darwin-aarch64
else
PLATFORM_DIR := darwin-x86-64
endif
else
LIB_NAME := libzen_uniffi.so
ifeq ($(UNAME_M),aarch64)
PLATFORM_DIR := linux-aarch64
else
PLATFORM_DIR := linux-x86-64
endif
endif
DEV_DIR := $(shell xcode-select -p 2>/dev/null)
ifeq ($(DEV_DIR),/Library/Developer/CommandLineTools)
TESTING_FRAMEWORKS := $(DEV_DIR)/Library/Developer/Frameworks
SWIFT_TEST_FLAGS := -Xswiftc -F$(TESTING_FRAMEWORKS) \
-Xlinker -F$(TESTING_FRAMEWORKS) \
-Xlinker -rpath -Xlinker $(TESTING_FRAMEWORKS) \
-Xlinker -rpath -Xlinker $(DEV_DIR)/Library/Developer/usr/lib
endif
build:
cargo build --lib --release
@mkdir -p build/generated/resources
cp -f ../../target/release/libzen_uniffi.dylib build/generated/resources/libzen_uniffi.dylib
@mkdir -p build/generated/resources/$(PLATFORM_DIR)
cp -f $(TARGET_DIR)/release/$(LIB_NAME) build/generated/resources/$(LIB_NAME)
cp -f $(TARGET_DIR)/release/$(LIB_NAME) build/generated/resources/$(PLATFORM_DIR)/$(LIB_NAME)
generate-java:
uniffi-bindgen-java generate \
--library build/generated/resources/libzen_uniffi.dylib \
--library build/generated/resources/$(LIB_NAME) \
--out-dir build/generated/java
generate-kotlin:
cargo run --bin uniffi-bindgen generate \
--library build/generated/resources/libzen_uniffi.dylib \
--library build/generated/resources/$(LIB_NAME) \
--language kotlin \
--out-dir build/generated/kotlin
generate-csharp:
uniffi-bindgen-cs \
--library build/generated/resources/libzen_uniffi.dylib \
--library build/generated/resources/$(LIB_NAME) \
--config uniffi.toml \
--out-dir build/generated/csharp
all: build generate-java generate-kotlin generate-csharp
generate-swift:
cargo run --bin uniffi-bindgen generate \
--library build/generated/resources/$(LIB_NAME) \
--language swift \
--out-dir build/generated/swift
.PHONY: all build generate-java generate-kotlin generate-csharp
test-java:
./gradlew testJava
test-kotlin:
./gradlew testKotlin
test-csharp:
dotnet test tests/csharp
test-swift:
@mkdir -p tests/swift/Sources/ZenUniffi tests/swift/Sources/zen_uniffiFFI
cp -f build/generated/swift/zen_uniffi.swift tests/swift/Sources/ZenUniffi/
cp -f build/generated/swift/zen_uniffiFFI.h tests/swift/Sources/zen_uniffiFFI/
cp -f build/generated/swift/zen_uniffiFFI.modulemap tests/swift/Sources/zen_uniffiFFI/module.modulemap
cd tests/swift && swift test $(SWIFT_TEST_FLAGS) -Xlinker $(abspath $(TARGET_DIR))/release/libzen_uniffi.a
all: build generate-java generate-kotlin generate-csharp generate-swift
test: test-java test-kotlin test-csharp test-swift
.PHONY: all build generate-java generate-kotlin generate-csharp generate-swift test test-java test-kotlin test-csharp test-swift
+58
View File
@@ -57,6 +57,24 @@ sourceSets {
compileClasspath += sourceSets["main"].compileClasspath
runtimeClasspath += sourceSets["main"].runtimeClasspath
}
val testsJava by creating {
java {
srcDirs("tests/java")
}
compileClasspath += sourceSets["java"].output + sourceSets["java"].compileClasspath
runtimeClasspath += sourceSets["java"].output + sourceSets["java"].runtimeClasspath
}
val testsKotlin by creating {
kotlin {
srcDirs("tests/kotlin")
}
compileClasspath += sourceSets["kotlin"].output + sourceSets["kotlin"].compileClasspath
runtimeClasspath += sourceSets["kotlin"].output + sourceSets["kotlin"].runtimeClasspath
}
}
@@ -65,6 +83,14 @@ dependencies {
"kotlinImplementation"("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
"kotlinAndroidImplementation"("net.java.dev.jna:jna:5.17.0")
"kotlinAndroidImplementation"("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
"testsJavaImplementation"("org.junit.jupiter:junit-jupiter:5.11.4")
"testsJavaImplementation"("com.fasterxml.jackson.core:jackson-databind:2.18.2")
"testsJavaRuntimeOnly"("org.junit.platform:junit-platform-launcher:1.11.4")
"testsKotlinImplementation"("org.junit.jupiter:junit-jupiter:5.11.4")
"testsKotlinImplementation"("com.fasterxml.jackson.core:jackson-databind:2.18.2")
"testsKotlinImplementation"("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
"testsKotlinImplementation"("net.java.dev.jna:jna:5.17.0")
"testsKotlinRuntimeOnly"("org.junit.platform:junit-platform-launcher:1.11.4")
}
tasks.withType<JavaCompile>().configureEach {
@@ -75,6 +101,10 @@ tasks.named<JavaCompile>("compileJavaJava") {
options.release = 22
}
tasks.named<JavaCompile>("compileTestsJavaJava") {
options.release = 22
}
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
compilerOptions.jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21)
}
@@ -103,6 +133,34 @@ tasks {
dependsOn(patchJavaNativeLoader)
}
val testJava by creating(Test::class) {
group = "verification"
testClassesDirs = sourceSets["testsJava"].output.classesDirs
classpath = sourceSets["testsJava"].runtimeClasspath
useJUnitPlatform()
jvmArgs("--enable-native-access=ALL-UNNAMED")
workingDir = projectDir
testLogging {
events("passed", "failed", "skipped")
showExceptions = true
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
}
}
val testKotlin by creating(Test::class) {
group = "verification"
testClassesDirs = sourceSets["testsKotlin"].output.classesDirs
classpath = sourceSets["testsKotlin"].runtimeClasspath
useJUnitPlatform()
jvmArgs("--enable-native-access=ALL-UNNAMED")
workingDir = projectDir
testLogging {
events("passed", "failed", "skipped")
showExceptions = true
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
}
}
val generateJavaJar by creating(Jar::class) {
archiveBaseName.set("zen_engine")
from(sourceSets["java"].output)
@@ -0,0 +1,46 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<IsPackable>false</IsPackable>
<RootNamespace>GoRules.ZenEngine.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<Compile Include="../../build/generated/csharp/zen_uniffi.cs" Link="zen_uniffi.cs" />
<Compile Include="../../lib/csharp/GoRules/ZenEngine/JsonBuffer.cs" Link="JsonBuffer.cs" />
</ItemGroup>
<ItemGroup>
<None Include="../../build/generated/resources/darwin-aarch64/libzen_uniffi.dylib"
Condition="$([MSBuild]::IsOSPlatform('OSX')) And '$([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)' == 'Arm64'"
CopyToOutputDirectory="PreserveNewest"
Link="libzen_uniffi.dylib" />
<None Include="../../build/generated/resources/darwin-x86-64/libzen_uniffi.dylib"
Condition="$([MSBuild]::IsOSPlatform('OSX')) And '$([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)' == 'X64'"
CopyToOutputDirectory="PreserveNewest"
Link="libzen_uniffi.dylib" />
<None Include="../../build/generated/resources/linux-x86-64/libzen_uniffi.so"
Condition="$([MSBuild]::IsOSPlatform('Linux')) And '$([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)' == 'X64'"
CopyToOutputDirectory="PreserveNewest"
Link="libzen_uniffi.so" />
<None Include="../../build/generated/resources/linux-aarch64/libzen_uniffi.so"
Condition="$([MSBuild]::IsOSPlatform('Linux')) And '$([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)' == 'Arm64'"
CopyToOutputDirectory="PreserveNewest"
Link="libzen_uniffi.so" />
<None Include="../../build/generated/resources/win32-x86-64/zen_uniffi.dll"
Condition="$([MSBuild]::IsOSPlatform('Windows'))"
CopyToOutputDirectory="PreserveNewest"
Link="zen_uniffi.dll" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
</Project>
@@ -0,0 +1,235 @@
using System.IO.Compression;
using System.Text.Json.Nodes;
using GoRules.ZenEngine;
using Xunit;
namespace GoRules.ZenEngine.Tests;
public class ZenEngineTests
{
private static string TestDataRoot()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current != null && !Directory.Exists(Path.Combine(current.FullName, "test-data")))
{
current = current.Parent;
}
if (current == null)
{
throw new InvalidOperationException("test-data directory not found");
}
return Path.Combine(current.FullName, "test-data");
}
private static JsonBuffer ReadTestFile(string name) =>
new(File.ReadAllBytes(Path.Combine(TestDataRoot(), name)));
private static JsonNode Json(JsonBuffer buffer) =>
JsonNode.Parse(buffer.ToString())!;
private class FilesystemCallback : ZenDecisionLoaderCallback
{
public Task<JsonBuffer?> Load(string key)
{
var path = Path.Combine(TestDataRoot(), key);
return Task.FromResult<JsonBuffer?>(File.Exists(path) ? new JsonBuffer(File.ReadAllBytes(path)) : null);
}
}
private class SumNodeCallback : ZenCustomNodeCallback
{
public ZenEngineHandlerRequest? SeenRequest;
public Task<ZenEngineHandlerResponse> Handle(ZenEngineHandlerRequest key)
{
SeenRequest = key;
var a = Json(key.Input)["a"]!.GetValue<int>();
var output = new JsonBuffer($"{{\"data\":{a + 20}}}");
return Task.FromResult(new ZenEngineHandlerResponse(output, null));
}
}
[Fact]
public async Task StaticLoader()
{
var loader = new ZenLoader.Static(new Dictionary<string, JsonBuffer>
{
["table.json"] = ReadTestFile("table.json"),
});
using var engine = new ZenEngine(loader);
var response = await engine.Evaluate("table.json", new JsonBuffer("{\"input\":12}"), null);
Assert.Equal(10, Json(response.Result)["output"]!.GetValue<int>());
Assert.Null(response.Trace);
}
[Fact]
public async Task FilesystemLoader()
{
using var engine = new ZenEngine(new ZenLoader.Filesystem(TestDataRoot()));
var response = await engine.Evaluate("table.json", new JsonBuffer("{\"input\":5}"), null);
Assert.Equal(0, Json(response.Result)["output"]!.GetValue<int>());
}
[Fact]
public async Task ZipLoader()
{
using var buffer = new MemoryStream();
using (var zip = new ZipArchive(buffer, ZipArchiveMode.Create, true))
{
var entry = zip.CreateEntry("table.json");
using var stream = entry.Open();
stream.Write(ReadTestFile("table.json").Value);
}
using var engine = new ZenEngine(new ZenLoader.Zip(buffer.ToArray()));
var response = await engine.Evaluate("table.json", new JsonBuffer("{\"input\":12}"), null);
Assert.Equal(10, Json(response.Result)["output"]!.GetValue<int>());
}
[Fact]
public void InvalidZipFailsOnConstruction()
{
Assert.ThrowsAny<ZenException>(() => new ZenEngine(new ZenLoader.Zip(new byte[] { 1, 2, 3, 4 })));
}
[Fact]
public async Task CallbackLoader()
{
using var engine = new ZenEngine(new ZenLoader.Callback(new FilesystemCallback()));
var response = await engine.Evaluate("table.json", new JsonBuffer("{\"input\":12}"), null);
Assert.Equal(10, Json(response.Result)["output"]!.GetValue<int>());
await Assert.ThrowsAnyAsync<ZenException>(() =>
engine.Evaluate("missing.json", new JsonBuffer("{}"), null));
}
[Fact]
public async Task MissingKeyFails()
{
using var engine = new ZenEngine(new ZenLoader.Static(new Dictionary<string, JsonBuffer>()));
await Assert.ThrowsAnyAsync<ZenException>(() =>
engine.Evaluate("missing.json", new JsonBuffer("{}"), null));
}
[Fact]
public async Task CreateDecision()
{
using var engine = new ZenEngine();
using var decision = engine.CreateDecision(ReadTestFile("table.json"));
decision.Validate();
var response = await decision.Evaluate(new JsonBuffer("{\"input\":12}"), null);
Assert.Equal(10, Json(response.Result)["output"]!.GetValue<int>());
}
[Fact]
public async Task GetDecision()
{
using var engine = new ZenEngine(new ZenLoader.Filesystem(TestDataRoot()));
using var decision = await engine.GetDecision("table.json");
var response = await decision.Evaluate(new JsonBuffer("{\"input\":12}"), null);
Assert.Equal(10, Json(response.Result)["output"]!.GetValue<int>());
}
[Fact]
public async Task EvaluateBatch()
{
using var engine = new ZenEngine(new ZenLoader.Filesystem(TestDataRoot()));
var results = await engine.EvaluateBatch(new[]
{
new ZenBatchRequest("table.json", new JsonBuffer("{\"input\":12}")),
new ZenBatchRequest("missing.json", new JsonBuffer("{}")),
new ZenBatchRequest("table.json", new JsonBuffer("{\"input\":5}")),
}, null);
Assert.Equal(3, results.Length);
Assert.True(results[0].Success);
Assert.Equal(10, Json(results[0].Data!.Result)["output"]!.GetValue<int>());
Assert.False(results[1].Success);
Assert.NotNull(results[1].Error);
Assert.True(results[2].Success);
Assert.Equal(0, Json(results[2].Data!.Result)["output"]!.GetValue<int>());
}
[Fact]
public async Task TraceOption()
{
using var engine = new ZenEngine(new ZenLoader.Filesystem(TestDataRoot()));
var options = new ZenEvaluateOptions(5, true);
var response = await engine.Evaluate("table.json", new JsonBuffer("{\"input\":12}"), options);
Assert.NotNull(response.Trace);
Assert.NotEmpty(response.Trace);
}
[Fact]
public async Task ExpressionGraph()
{
using var engine = new ZenEngine(new ZenLoader.Filesystem(TestDataRoot()));
var context = new JsonBuffer("{\"numbers\":[1,5,15,25],\"firstName\":\"John\",\"lastName\":\"Doe\"}");
var response = await engine.Evaluate("expression.json", context, null);
var expected = JsonNode.Parse(
"{\"deep\":{\"nested\":{\"sum\":46}},\"fullName\":\"John Doe\",\"largeNumbers\":[15,25],\"smallNumbers\":[1,5]}");
Assert.True(JsonNode.DeepEquals(expected, Json(response.Result)));
}
[Fact]
public async Task FunctionGraph()
{
using var engine = new ZenEngine(new ZenLoader.Filesystem(TestDataRoot()));
var response = await engine.Evaluate("function.json", new JsonBuffer("{\"input\":15}"), null);
Assert.Equal(30, Json(response.Result)["output"]!.GetValue<int>());
}
[Fact]
public async Task CustomNodeHandler()
{
var handler = new SumNodeCallback();
using (var engine = new ZenEngine(new ZenLoader.Filesystem(TestDataRoot()), handler))
{
var response = await engine.Evaluate("custom.json", new JsonBuffer("{\"a\":5}"), null);
Assert.Equal(25, Json(response.Result)["data"]!.GetValue<int>());
}
Assert.NotNull(handler.SeenRequest);
Assert.Equal("sum", handler.SeenRequest!.Node.Kind);
Assert.Equal("customNode1", handler.SeenRequest.Node.Name);
Assert.Equal("{{ a + 10 }}", Json(handler.SeenRequest.Node.Config)["prop1"]!.GetValue<string>());
}
[Fact]
public void EvaluateExpression()
{
var result = ZenUniffiMethods.EvaluateExpression("sum(numbers)", new JsonBuffer("{\"numbers\":[1,2,3]}"));
Assert.Equal(6, Json(result).GetValue<int>());
}
[Fact]
public void EvaluateUnaryExpression()
{
Assert.True(ZenUniffiMethods.EvaluateUnaryExpression("$ > 10", new JsonBuffer("{\"$\":15}")));
Assert.False(ZenUniffiMethods.EvaluateUnaryExpression("$ > 10", new JsonBuffer("{\"$\":5}")));
}
[Fact]
public void InvalidExpressionFails()
{
Assert.ThrowsAny<ZenException>(() =>
ZenUniffiMethods.EvaluateExpression("a +* b", new JsonBuffer("{}")));
}
[Fact]
public void CompiledExpression()
{
using (var expression = ZenExpression.Compile("a + b"))
{
var result = expression.Evaluate(new JsonBuffer("{\"a\":1,\"b\":2}"));
Assert.Equal(3, Json(result).GetValue<int>());
}
using var unary = ZenExpressionUnary.Compile("$ > 3");
Assert.True(unary.Evaluate(new JsonBuffer("{\"$\":4}")));
Assert.False(unary.Evaluate(new JsonBuffer("{\"$\":2}")));
}
}
@@ -0,0 +1,246 @@
package io.gorules.zen_engine;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.atomic.AtomicReference;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ZenEngineTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static Path testDataRoot() {
Path current = Paths.get("").toAbsolutePath();
while (current != null && !Files.isDirectory(current.resolve("test-data"))) {
current = current.getParent();
}
if (current == null) {
throw new IllegalStateException("test-data directory not found");
}
return current.resolve("test-data");
}
private static JsonBuffer readTestFile(String name) throws IOException {
return new JsonBuffer(Files.readAllBytes(testDataRoot().resolve(name)));
}
private static JsonNode json(JsonBuffer buffer) throws IOException {
return MAPPER.readTree(buffer.value());
}
@Test
void staticLoader() throws Exception {
var loader = new ZenLoader.Static(Map.of("table.json", readTestFile("table.json")));
try (var engine = new ZenEngine(loader, null)) {
var response = engine.evaluate("table.json", new JsonBuffer("{\"input\":12}"), null).get();
assertEquals(10, json(response.result()).get("output").asInt());
assertNull(response.trace());
}
}
@Test
void filesystemLoader() throws Exception {
try (var engine = new ZenEngine(new ZenLoader.Filesystem(testDataRoot().toString()), null)) {
var response = engine.evaluate("table.json", new JsonBuffer("{\"input\":5}"), null).get();
assertEquals(0, json(response.result()).get("output").asInt());
}
}
@Test
void zipLoader() throws Exception {
var buffer = new ByteArrayOutputStream();
try (var zip = new ZipOutputStream(buffer)) {
zip.putNextEntry(new ZipEntry("table.json"));
zip.write(readTestFile("table.json").value());
zip.closeEntry();
}
try (var engine = new ZenEngine(new ZenLoader.Zip(buffer.toByteArray()), null)) {
var response = engine.evaluate("table.json", new JsonBuffer("{\"input\":12}"), null).get();
assertEquals(10, json(response.result()).get("output").asInt());
}
}
@Test
void invalidZipFailsOnConstruction() {
assertThrows(ZenException.class, () -> new ZenEngine(new ZenLoader.Zip(new byte[]{1, 2, 3, 4}), null));
}
@Test
void callbackLoader() throws Exception {
ZenDecisionLoaderCallback callback = key -> {
try {
return CompletableFuture.completedFuture(readTestFile(key));
} catch (IOException e) {
return CompletableFuture.completedFuture(null);
}
};
try (var engine = new ZenEngine(new ZenLoader.Callback(callback), null)) {
var response = engine.evaluate("table.json", new JsonBuffer("{\"input\":12}"), null).get();
assertEquals(10, json(response.result()).get("output").asInt());
var error = assertThrows(
ExecutionException.class,
() -> engine.evaluate("missing.json", new JsonBuffer("{}"), null).get()
);
assertInstanceOf(ZenException.class, error.getCause());
}
}
@Test
void missingKeyFails() throws Exception {
try (var engine = new ZenEngine(new ZenLoader.Static(Map.of()), null)) {
var error = assertThrows(
ExecutionException.class,
() -> engine.evaluate("missing.json", new JsonBuffer("{}"), null).get()
);
assertInstanceOf(ZenException.class, error.getCause());
}
}
@Test
void createDecision() throws Exception {
try (var engine = new ZenEngine(null, null)) {
try (var decision = engine.createDecision(readTestFile("table.json"))) {
decision.validate();
var response = decision.evaluate(new JsonBuffer("{\"input\":12}"), null).get();
assertEquals(10, json(response.result()).get("output").asInt());
}
}
}
@Test
void getDecision() throws Exception {
try (var engine = new ZenEngine(new ZenLoader.Filesystem(testDataRoot().toString()), null)) {
try (var decision = engine.getDecision("table.json").get()) {
var response = decision.evaluate(new JsonBuffer("{\"input\":12}"), null).get();
assertEquals(10, json(response.result()).get("output").asInt());
}
}
}
@Test
void evaluateBatch() throws Exception {
try (var engine = new ZenEngine(new ZenLoader.Filesystem(testDataRoot().toString()), null)) {
var results = engine.evaluateBatch(List.of(
new ZenBatchRequest("table.json", new JsonBuffer("{\"input\":12}")),
new ZenBatchRequest("missing.json", new JsonBuffer("{}")),
new ZenBatchRequest("table.json", new JsonBuffer("{\"input\":5}"))
), null).get();
assertEquals(3, results.size());
assertTrue(results.get(0).success());
assertEquals(10, json(results.get(0).data().result()).get("output").asInt());
assertFalse(results.get(1).success());
assertNotNull(results.get(1).error());
assertTrue(results.get(2).success());
assertEquals(0, json(results.get(2).data().result()).get("output").asInt());
}
}
@Test
void traceOption() throws Exception {
try (var engine = new ZenEngine(new ZenLoader.Filesystem(testDataRoot().toString()), null)) {
var options = new ZenEvaluateOptions((byte) 5, true);
var response = engine.evaluate("table.json", new JsonBuffer("{\"input\":12}"), options).get();
assertNotNull(response.trace());
assertFalse(response.trace().isEmpty());
}
}
@Test
void expressionGraph() throws Exception {
try (var engine = new ZenEngine(new ZenLoader.Filesystem(testDataRoot().toString()), null)) {
var context = new JsonBuffer("{\"numbers\":[1,5,15,25],\"firstName\":\"John\",\"lastName\":\"Doe\"}");
var response = engine.evaluate("expression.json", context, null).get();
var expected = MAPPER.readTree(
"{\"deep\":{\"nested\":{\"sum\":46}},\"fullName\":\"John Doe\",\"largeNumbers\":[15,25],\"smallNumbers\":[1,5]}"
);
assertEquals(expected, json(response.result()));
}
}
@Test
void functionGraph() throws Exception {
try (var engine = new ZenEngine(new ZenLoader.Filesystem(testDataRoot().toString()), null)) {
var response = engine.evaluate("function.json", new JsonBuffer("{\"input\":15}"), null).get();
assertEquals(30, json(response.result()).get("output").asInt());
}
}
@Test
void customNodeHandler() throws Exception {
var seenRequest = new AtomicReference<ZenEngineHandlerRequest>();
ZenCustomNodeCallback handler = request -> {
seenRequest.set(request);
try {
var a = json(request.input()).get("a").asInt();
var output = new JsonBuffer("{\"data\":" + (a + 20) + "}");
return CompletableFuture.completedFuture(new ZenEngineHandlerResponse(output, null));
} catch (IOException e) {
return CompletableFuture.failedFuture(e);
}
};
try (var engine = new ZenEngine(new ZenLoader.Filesystem(testDataRoot().toString()), handler)) {
var response = engine.evaluate("custom.json", new JsonBuffer("{\"a\":5}"), null).get();
assertEquals(25, json(response.result()).get("data").asInt());
}
var request = seenRequest.get();
assertNotNull(request);
assertEquals("sum", request.node().kind());
assertEquals("customNode1", request.node().name());
assertEquals("{{ a + 10 }}", json(request.node().config()).get("prop1").asText());
}
@Test
void evaluateExpression() throws Exception {
var result = ZenUniffi.evaluateExpression("sum(numbers)", new JsonBuffer("{\"numbers\":[1,2,3]}"));
assertEquals(6, json(result).asInt());
}
@Test
void evaluateUnaryExpression() throws Exception {
assertTrue(ZenUniffi.evaluateUnaryExpression("$ > 10", new JsonBuffer("{\"$\":15}")));
assertFalse(ZenUniffi.evaluateUnaryExpression("$ > 10", new JsonBuffer("{\"$\":5}")));
}
@Test
void invalidExpressionFails() {
assertThrows(ZenException.class, () -> ZenUniffi.evaluateExpression("a +* b", new JsonBuffer("{}")));
}
@Test
void compiledExpression() throws Exception {
try (var expression = ZenExpression.compile("a + b")) {
var result = expression.evaluate(new JsonBuffer("{\"a\":1,\"b\":2}"));
assertEquals(3, json(result).asInt());
}
try (var unary = ZenExpressionUnary.compile("$ > 3")) {
assertTrue(unary.evaluate(new JsonBuffer("{\"$\":4}")));
assertFalse(unary.evaluate(new JsonBuffer("{\"$\":2}")));
}
}
}
@@ -0,0 +1,224 @@
package io.gorules.zen_engine.kotlin
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import java.io.ByteArrayOutputStream
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
class ZenEngineTest {
private val mapper = ObjectMapper()
private fun testDataRoot(): Path {
var current: Path? = Paths.get("").toAbsolutePath()
while (current != null && !Files.isDirectory(current.resolve("test-data"))) {
current = current.parent
}
return checkNotNull(current) { "test-data directory not found" }.resolve("test-data")
}
private fun readTestFile(name: String): JsonBuffer =
JsonBuffer(Files.readAllBytes(testDataRoot().resolve(name)))
private fun json(buffer: JsonBuffer): JsonNode = mapper.readTree(buffer.value)
@Test
fun staticLoader() = runBlocking {
val loader = ZenLoader.Static(mapOf("table.json" to readTestFile("table.json")))
ZenEngine(loader).use { engine ->
val response = engine.evaluate("table.json", JsonBuffer("""{"input":12}"""), null)
assertEquals(10, json(response.result).get("output").asInt())
assertNull(response.trace)
}
}
@Test
fun filesystemLoader() = runBlocking {
ZenEngine(ZenLoader.Filesystem(testDataRoot().toString())).use { engine ->
val response = engine.evaluate("table.json", JsonBuffer("""{"input":5}"""), null)
assertEquals(0, json(response.result).get("output").asInt())
}
}
@Test
fun zipLoader() = runBlocking {
val buffer = ByteArrayOutputStream()
ZipOutputStream(buffer).use { zip ->
zip.putNextEntry(ZipEntry("table.json"))
zip.write(readTestFile("table.json").value)
zip.closeEntry()
}
ZenEngine(ZenLoader.Zip(buffer.toByteArray())).use { engine ->
val response = engine.evaluate("table.json", JsonBuffer("""{"input":12}"""), null)
assertEquals(10, json(response.result).get("output").asInt())
}
}
@Test
fun invalidZipFailsOnConstruction() {
assertThrows<ZenException> { ZenEngine(ZenLoader.Zip(byteArrayOf(1, 2, 3, 4))) }
}
@Test
fun callbackLoader() = runBlocking<Unit> {
val callback = object : ZenDecisionLoaderCallback {
override suspend fun load(key: String): JsonBuffer? =
runCatching { readTestFile(key) }.getOrNull()
}
ZenEngine(ZenLoader.Callback(callback)).use { engine ->
val response = engine.evaluate("table.json", JsonBuffer("""{"input":12}"""), null)
assertEquals(10, json(response.result).get("output").asInt())
assertThrows<ZenException> {
runBlocking { engine.evaluate("missing.json", JsonBuffer("{}"), null) }
}
}
}
@Test
fun missingKeyFails() = runBlocking<Unit> {
ZenEngine(ZenLoader.Static(emptyMap())).use { engine ->
assertThrows<ZenException> {
runBlocking { engine.evaluate("missing.json", JsonBuffer("{}"), null) }
}
}
}
@Test
fun createDecision() = runBlocking {
ZenEngine().use { engine ->
engine.createDecision(readTestFile("table.json")).use { decision ->
decision.validate()
val response = decision.evaluate(JsonBuffer("""{"input":12}"""), null)
assertEquals(10, json(response.result).get("output").asInt())
}
}
}
@Test
fun getDecision() = runBlocking {
ZenEngine(ZenLoader.Filesystem(testDataRoot().toString())).use { engine ->
engine.getDecision("table.json").use { decision ->
val response = decision.evaluate(JsonBuffer("""{"input":12}"""), null)
assertEquals(10, json(response.result).get("output").asInt())
}
}
}
@Test
fun evaluateBatch() = runBlocking {
ZenEngine(ZenLoader.Filesystem(testDataRoot().toString())).use { engine ->
val results = engine.evaluateBatch(
listOf(
ZenBatchRequest("table.json", JsonBuffer("""{"input":12}""")),
ZenBatchRequest("missing.json", JsonBuffer("{}")),
ZenBatchRequest("table.json", JsonBuffer("""{"input":5}""")),
),
null,
)
assertEquals(3, results.size)
assertTrue(results[0].success)
assertEquals(10, json(results[0].data!!.result).get("output").asInt())
assertFalse(results[1].success)
assertNotNull(results[1].error)
assertTrue(results[2].success)
assertEquals(0, json(results[2].data!!.result).get("output").asInt())
}
}
@Test
fun traceOption() = runBlocking {
ZenEngine(ZenLoader.Filesystem(testDataRoot().toString())).use { engine ->
val options = ZenEvaluateOptions(maxDepth = 5u, trace = true)
val response = engine.evaluate("table.json", JsonBuffer("""{"input":12}"""), options)
assertNotNull(response.trace)
assertTrue(response.trace!!.isNotEmpty())
}
}
@Test
fun expressionGraph() = runBlocking {
ZenEngine(ZenLoader.Filesystem(testDataRoot().toString())).use { engine ->
val context = JsonBuffer("""{"numbers":[1,5,15,25],"firstName":"John","lastName":"Doe"}""")
val response = engine.evaluate("expression.json", context, null)
val expected = mapper.readTree(
"""{"deep":{"nested":{"sum":46}},"fullName":"John Doe","largeNumbers":[15,25],"smallNumbers":[1,5]}""",
)
assertEquals(expected, json(response.result))
}
}
@Test
fun functionGraph() = runBlocking {
ZenEngine(ZenLoader.Filesystem(testDataRoot().toString())).use { engine ->
val response = engine.evaluate("function.json", JsonBuffer("""{"input":15}"""), null)
assertEquals(30, json(response.result).get("output").asInt())
}
}
@Test
fun customNodeHandler() = runBlocking {
var seenRequest: ZenEngineHandlerRequest? = null
val handler = object : ZenCustomNodeCallback {
override suspend fun handle(key: ZenEngineHandlerRequest): ZenEngineHandlerResponse {
seenRequest = key
val a = json(key.input).get("a").asInt()
return ZenEngineHandlerResponse(JsonBuffer("""{"data":${a + 20}}"""), null)
}
}
ZenEngine(ZenLoader.Filesystem(testDataRoot().toString()), handler).use { engine ->
val response = engine.evaluate("custom.json", JsonBuffer("""{"a":5}"""), null)
assertEquals(25, json(response.result).get("data").asInt())
}
val request = checkNotNull(seenRequest)
assertEquals("sum", request.node.kind)
assertEquals("customNode1", request.node.name)
assertEquals("{{ a + 10 }}", json(request.node.config).get("prop1").asText())
}
@Test
fun evaluateExpressionFunction() {
val result = evaluateExpression("sum(numbers)", JsonBuffer("""{"numbers":[1,2,3]}"""))
assertEquals(6, json(result).asInt())
}
@Test
fun evaluateUnaryExpressionFunction() {
assertTrue(evaluateUnaryExpression("$ > 10", JsonBuffer("""{"$":15}""")))
assertFalse(evaluateUnaryExpression("$ > 10", JsonBuffer("""{"$":5}""")))
}
@Test
fun invalidExpressionFails() {
assertThrows<ZenException> { evaluateExpression("a +* b", JsonBuffer("{}")) }
}
@Test
fun compiledExpression() {
ZenExpression.compile("a + b").use { expression ->
val result = expression.evaluate(JsonBuffer("""{"a":1,"b":2}"""))
assertEquals(3, json(result).asInt())
}
ZenExpressionUnary.compile("$ > 3").use { unary ->
assertTrue(unary.evaluate(JsonBuffer("""{"$":4}""")))
assertFalse(unary.evaluate(JsonBuffer("""{"$":2}""")))
}
}
}
+32
View File
@@ -0,0 +1,32 @@
// swift-tools-version:6.0
import PackageDescription
let package = Package(
name: "ZenUniffiTests",
platforms: [
.macOS(.v13)
],
targets: [
.systemLibrary(
name: "zen_uniffiFFI",
path: "Sources/zen_uniffiFFI"
),
.target(
name: "ZenUniffi",
dependencies: ["zen_uniffiFFI"],
path: "Sources/ZenUniffi",
swiftSettings: [.swiftLanguageMode(.v5)]
),
.testTarget(
name: "ZenEngineTests",
dependencies: ["ZenUniffi"],
path: "Tests/ZenEngineTests",
swiftSettings: [.swiftLanguageMode(.v5)],
linkerSettings: [
.linkedFramework("Security"),
.linkedFramework("SystemConfiguration"),
.linkedFramework("CoreFoundation")
]
)
]
)
@@ -0,0 +1,217 @@
import Foundation
import Testing
@testable import ZenUniffi
enum TestDataError: Error {
case notFound
}
func testDataRoot() throws -> URL {
var current = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
while current.path != "/" {
let candidate = current.appendingPathComponent("test-data")
if FileManager.default.fileExists(atPath: candidate.path) {
return candidate
}
current = current.deletingLastPathComponent()
}
throw TestDataError.notFound
}
func readTestFile(_ name: String) throws -> Data {
try Data(contentsOf: testDataRoot().appendingPathComponent(name))
}
func json(_ buffer: Data) throws -> [String: Any] {
try #require(JSONSerialization.jsonObject(with: buffer) as? [String: Any])
}
final class FilesystemCallback: ZenDecisionLoaderCallback {
let root: URL
init(root: URL) {
self.root = root
}
func load(key: String) async throws -> JsonBuffer? {
try? Data(contentsOf: root.appendingPathComponent(key))
}
}
final class SumNodeCallback: ZenCustomNodeCallback, @unchecked Sendable {
var seenRequest: ZenEngineHandlerRequest?
func handle(key: ZenEngineHandlerRequest) async throws -> ZenEngineHandlerResponse {
seenRequest = key
let input = try JSONSerialization.jsonObject(with: key.input) as? [String: Any]
let a = input?["a"] as? Int ?? 0
let output = try JSONSerialization.data(withJSONObject: ["data": a + 20])
return ZenEngineHandlerResponse(output: output, traceData: nil)
}
}
struct ZenEngineTests {
@Test func staticLoader() async throws {
let loader = ZenLoader.static(content: ["table.json": try readTestFile("table.json")])
let engine = try ZenEngine(loader: loader)
let response = try await engine.evaluate(
key: "table.json",
context: Data("{\"input\":12}".utf8),
options: nil
)
#expect(try json(response.result)["output"] as? Int == 10)
#expect(response.trace == nil)
}
@Test func filesystemLoader() async throws {
let engine = try ZenEngine(loader: .filesystem(path: testDataRoot().path))
let response = try await engine.evaluate(
key: "table.json",
context: Data("{\"input\":5}".utf8),
options: nil
)
#expect(try json(response.result)["output"] as? Int == 0)
}
@Test func invalidZipFailsOnConstruction() {
#expect(throws: (any Error).self) {
try ZenEngine(loader: .zip(bytes: Data([1, 2, 3, 4])))
}
}
@Test func callbackLoader() async throws {
let callback = FilesystemCallback(root: try testDataRoot())
let engine = try ZenEngine(loader: .callback(callback: callback))
let response = try await engine.evaluate(
key: "table.json",
context: Data("{\"input\":12}".utf8),
options: nil
)
#expect(try json(response.result)["output"] as? Int == 10)
await #expect(throws: (any Error).self) {
try await engine.evaluate(key: "missing.json", context: Data("{}".utf8), options: nil)
}
}
@Test func missingKeyFails() async throws {
let engine = try ZenEngine(loader: .static(content: [:]))
await #expect(throws: (any Error).self) {
try await engine.evaluate(key: "missing.json", context: Data("{}".utf8), options: nil)
}
}
@Test func createDecision() async throws {
let engine = try ZenEngine()
let decision = try engine.createDecision(content: try readTestFile("table.json"))
try decision.validate()
let response = try await decision.evaluate(context: Data("{\"input\":12}".utf8), options: nil)
#expect(try json(response.result)["output"] as? Int == 10)
}
@Test func getDecision() async throws {
let engine = try ZenEngine(loader: .filesystem(path: testDataRoot().path))
let decision = try await engine.getDecision(key: "table.json")
let response = try await decision.evaluate(context: Data("{\"input\":12}".utf8), options: nil)
#expect(try json(response.result)["output"] as? Int == 10)
}
@Test func evaluateBatch() async throws {
let engine = try ZenEngine(loader: .filesystem(path: testDataRoot().path))
let results = await engine.evaluateBatch(
requests: [
ZenBatchRequest(key: "table.json", context: Data("{\"input\":12}".utf8)),
ZenBatchRequest(key: "missing.json", context: Data("{}".utf8)),
ZenBatchRequest(key: "table.json", context: Data("{\"input\":5}".utf8)),
],
options: nil
)
#expect(results.count == 3)
#expect(results[0].success)
#expect(try json(#require(results[0].data).result)["output"] as? Int == 10)
#expect(!results[1].success)
#expect(results[1].error != nil)
#expect(results[2].success)
#expect(try json(#require(results[2].data).result)["output"] as? Int == 0)
}
@Test func traceOption() async throws {
let engine = try ZenEngine(loader: .filesystem(path: testDataRoot().path))
let response = try await engine.evaluate(
key: "table.json",
context: Data("{\"input\":12}".utf8),
options: ZenEvaluateOptions(maxDepth: 5, trace: true)
)
let trace = try #require(response.trace)
#expect(!trace.isEmpty)
}
@Test func expressionGraph() async throws {
let engine = try ZenEngine(loader: .filesystem(path: testDataRoot().path))
let context = Data("{\"numbers\":[1,5,15,25],\"firstName\":\"John\",\"lastName\":\"Doe\"}".utf8)
let response = try await engine.evaluate(key: "expression.json", context: context, options: nil)
let result = try json(response.result)
let expected = try json(Data(
"{\"deep\":{\"nested\":{\"sum\":46}},\"fullName\":\"John Doe\",\"largeNumbers\":[15,25],\"smallNumbers\":[1,5]}".utf8
))
#expect(result as NSDictionary == expected as NSDictionary)
}
@Test func functionGraph() async throws {
let engine = try ZenEngine(loader: .filesystem(path: testDataRoot().path))
let response = try await engine.evaluate(
key: "function.json",
context: Data("{\"input\":15}".utf8),
options: nil
)
#expect(try json(response.result)["output"] as? Int == 30)
}
@Test func customNodeHandler() async throws {
let handler = SumNodeCallback()
let engine = try ZenEngine(loader: .filesystem(path: testDataRoot().path), customNode: handler)
let response = try await engine.evaluate(
key: "custom.json",
context: Data("{\"a\":5}".utf8),
options: nil
)
#expect(try json(response.result)["data"] as? Int == 25)
let request = try #require(handler.seenRequest)
#expect(request.node.kind == "sum")
#expect(request.node.name == "customNode1")
#expect(try json(request.node.config)["prop1"] as? String == "{{ a + 10 }}")
}
@Test func evaluateExpressionFunction() throws {
let result = try evaluateExpression(
expression: "sum(numbers)",
context: Data("{\"numbers\":[1,2,3]}".utf8)
)
let value = try JSONSerialization.jsonObject(with: result, options: [.fragmentsAllowed])
#expect(value as? Int == 6)
}
@Test func evaluateUnaryExpressionFunction() throws {
#expect(try evaluateUnaryExpression(expression: "$ > 10", context: Data("{\"$\":15}".utf8)))
#expect(try !evaluateUnaryExpression(expression: "$ > 10", context: Data("{\"$\":5}".utf8)))
}
@Test func invalidExpressionFails() {
#expect(throws: (any Error).self) {
try evaluateExpression(expression: "a +* b", context: Data("{}".utf8))
}
}
@Test func compiledExpression() throws {
let expression = try ZenExpression.compile(expression: "a + b")
let result = try expression.evaluate(context: Data("{\"a\":1,\"b\":2}".utf8))
let value = try JSONSerialization.jsonObject(with: result, options: [.fragmentsAllowed])
#expect(value as? Int == 3)
let unary = try ZenExpressionUnary.compile(expression: "$ > 3")
#expect(try unary.evaluate(context: Data("{\"$\":4}".utf8)))
#expect(try !unary.evaluate(context: Data("{\"$\":2}".utf8)))
}
}