mirror of
https://github.com/gorules/zen.git
synced 2026-10-04 08:02:28 +00:00
chore: add uniffi tests
This commit is contained in:
@@ -102,10 +102,123 @@ 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
|
||||
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
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
- name: Generate C# sources
|
||||
run: |
|
||||
uniffi-bindgen-cs \
|
||||
--library build/generated/resources/libzen_uniffi.so \
|
||||
--config uniffi.toml \
|
||||
--out-dir build/generated/csharp
|
||||
|
||||
- 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
|
||||
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 +292,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 +529,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:
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}""")))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user