java shims and tests
This commit is contained in:
parent
e819a8d0bf
commit
23c73f8513
3 changed files with 1089 additions and 0 deletions
783
engine/src/jni.zig
Normal file
783
engine/src/jni.zig
Normal file
|
|
@ -0,0 +1,783 @@
|
|||
//! JNI entry points for the Android app. Compiled only for Android targets.
|
||||
//!
|
||||
//! This is the one Android-specific layer (design 6.3). It is deliberately thin:
|
||||
//! convert a `jstring` to bytes, call the C ABI, copy the answer into a Java string,
|
||||
//! return. Everything below it is `c_api.zig`, which knows nothing about Java.
|
||||
//!
|
||||
//! That copy is also what makes the C ABI's borrowed-result rule safe here: the bytes
|
||||
//! are turned into a JVM object inside the same function that asked for them, so the
|
||||
//! app never holds a native pointer and cannot outlive one.
|
||||
//!
|
||||
//! ## Why the function table is written out by hand
|
||||
//!
|
||||
//! JNI passes a `JNIEnv*`, which is a pointer to a table of function pointers at
|
||||
//! fixed indices. Reaching them normally means including `jni.h` from the NDK. We do
|
||||
//! not: the library is built with no NDK and no libc at all (design 6.2), and pulling
|
||||
//! in a sysroot for three functions would undo that.
|
||||
//!
|
||||
//! The price is that four integers below have to be right, and a wrong one is a jump
|
||||
//! into the wrong function rather than a compile error. Three things hold that down:
|
||||
//!
|
||||
//! 1. The table is declared as its full published length. `table_len` is asserted at
|
||||
//! comptime, so an index past the end cannot compile.
|
||||
//! 2. `JNI_OnLoad` proves the indices against the real JVM before anything else runs:
|
||||
//! it checks the version, then round-trips a string through `NewStringUTF` and
|
||||
//! `GetStringUTFChars` and compares the bytes. If the table is not what this file
|
||||
//! thinks it is, `System.loadLibrary` fails at load with `JNI_ERR` instead of the
|
||||
//! app crashing later inside an evaluation.
|
||||
//! 3. The tests at the bottom run every entry point against a synthetic table, which
|
||||
//! catches the marshalling and the self-check logic. What they cannot catch is a
|
||||
//! transcription error in the indices themselves - only a real JVM can - which is
|
||||
//! exactly what point 2 exists for.
|
||||
|
||||
const std = @import("std");
|
||||
const c_api = @import("c_api.zig");
|
||||
|
||||
// -- Std configuration --
|
||||
|
||||
/// Answers "how big is a page?" without asking the operating system.
|
||||
///
|
||||
/// `std.heap.page_allocator` rounds every request up to `std.heap.pageSize()`. Where
|
||||
/// Zig's minimum and maximum for a target are equal that is a comptime constant, but on
|
||||
/// aarch64 the range is 4 KiB to 64 KiB, so it becomes a runtime query - and the way std
|
||||
/// asks is `getauxval`, which lives in a libc this library does not link. The result was
|
||||
/// an undefined `getauxval` on arm64-v8a only, with an eagerly bound PLT entry
|
||||
/// (`BIND_NOW`) that fails `dlopen` on the one ABI most phones actually run. x86_64 and
|
||||
/// armeabi-v7a were clean, so an emulator alone would not have caught it.
|
||||
///
|
||||
/// Only the ceiling and the query are overridden, deliberately. `page_size_min` is what
|
||||
/// std documents as the alignment a caller may assume from a page allocation, and on a
|
||||
/// 4 KiB kernel `mmap` returns 4 KiB-aligned memory: raising the minimum would promise an
|
||||
/// alignment the kernel does not give. Raising only the maximum, and answering the query
|
||||
/// with it, affects the direction that is safe - lengths get rounded up to a multiple of
|
||||
/// the real page size, never down.
|
||||
///
|
||||
/// 16 KiB is the honest ceiling for current Android rather than a guess: Android 15
|
||||
/// introduced 16 KiB pages and the platform requires libraries to be aligned for them,
|
||||
/// which is the same number `link_z_max_page_size` uses in `build.zig`. The cost of
|
||||
/// assuming it on a 4 KiB device is granularity on allocations the session's arena is
|
||||
/// already batching.
|
||||
pub const std_options: std.Options = .{
|
||||
.page_size_max = 16 * 1024,
|
||||
.queryPageSize = androidPageSize,
|
||||
};
|
||||
|
||||
fn androidPageSize() usize {
|
||||
return 16 * 1024;
|
||||
}
|
||||
|
||||
// -- Panic --
|
||||
|
||||
/// Replaces std's default panic handler, for this library only.
|
||||
///
|
||||
/// The default handler is what pulled the last two Bionic symbols into this image, and
|
||||
/// it did so on a path that only a bug would ever reach. It prints a stack trace, which
|
||||
/// reads the library's own ELF headers through `getauxval`, and it guards against
|
||||
/// recursive panics with a thread-local, which needs `__tls_get_addr`. Neither is
|
||||
/// defined here and nothing is linked that defines them, so `dlopen` fails with
|
||||
/// "cannot locate symbol __tls_get_addr" at load - before `JNI_OnLoad`, before any
|
||||
/// calculation, on a device that is working perfectly. A debugging aid cost the whole
|
||||
/// library its ability to load.
|
||||
///
|
||||
/// Writing the message to stderr and trapping gives up nothing on Android. The trap
|
||||
/// raises a fatal signal, and the platform's crash handler writes a tombstone with a
|
||||
/// native backtrace of its own, which is a better trace than std would have printed
|
||||
/// here and is produced by code that is already resident.
|
||||
///
|
||||
/// Only the Android build sees this: it is the one that uses this file as its root, and
|
||||
/// a panic decl is read from the root. The tests below run under the test runner's own
|
||||
/// root and keep the default handler, so a failing assertion still reports normally.
|
||||
pub const panic = std.debug.FullPanic(panicTrap);
|
||||
|
||||
fn panicTrap(msg: []const u8, first_trace_addr: ?usize) noreturn {
|
||||
_ = first_trace_addr;
|
||||
// Best effort by design: a closed or redirected stderr is not worth handling on the
|
||||
// way down, and the tombstone carries the failure regardless.
|
||||
const prefix = "tally panic: ";
|
||||
_ = std.os.linux.write(2, prefix.ptr, prefix.len);
|
||||
_ = std.os.linux.write(2, msg.ptr, msg.len);
|
||||
_ = std.os.linux.write(2, "\n", 1);
|
||||
@trap();
|
||||
}
|
||||
|
||||
// -- JNI types --
|
||||
//
|
||||
// The subset this file touches. `jobject` and friends are opaque handles; nothing here
|
||||
// dereferences one.
|
||||
|
||||
const jint = i32;
|
||||
const jlong = i64;
|
||||
const jboolean = u8;
|
||||
const jobject = ?*anyopaque;
|
||||
const jstring = jobject;
|
||||
|
||||
const jni_version_1_6: jint = 0x00010006;
|
||||
const jni_err: jint = -1;
|
||||
|
||||
/// Entries in `JNINativeInterface`, which has had this length since JNI 1.6.
|
||||
const table_len = 233;
|
||||
|
||||
/// The four we call, by index into that table.
|
||||
///
|
||||
/// Derived from the function table in the JNI specification, where the order is
|
||||
/// normative: the four reserved slots, then `GetVersion`, through the class, exception,
|
||||
/// reference, call, field and static-field groups, to the string group at 163. Within
|
||||
/// it: `NewString` 163, `GetStringLength` 164, `GetStringChars` 165,
|
||||
/// `ReleaseStringChars` 166, `NewStringUTF` 167, `GetStringUTFLength` 168,
|
||||
/// `GetStringUTFChars` 169, `ReleaseStringUTFChars` 170. The count arrives at exactly
|
||||
/// `table_len` entries with `GetObjectRefType` last, which is the arithmetic check that
|
||||
/// nothing was dropped along the way.
|
||||
const EnvIndex = enum(usize) {
|
||||
get_version = 4,
|
||||
new_string_utf = 167,
|
||||
get_string_utf_chars = 169,
|
||||
release_string_utf_chars = 170,
|
||||
};
|
||||
|
||||
/// `JavaVM`'s table is short enough to be uncontroversial: three reserved slots, then
|
||||
/// `DestroyJavaVM`, `AttachCurrentThread`, `DetachCurrentThread`, `GetEnv`,
|
||||
/// `AttachCurrentThreadAsDaemon`.
|
||||
const VmIndex = enum(usize) {
|
||||
get_env = 6,
|
||||
};
|
||||
|
||||
const vm_table_len = 8;
|
||||
|
||||
comptime {
|
||||
for (std.enums.values(EnvIndex)) |index| {
|
||||
if (@intFromEnum(index) >= table_len) @compileError("JNI index past the end of the table");
|
||||
}
|
||||
for (std.enums.values(VmIndex)) |index| {
|
||||
if (@intFromEnum(index) >= vm_table_len) @compileError("JavaVM index past the end of the table");
|
||||
}
|
||||
}
|
||||
|
||||
/// `JNINativeInterface`: a table of function pointers, reached by index because the
|
||||
/// names are not available without the NDK's headers.
|
||||
pub const FunctionTable = extern struct {
|
||||
entries: [table_len]?*const anyopaque,
|
||||
};
|
||||
|
||||
/// `JNIEnv`, which C declares as a pointer to the table. A `JNIEnv*` parameter is
|
||||
/// therefore a pointer to this.
|
||||
pub const Env = extern struct {
|
||||
functions: *const FunctionTable,
|
||||
|
||||
fn call(self: *Env, comptime index: EnvIndex, comptime Signature: type) Signature {
|
||||
const slot = self.functions.entries[@intFromEnum(index)] orelse
|
||||
@panic("JNI function table has a null entry where a function belongs");
|
||||
return @ptrCast(@alignCast(slot));
|
||||
}
|
||||
|
||||
fn getVersion(self: *Env) jint {
|
||||
return self.call(.get_version, *const fn (*Env) callconv(.c) jint)(self);
|
||||
}
|
||||
|
||||
/// A Java string from NUL-terminated modified UTF-8. Every byte this library
|
||||
/// produces is ASCII - digits, unit names, the error phrases - and ASCII is
|
||||
/// identical in both encodings, so the distinction never arises here.
|
||||
fn newStringUtf(self: *Env, bytes: [*:0]const u8) jstring {
|
||||
return self.call(
|
||||
.new_string_utf,
|
||||
*const fn (*Env, [*:0]const u8) callconv(.c) jstring,
|
||||
)(self, bytes);
|
||||
}
|
||||
|
||||
fn getStringUtfChars(self: *Env, string: jstring) ?[*:0]const u8 {
|
||||
return self.call(
|
||||
.get_string_utf_chars,
|
||||
*const fn (*Env, jstring, ?*jboolean) callconv(.c) ?[*:0]const u8,
|
||||
)(self, string, null);
|
||||
}
|
||||
|
||||
fn releaseStringUtfChars(self: *Env, string: jstring, chars: [*:0]const u8) void {
|
||||
self.call(
|
||||
.release_string_utf_chars,
|
||||
*const fn (*Env, jstring, [*:0]const u8) callconv(.c) void,
|
||||
)(self, string, chars);
|
||||
}
|
||||
};
|
||||
|
||||
/// `JavaVM`, the same shape: a pointer to its own table.
|
||||
pub const JavaVm = extern struct {
|
||||
functions: *const VmTable,
|
||||
|
||||
pub const VmTable = extern struct {
|
||||
entries: [vm_table_len]?*const anyopaque,
|
||||
};
|
||||
|
||||
fn getEnv(self: *JavaVm, out: **Env, version: jint) jint {
|
||||
const slot = self.functions.entries[@intFromEnum(VmIndex.get_env)] orelse return jni_err;
|
||||
const get_env: *const fn (*JavaVm, **Env, jint) callconv(.c) jint = @ptrCast(@alignCast(slot));
|
||||
return get_env(self, out, version);
|
||||
}
|
||||
};
|
||||
|
||||
// -- Entry points --
|
||||
//
|
||||
// Kotlin declares these in `object TallyEngine`, so each takes the instance as its
|
||||
// second argument. Nothing here uses it.
|
||||
//
|
||||
// The session travels as a `jlong`. On a 32-bit ABI a pointer is narrower than the
|
||||
// handle, which is why the conversions go through `usize` rather than casting straight
|
||||
// across.
|
||||
|
||||
fn sessionFromHandle(handle: jlong) ?*c_api.Session {
|
||||
if (handle == 0) return null;
|
||||
return @ptrFromInt(@as(usize, @intCast(handle)));
|
||||
}
|
||||
|
||||
fn handleFromSession(session: *c_api.Session) jlong {
|
||||
return @intCast(@intFromPtr(session));
|
||||
}
|
||||
|
||||
export fn Java_dev_lerch_tally_TallyEngine_sessionNew(env: *Env, this: jobject) callconv(.c) jlong {
|
||||
_ = env;
|
||||
_ = this;
|
||||
const session = c_api.tally_session_new() orelse return 0;
|
||||
return handleFromSession(session);
|
||||
}
|
||||
|
||||
export fn Java_dev_lerch_tally_TallyEngine_sessionFree(env: *Env, this: jobject, handle: jlong) callconv(.c) void {
|
||||
_ = env;
|
||||
_ = this;
|
||||
c_api.tally_session_free(sessionFromHandle(handle));
|
||||
}
|
||||
|
||||
/// Evaluate and return the result JSON. A null return means the call could not be made
|
||||
/// at all (no session, or the JVM would not give us the expression); an evaluation
|
||||
/// failure is JSON describing itself, exactly as through the C ABI.
|
||||
export fn Java_dev_lerch_tally_TallyEngine_eval(
|
||||
env: *Env,
|
||||
this: jobject,
|
||||
handle: jlong,
|
||||
expr: jstring,
|
||||
mode: jint,
|
||||
) callconv(.c) jstring {
|
||||
_ = this;
|
||||
return evaluate(c_api.tally_eval, env, handle, expr, mode);
|
||||
}
|
||||
|
||||
/// What `eval` would return, with nothing stored in the session (design 6.5). Called on
|
||||
/// every keystroke by the live result.
|
||||
export fn Java_dev_lerch_tally_TallyEngine_preview(
|
||||
env: *Env,
|
||||
this: jobject,
|
||||
handle: jlong,
|
||||
expr: jstring,
|
||||
mode: jint,
|
||||
) callconv(.c) jstring {
|
||||
_ = this;
|
||||
return evaluate(c_api.tally_preview, env, handle, expr, mode);
|
||||
}
|
||||
|
||||
/// The marshalling `eval` and `preview` share. The C entry point is a comptime
|
||||
/// parameter, so each export is a direct call rather than an indirect one.
|
||||
fn evaluate(
|
||||
comptime entry: fn (?*c_api.Session, ?[*]const u8, usize, c_int, ?*[*]const u8, ?*usize) callconv(.c) c_api.Status,
|
||||
env: *Env,
|
||||
handle: jlong,
|
||||
expr: jstring,
|
||||
mode: jint,
|
||||
) jstring {
|
||||
const session = sessionFromHandle(handle) orelse return null;
|
||||
const chars = env.getStringUtfChars(expr) orelse return null;
|
||||
defer env.releaseStringUtfChars(expr, chars);
|
||||
|
||||
// SAFETY: written by every call that gets past its argument checks. A refused call
|
||||
// may leave them alone, which is why the status is checked before they are read.
|
||||
var out_ptr: [*]const u8 = undefined;
|
||||
var out_len: usize = 0;
|
||||
const status = entry(
|
||||
session,
|
||||
chars,
|
||||
std.mem.len(chars),
|
||||
mode,
|
||||
&out_ptr,
|
||||
&out_len,
|
||||
);
|
||||
return resultString(env, status, out_ptr);
|
||||
}
|
||||
|
||||
/// A result as a Java string, or null when there is none to give: out of memory, or a
|
||||
/// call refused before it ran (an unknown mode, say). Kotlin reads null as "the engine
|
||||
/// could not answer", which is true of both; what it must never get is the previous
|
||||
/// call's JSON, or a string read from a pointer nothing wrote.
|
||||
fn resultString(env: *Env, status: c_api.Status, out_ptr: [*]const u8) jstring {
|
||||
switch (status) {
|
||||
// `c_api` leaves a NUL after the JSON for exactly this, so the copy the JVM
|
||||
// makes is the only copy in the call.
|
||||
.ok, .eval_error => return env.newStringUtf(@ptrCast(out_ptr)),
|
||||
.out_of_memory, .invalid_argument => return null,
|
||||
}
|
||||
}
|
||||
|
||||
export fn Java_dev_lerch_tally_TallyEngine_unitCatalog(
|
||||
env: *Env,
|
||||
this: jobject,
|
||||
handle: jlong,
|
||||
) callconv(.c) jstring {
|
||||
_ = this;
|
||||
const session = sessionFromHandle(handle) orelse return null;
|
||||
|
||||
// SAFETY: as in `evaluate`; read only once the status says they were written.
|
||||
var out_ptr: [*]const u8 = undefined;
|
||||
var out_len: usize = 0;
|
||||
const status = c_api.tally_unit_catalog(session, &out_ptr, &out_len);
|
||||
return resultString(env, status, out_ptr);
|
||||
}
|
||||
|
||||
/// A value in every unit of `unit`'s category, as JSON (design 6.6). Null when the call
|
||||
/// could not be made at all; a bad value or unit is JSON describing itself.
|
||||
export fn Java_dev_lerch_tally_TallyEngine_convert(
|
||||
env: *Env,
|
||||
this: jobject,
|
||||
handle: jlong,
|
||||
value: jstring,
|
||||
unit: jstring,
|
||||
) callconv(.c) jstring {
|
||||
_ = this;
|
||||
const session = sessionFromHandle(handle) orelse return null;
|
||||
const value_chars = env.getStringUtfChars(value) orelse return null;
|
||||
defer env.releaseStringUtfChars(value, value_chars);
|
||||
const unit_chars = env.getStringUtfChars(unit) orelse return null;
|
||||
defer env.releaseStringUtfChars(unit, unit_chars);
|
||||
|
||||
// SAFETY: as in `evaluate`; read only once the status says they were written.
|
||||
var out_ptr: [*]const u8 = undefined;
|
||||
var out_len: usize = 0;
|
||||
const status = c_api.tally_convert(
|
||||
session,
|
||||
value_chars,
|
||||
std.mem.len(value_chars),
|
||||
unit_chars,
|
||||
std.mem.len(unit_chars),
|
||||
&out_ptr,
|
||||
&out_len,
|
||||
);
|
||||
return resultString(env, status, out_ptr);
|
||||
}
|
||||
|
||||
export fn Java_dev_lerch_tally_TallyEngine_versionString(env: *Env, this: jobject) callconv(.c) jstring {
|
||||
_ = this;
|
||||
var len: usize = 0;
|
||||
const version = c_api.tally_version(&len);
|
||||
return env.newStringUtf(@ptrCast(version));
|
||||
}
|
||||
|
||||
export fn Java_dev_lerch_tally_TallyEngine_abiVersion(env: *Env, this: jobject) callconv(.c) jint {
|
||||
_ = env;
|
||||
_ = this;
|
||||
return @intCast(c_api.tally_abi_version());
|
||||
}
|
||||
|
||||
/// The session's variables and `Ans` as JSON, for the app to keep across a restart. Null
|
||||
/// when the call could not be made at all.
|
||||
export fn Java_dev_lerch_tally_TallyEngine_sessionSave(env: *Env, this: jobject, handle: jlong) callconv(.c) jstring {
|
||||
_ = this;
|
||||
const session = sessionFromHandle(handle) orelse return null;
|
||||
// SAFETY: as in `evaluate`; read only once the status says they were written.
|
||||
var out_ptr: [*]const u8 = undefined;
|
||||
var out_len: usize = 0;
|
||||
if (c_api.tally_session_save(session, &out_ptr, &out_len) != .ok) return null;
|
||||
return env.newStringUtf(@ptrCast(out_ptr));
|
||||
}
|
||||
|
||||
/// Put a saved state back. Returns the `tally_status`; anything but ok leaves the
|
||||
/// session as it was.
|
||||
export fn Java_dev_lerch_tally_TallyEngine_sessionLoad(
|
||||
env: *Env,
|
||||
this: jobject,
|
||||
handle: jlong,
|
||||
state: jstring,
|
||||
) callconv(.c) jint {
|
||||
_ = this;
|
||||
const invalid: jint = @intFromEnum(c_api.Status.invalid_argument);
|
||||
const session = sessionFromHandle(handle) orelse return invalid;
|
||||
const chars = env.getStringUtfChars(state) orelse return invalid;
|
||||
defer env.releaseStringUtfChars(state, chars);
|
||||
return @intFromEnum(c_api.tally_session_load(session, chars, std.mem.len(chars)));
|
||||
}
|
||||
|
||||
/// Set how many digits a session renders, leaving everything else in its configuration
|
||||
/// as it was. A calculator wants every digit of `2^100`; a converter's list of a dozen
|
||||
/// units does not want twenty decimals on each. Returns the `tally_status`.
|
||||
export fn Java_dev_lerch_tally_TallyEngine_configureDisplay(
|
||||
env: *Env,
|
||||
this: jobject,
|
||||
handle: jlong,
|
||||
fraction_digits: jint,
|
||||
significant_digits: jint,
|
||||
) callconv(.c) jint {
|
||||
_ = env;
|
||||
_ = this;
|
||||
const session = sessionFromHandle(handle) orelse return @intFromEnum(c_api.Status.invalid_argument);
|
||||
if (fraction_digits < 0 or fraction_digits > std.math.maxInt(u16) or
|
||||
significant_digits < 1 or significant_digits > std.math.maxInt(u16))
|
||||
{
|
||||
return @intFromEnum(c_api.Status.invalid_argument);
|
||||
}
|
||||
var config: c_api.Config = .{};
|
||||
_ = c_api.tally_session_config(session, &config);
|
||||
config.fraction_digits = @intCast(fraction_digits);
|
||||
config.significant_digits = @intCast(significant_digits);
|
||||
return @intFromEnum(c_api.tally_session_configure(session, &config));
|
||||
}
|
||||
|
||||
/// Called by the runtime when the library loads.
|
||||
///
|
||||
/// This is where the hand-written table is proved against the JVM that is actually
|
||||
/// running. Failing here surfaces as an exception from `System.loadLibrary`, which is
|
||||
/// the best available outcome if the indices are wrong: loud, immediate, and nowhere
|
||||
/// near a user's expression.
|
||||
export fn JNI_OnLoad(vm: *JavaVm, reserved: ?*anyopaque) callconv(.c) jint {
|
||||
_ = reserved;
|
||||
// SAFETY: `getEnv` writes this, and its return value is checked before it is read.
|
||||
var env: *Env = undefined;
|
||||
if (vm.getEnv(&env, jni_version_1_6) != 0) return jni_err;
|
||||
if (!selfCheck(env)) return jni_err;
|
||||
return jni_version_1_6;
|
||||
}
|
||||
|
||||
/// Prove the three string functions are where this file says they are.
|
||||
///
|
||||
/// A version that looks like a JNI version, then a string round trip: build one from
|
||||
/// known bytes, read it back, compare. Wrong indices will usually crash rather than
|
||||
/// return the wrong answer, and that is fine - the point is that it happens here, in a
|
||||
/// function whose name says what it was doing.
|
||||
fn selfCheck(env: *Env) bool {
|
||||
if (env.getVersion() < jni_version_1_6) return false;
|
||||
|
||||
const probe = "tally";
|
||||
const made = env.newStringUtf(probe) orelse return false;
|
||||
const read_back = env.getStringUtfChars(made) orelse return false;
|
||||
defer env.releaseStringUtfChars(made, read_back);
|
||||
return std.mem.orderZ(u8, read_back, probe) == .eq;
|
||||
}
|
||||
|
||||
// -- Tests --
|
||||
//
|
||||
// A synthetic function table stands in for the JVM: entries at the indices this file
|
||||
// claims, backed by implementations that record what they were asked. That covers the
|
||||
// marshalling, the handle arithmetic, the NUL-terminated hand-off from `c_api`, and the
|
||||
// self-check in both directions. It cannot verify the indices against a real JVM, which
|
||||
// is what `JNI_OnLoad` is for.
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
/// A fake JVM, holding the strings it was asked to make.
|
||||
const FakeJvm = struct {
|
||||
table: FunctionTable,
|
||||
vm_table: JavaVm.VmTable,
|
||||
env: Env,
|
||||
vm: JavaVm,
|
||||
/// The last string `NewStringUTF` was given, NUL-terminated as the JVM would see it.
|
||||
made: [4096]u8 = @splat(0),
|
||||
made_len: usize = 0,
|
||||
/// What `GetStringUTFChars` hands out, i.e. the expression coming from Kotlin.
|
||||
supplied: [:0]const u8 = "",
|
||||
releases: usize = 0,
|
||||
/// Set to break the table the way a wrong index would.
|
||||
misplace_new_string: bool = false,
|
||||
|
||||
var current: ?*FakeJvm = null;
|
||||
|
||||
fn init(self: *FakeJvm) void {
|
||||
self.table = .{ .entries = @splat(null) };
|
||||
self.table.entries[@intFromEnum(EnvIndex.get_version)] = @ptrCast(&getVersion);
|
||||
self.table.entries[@intFromEnum(EnvIndex.new_string_utf)] = @ptrCast(&newStringUtf);
|
||||
self.table.entries[@intFromEnum(EnvIndex.get_string_utf_chars)] = @ptrCast(&getStringUtfChars);
|
||||
self.table.entries[@intFromEnum(EnvIndex.release_string_utf_chars)] = @ptrCast(&releaseStringUtfChars);
|
||||
self.env = .{ .functions = &self.table };
|
||||
|
||||
self.vm_table = .{ .entries = @splat(null) };
|
||||
self.vm_table.entries[@intFromEnum(VmIndex.get_env)] = @ptrCast(&getEnv);
|
||||
self.vm = .{ .functions = &self.vm_table };
|
||||
|
||||
self.made_len = 0;
|
||||
self.releases = 0;
|
||||
current = self;
|
||||
}
|
||||
|
||||
fn madeString(self: *const FakeJvm) []const u8 {
|
||||
return self.made[0..self.made_len];
|
||||
}
|
||||
|
||||
fn getVersion(env: *Env) callconv(.c) jint {
|
||||
_ = env;
|
||||
return jni_version_1_6;
|
||||
}
|
||||
|
||||
fn newStringUtf(env: *Env, bytes: [*:0]const u8) callconv(.c) jstring {
|
||||
_ = env;
|
||||
const self = current.?;
|
||||
if (self.misplace_new_string) return null;
|
||||
const text = std.mem.span(bytes);
|
||||
const n = @min(text.len, self.made.len - 1);
|
||||
@memcpy(self.made[0..n], text[0..n]);
|
||||
self.made[n] = 0;
|
||||
self.made_len = n;
|
||||
// A JVM returns an opaque handle; the identity is all this test needs.
|
||||
return @ptrCast(self);
|
||||
}
|
||||
|
||||
fn getStringUtfChars(env: *Env, string: jstring, is_copy: ?*jboolean) callconv(.c) ?[*:0]const u8 {
|
||||
_ = env;
|
||||
if (is_copy) |flag| flag.* = 1;
|
||||
const self = current.?;
|
||||
// Two callers, distinguished the way a JVM would distinguish them: by object.
|
||||
// `newStringUtf` hands back this fake's own address, so a string made here reads
|
||||
// back as what went in (which is what the self-check relies on), while the
|
||||
// expression coming from Kotlin is a different object.
|
||||
if (string != null) return @ptrCast(self.made[0..self.made_len :0].ptr);
|
||||
return self.supplied.ptr;
|
||||
}
|
||||
|
||||
fn releaseStringUtfChars(env: *Env, string: jstring, chars: [*:0]const u8) callconv(.c) void {
|
||||
_ = env;
|
||||
_ = string;
|
||||
_ = chars;
|
||||
current.?.releases += 1;
|
||||
}
|
||||
|
||||
fn getEnv(vm: *JavaVm, out: **Env, version: jint) callconv(.c) jint {
|
||||
_ = version;
|
||||
const self = current.?;
|
||||
_ = vm;
|
||||
out.* = &self.env;
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
test "the table this file declares is the length the JNI specification gives it" {
|
||||
// A dropped or duplicated entry would move everything after it, so the length is
|
||||
// the one cheap check available without a JVM.
|
||||
try testing.expectEqual(@as(usize, 233), table_len);
|
||||
try testing.expectEqual(table_len * @sizeOf(usize), @sizeOf(FunctionTable));
|
||||
try testing.expectEqual(vm_table_len * @sizeOf(usize), @sizeOf(JavaVm.VmTable));
|
||||
// And `JNIEnv*` has to be a pointer to a pointer to the table, not to the table.
|
||||
try testing.expectEqual(@sizeOf(usize), @sizeOf(Env));
|
||||
}
|
||||
|
||||
test "an evaluation crosses JNI and comes back as JSON" {
|
||||
var jvm: FakeJvm = undefined;
|
||||
jvm.init();
|
||||
|
||||
const handle = Java_dev_lerch_tally_TallyEngine_sessionNew(&jvm.env, null);
|
||||
try testing.expect(handle != 0);
|
||||
defer Java_dev_lerch_tally_TallyEngine_sessionFree(&jvm.env, null, handle);
|
||||
|
||||
jvm.supplied = "2 + 2";
|
||||
const result = Java_dev_lerch_tally_TallyEngine_eval(&jvm.env, null, handle, null, 0);
|
||||
try testing.expect(result != null);
|
||||
try testing.expect(std.mem.indexOf(u8, jvm.madeString(), "\"display\":\"4\"") != null);
|
||||
// The expression handed over by the JVM was released, once.
|
||||
try testing.expectEqual(@as(usize, 1), jvm.releases);
|
||||
}
|
||||
|
||||
test "a session survives across calls, which is the whole reason for the handle" {
|
||||
var jvm: FakeJvm = undefined;
|
||||
jvm.init();
|
||||
|
||||
const handle = Java_dev_lerch_tally_TallyEngine_sessionNew(&jvm.env, null);
|
||||
defer Java_dev_lerch_tally_TallyEngine_sessionFree(&jvm.env, null, handle);
|
||||
|
||||
jvm.supplied = "x = 21";
|
||||
_ = Java_dev_lerch_tally_TallyEngine_eval(&jvm.env, null, handle, null, 0);
|
||||
jvm.supplied = "x * 2";
|
||||
_ = Java_dev_lerch_tally_TallyEngine_eval(&jvm.env, null, handle, null, 0);
|
||||
try testing.expect(std.mem.indexOf(u8, jvm.madeString(), "\"display\":\"42\"") != null);
|
||||
|
||||
jvm.supplied = "Ans + 1";
|
||||
_ = Java_dev_lerch_tally_TallyEngine_eval(&jvm.env, null, handle, null, 0);
|
||||
try testing.expect(std.mem.indexOf(u8, jvm.madeString(), "\"display\":\"43\"") != null);
|
||||
}
|
||||
|
||||
test "programmer mode reaches through, and an error comes back as JSON rather than null" {
|
||||
var jvm: FakeJvm = undefined;
|
||||
jvm.init();
|
||||
|
||||
const handle = Java_dev_lerch_tally_TallyEngine_sessionNew(&jvm.env, null);
|
||||
defer Java_dev_lerch_tally_TallyEngine_sessionFree(&jvm.env, null, handle);
|
||||
|
||||
jvm.supplied = "0xFF and 0x0F";
|
||||
_ = Java_dev_lerch_tally_TallyEngine_eval(&jvm.env, null, handle, null, 1);
|
||||
try testing.expect(std.mem.indexOf(u8, jvm.madeString(), "\"dec_unsigned\":\"15\"") != null);
|
||||
|
||||
jvm.supplied = "1 / 0";
|
||||
const result = Java_dev_lerch_tally_TallyEngine_eval(&jvm.env, null, handle, null, 0);
|
||||
try testing.expect(result != null);
|
||||
try testing.expect(std.mem.indexOf(u8, jvm.madeString(), "division by zero") != null);
|
||||
}
|
||||
|
||||
test "the catalogue and the version strings cross too" {
|
||||
var jvm: FakeJvm = undefined;
|
||||
jvm.init();
|
||||
|
||||
const handle = Java_dev_lerch_tally_TallyEngine_sessionNew(&jvm.env, null);
|
||||
defer Java_dev_lerch_tally_TallyEngine_sessionFree(&jvm.env, null, handle);
|
||||
|
||||
_ = Java_dev_lerch_tally_TallyEngine_unitCatalog(&jvm.env, null, handle);
|
||||
try testing.expect(std.mem.indexOf(u8, jvm.madeString(), "\"base_unit\":\"m\"") != null);
|
||||
|
||||
_ = Java_dev_lerch_tally_TallyEngine_versionString(&jvm.env, null);
|
||||
try testing.expectEqualStrings("0.1.0", jvm.madeString());
|
||||
try testing.expectEqual(@as(jint, 5), Java_dev_lerch_tally_TallyEngine_abiVersion(&jvm.env, null));
|
||||
}
|
||||
|
||||
test "a preview crosses JNI and leaves the session as it found it" {
|
||||
var jvm: FakeJvm = undefined;
|
||||
jvm.init();
|
||||
|
||||
const handle = Java_dev_lerch_tally_TallyEngine_sessionNew(&jvm.env, null);
|
||||
defer Java_dev_lerch_tally_TallyEngine_sessionFree(&jvm.env, null, handle);
|
||||
|
||||
jvm.supplied = "x = 6 * 7";
|
||||
try testing.expect(Java_dev_lerch_tally_TallyEngine_preview(&jvm.env, null, handle, null, 0) != null);
|
||||
try testing.expect(std.mem.indexOf(u8, jvm.madeString(), "\"display\":\"42\"") != null);
|
||||
try testing.expectEqual(@as(usize, 1), jvm.releases);
|
||||
|
||||
jvm.supplied = "x";
|
||||
_ = Java_dev_lerch_tally_TallyEngine_eval(&jvm.env, null, handle, null, 0);
|
||||
try testing.expect(std.mem.indexOf(u8, jvm.madeString(), "unknown variable") != null);
|
||||
|
||||
try testing.expect(Java_dev_lerch_tally_TallyEngine_preview(&jvm.env, null, 0, null, 0) == null);
|
||||
}
|
||||
|
||||
test "a conversion crosses JNI with two strings in and one out" {
|
||||
var jvm: FakeJvm = undefined;
|
||||
jvm.init();
|
||||
|
||||
const handle = Java_dev_lerch_tally_TallyEngine_sessionNew(&jvm.env, null);
|
||||
defer Java_dev_lerch_tally_TallyEngine_sessionFree(&jvm.env, null, handle);
|
||||
|
||||
// The fake tells its strings apart by object: null is "from Kotlin", a string it
|
||||
// made reads back as what it made. Two different inputs need one of each.
|
||||
const unit = jvm.env.newStringUtf("C");
|
||||
jvm.supplied = "100";
|
||||
try testing.expect(Java_dev_lerch_tally_TallyEngine_convert(&jvm.env, null, handle, null, unit) != null);
|
||||
try testing.expect(std.mem.indexOf(u8, jvm.madeString(), "{\"unit\":\"F\",\"display\":\"212\"") != null);
|
||||
// Both strings were released.
|
||||
try testing.expectEqual(@as(usize, 2), jvm.releases);
|
||||
|
||||
try testing.expect(Java_dev_lerch_tally_TallyEngine_convert(&jvm.env, null, 0, null, unit) == null);
|
||||
}
|
||||
|
||||
test "a session saved through JNI loads back into another" {
|
||||
var jvm: FakeJvm = undefined;
|
||||
jvm.init();
|
||||
|
||||
const first = Java_dev_lerch_tally_TallyEngine_sessionNew(&jvm.env, null);
|
||||
defer Java_dev_lerch_tally_TallyEngine_sessionFree(&jvm.env, null, first);
|
||||
jvm.supplied = "x = 1/3";
|
||||
_ = Java_dev_lerch_tally_TallyEngine_eval(&jvm.env, null, first, null, 0);
|
||||
|
||||
// What Kotlin would hold: the string the JVM made from the saved bytes.
|
||||
const saved = Java_dev_lerch_tally_TallyEngine_sessionSave(&jvm.env, null, first);
|
||||
try testing.expect(saved != null);
|
||||
try testing.expect(std.mem.indexOf(u8, jvm.madeString(), "\"exact\":\"1/3\"") != null);
|
||||
|
||||
const second = Java_dev_lerch_tally_TallyEngine_sessionNew(&jvm.env, null);
|
||||
defer Java_dev_lerch_tally_TallyEngine_sessionFree(&jvm.env, null, second);
|
||||
// A string the fake made reads back as what it made, which is the saved state.
|
||||
const ok = @intFromEnum(c_api.Status.ok);
|
||||
try testing.expectEqual(ok, Java_dev_lerch_tally_TallyEngine_sessionLoad(&jvm.env, null, second, saved));
|
||||
jvm.supplied = "x * 3";
|
||||
_ = Java_dev_lerch_tally_TallyEngine_eval(&jvm.env, null, second, null, 0);
|
||||
try testing.expect(std.mem.indexOf(u8, jvm.madeString(), "\"display\":\"1\",\"exact\":true") != null);
|
||||
|
||||
const refused = @intFromEnum(c_api.Status.invalid_argument);
|
||||
try testing.expect(Java_dev_lerch_tally_TallyEngine_sessionSave(&jvm.env, null, 0) == null);
|
||||
try testing.expectEqual(refused, Java_dev_lerch_tally_TallyEngine_sessionLoad(&jvm.env, null, 0, saved));
|
||||
// The expression slot (null) reads back as `supplied`, which is not a saved state.
|
||||
try testing.expectEqual(refused, Java_dev_lerch_tally_TallyEngine_sessionLoad(&jvm.env, null, second, null));
|
||||
}
|
||||
|
||||
test "no result crosses JNI when there is none: out of memory, or a refused call" {
|
||||
var jvm: FakeJvm = undefined;
|
||||
jvm.init();
|
||||
|
||||
// A session built here rather than by `sessionNew`, so its allocator can fail.
|
||||
var failing = testing.FailingAllocator.init(testing.allocator, .{});
|
||||
var session: c_api.Session = .init(failing.allocator());
|
||||
defer session.deinit();
|
||||
const handle = handleFromSession(&session);
|
||||
|
||||
jvm.supplied = "6 * 7";
|
||||
try testing.expect(Java_dev_lerch_tally_TallyEngine_eval(&jvm.env, null, handle, null, 0) != null);
|
||||
try testing.expect(std.mem.indexOf(u8, jvm.madeString(), "\"display\":\"42\"") != null);
|
||||
|
||||
// Out of memory at each point in turn: null every time, never the 42 again. At
|
||||
// least one point has to fail, or the loop proved nothing.
|
||||
var failures: usize = 0;
|
||||
var point: usize = 0;
|
||||
while (true) : (point += 1) {
|
||||
failing.fail_index = failing.alloc_index + point;
|
||||
jvm.made_len = 0;
|
||||
if (Java_dev_lerch_tally_TallyEngine_unitCatalog(&jvm.env, null, handle)) |made| {
|
||||
// A result, and a real one: the catalogue, not an empty string standing in.
|
||||
_ = made;
|
||||
try testing.expect(std.mem.indexOf(u8, jvm.madeString(), "\"categories\"") != null);
|
||||
break;
|
||||
}
|
||||
failures += 1;
|
||||
try testing.expectEqual(@as(usize, 0), jvm.made_len);
|
||||
}
|
||||
try testing.expect(failures > 0);
|
||||
failing.fail_index = std.math.maxInt(usize);
|
||||
|
||||
// An unknown mode is refused before anything is written. This used to read the
|
||||
// out-parameters anyway, which nothing had set.
|
||||
jvm.made_len = 0;
|
||||
try testing.expect(Java_dev_lerch_tally_TallyEngine_eval(&jvm.env, null, handle, null, 99) == null);
|
||||
try testing.expect(Java_dev_lerch_tally_TallyEngine_preview(&jvm.env, null, handle, null, 99) == null);
|
||||
try testing.expectEqual(@as(usize, 0), jvm.made_len);
|
||||
}
|
||||
|
||||
test "a session's display budget can be narrowed, and a nonsense one is refused" {
|
||||
var jvm: FakeJvm = undefined;
|
||||
jvm.init();
|
||||
|
||||
const handle = Java_dev_lerch_tally_TallyEngine_sessionNew(&jvm.env, null);
|
||||
defer Java_dev_lerch_tally_TallyEngine_sessionFree(&jvm.env, null, handle);
|
||||
|
||||
const ok = @intFromEnum(c_api.Status.ok);
|
||||
const refused = @intFromEnum(c_api.Status.invalid_argument);
|
||||
try testing.expectEqual(ok, Java_dev_lerch_tally_TallyEngine_configureDisplay(&jvm.env, null, handle, 4, 17));
|
||||
jvm.supplied = "2 / 3";
|
||||
_ = Java_dev_lerch_tally_TallyEngine_eval(&jvm.env, null, handle, null, 0);
|
||||
try testing.expect(std.mem.indexOf(u8, jvm.madeString(), "\"display\":\"0.6667\"") != null);
|
||||
|
||||
try testing.expectEqual(refused, Java_dev_lerch_tally_TallyEngine_configureDisplay(&jvm.env, null, handle, -1, 17));
|
||||
try testing.expectEqual(refused, Java_dev_lerch_tally_TallyEngine_configureDisplay(&jvm.env, null, handle, 4, 0));
|
||||
try testing.expectEqual(refused, Java_dev_lerch_tally_TallyEngine_configureDisplay(&jvm.env, null, handle, 70000, 17));
|
||||
try testing.expectEqual(refused, Java_dev_lerch_tally_TallyEngine_configureDisplay(&jvm.env, null, 0, 4, 17));
|
||||
}
|
||||
|
||||
test "a call with no session is refused instead of dereferencing zero" {
|
||||
var jvm: FakeJvm = undefined;
|
||||
jvm.init();
|
||||
|
||||
jvm.supplied = "2 + 2";
|
||||
try testing.expect(Java_dev_lerch_tally_TallyEngine_eval(&jvm.env, null, 0, null, 0) == null);
|
||||
try testing.expect(Java_dev_lerch_tally_TallyEngine_unitCatalog(&jvm.env, null, 0) == null);
|
||||
// Freeing nothing is allowed, so a ViewModel's teardown needs no null check.
|
||||
Java_dev_lerch_tally_TallyEngine_sessionFree(&jvm.env, null, 0);
|
||||
}
|
||||
|
||||
test "JNI_OnLoad accepts a table that behaves and rejects one that does not" {
|
||||
var jvm: FakeJvm = undefined;
|
||||
jvm.init();
|
||||
try testing.expectEqual(jni_version_1_6, JNI_OnLoad(&jvm.vm, null));
|
||||
|
||||
// A table where the string functions are not what this file thinks they are. On a
|
||||
// real JVM that is a crash; the point of the check is that it happens at load.
|
||||
jvm.init();
|
||||
jvm.misplace_new_string = true;
|
||||
try testing.expectEqual(jni_err, JNI_OnLoad(&jvm.vm, null));
|
||||
|
||||
// And a JavaVM that will not hand over an environment.
|
||||
jvm.init();
|
||||
jvm.vm_table.entries[@intFromEnum(VmIndex.get_env)] = null;
|
||||
try testing.expectEqual(jni_err, JNI_OnLoad(&jvm.vm, null));
|
||||
}
|
||||
169
engine/test/c_abi_test.c
Normal file
169
engine/test/c_abi_test.c
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
/* Calls the C ABI the way a JNI or Swift binding will: through include/tally.h,
|
||||
* linked against the real library.
|
||||
*
|
||||
* The Zig tests in c_api.zig cover behaviour and are leak-checked. This covers what
|
||||
* they cannot: that the header matches the library, that the symbols are findable by a
|
||||
* C linker, and that the struct layouts agree across the language boundary. A wrong
|
||||
* field order in tally.h is invisible from Zig and fatal in the field.
|
||||
*/
|
||||
|
||||
#include "tally.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
static int failures = 0;
|
||||
|
||||
static void check(int condition, const char *what) {
|
||||
if (!condition) {
|
||||
printf("FAIL: %s\n", what);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
|
||||
static void check_contains(const uint8_t *json, size_t len, const char *needle, const char *what) {
|
||||
/* The JSON is not null-terminated: it is a borrowed (ptr, len). */
|
||||
char buf[4096];
|
||||
size_t n = len < sizeof(buf) - 1 ? len : sizeof(buf) - 1;
|
||||
memcpy(buf, json, n);
|
||||
buf[n] = '\0';
|
||||
if (strstr(buf, needle) == NULL) {
|
||||
printf("FAIL: %s\n wanted: %s\n got: %s\n", what, needle, buf);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
check(tally_abi_version() == TALLY_ABI_VERSION, "header and library agree on the ABI version");
|
||||
|
||||
size_t version_len = 0;
|
||||
const uint8_t *version = tally_version(&version_len);
|
||||
check(version_len == 5 && memcmp(version, "0.1.0", 5) == 0, "tally_version");
|
||||
|
||||
tally_session *session = tally_session_new();
|
||||
check(session != NULL, "tally_session_new");
|
||||
if (session == NULL) return 1;
|
||||
|
||||
const uint8_t *json = NULL;
|
||||
size_t len = 0;
|
||||
|
||||
/* A session that remembers: the whole reason for a handle. */
|
||||
const char *assign = "x = 2^100";
|
||||
check(tally_eval(session, (const uint8_t *)assign, strlen(assign),
|
||||
TALLY_MODE_STANDARD, &json, &len) == TALLY_OK,
|
||||
"assignment evaluates");
|
||||
|
||||
const char *use = "x + 1";
|
||||
check(tally_eval(session, (const uint8_t *)use, strlen(use),
|
||||
TALLY_MODE_STANDARD, &json, &len) == TALLY_OK,
|
||||
"the variable survives into the next call");
|
||||
check_contains(json, len, "1,267,650,600,228,229,401,496,703,205,377",
|
||||
"exact 128-bit arithmetic crosses the boundary intact");
|
||||
|
||||
const char *ans = "Ans / 2";
|
||||
check(tally_eval(session, (const uint8_t *)ans, strlen(ans),
|
||||
TALLY_MODE_STANDARD, &json, &len) == TALLY_OK, "Ans is readable");
|
||||
check_contains(json, len, "633,825,300,114,114,700,748,351,602,688.5",
|
||||
"half of an odd number, so the exact tier is really exact");
|
||||
|
||||
/* A preview answers and stores nothing: neither the variable nor Ans. */
|
||||
const char *peek = "y = 41 + 1";
|
||||
check(tally_preview(session, (const uint8_t *)peek, strlen(peek),
|
||||
TALLY_MODE_STANDARD, &json, &len) == TALLY_OK, "preview evaluates");
|
||||
check_contains(json, len, "\"display\":\"42\"", "a preview carries the answer");
|
||||
const char *y = "y";
|
||||
check(tally_eval(session, (const uint8_t *)y, strlen(y),
|
||||
TALLY_MODE_STANDARD, &json, &len) == TALLY_EVAL_ERROR,
|
||||
"a previewed assignment stored nothing");
|
||||
const char *same = "Ans";
|
||||
check(tally_eval(session, (const uint8_t *)same, strlen(same),
|
||||
TALLY_MODE_STANDARD, &json, &len) == TALLY_OK, "Ans after a preview");
|
||||
check_contains(json, len, "633,825,300,114,114,700,748,351,602,688.5",
|
||||
"Ans is still the last committed answer");
|
||||
|
||||
/* An error is a status plus the engine's own wording. */
|
||||
const char *bad = "1 / 0";
|
||||
check(tally_eval(session, (const uint8_t *)bad, strlen(bad),
|
||||
TALLY_MODE_STANDARD, &json, &len) == TALLY_EVAL_ERROR,
|
||||
"division by zero is an eval error");
|
||||
check_contains(json, len, "division by zero", "the error carries the engine's phrase");
|
||||
|
||||
/* Unit conversion, from standard mode, with no separate entry point. */
|
||||
const char *convert = "100 km to mi";
|
||||
check(tally_eval(session, (const uint8_t *)convert, strlen(convert),
|
||||
TALLY_MODE_STANDARD, &json, &len) == TALLY_OK, "conversion evaluates");
|
||||
check_contains(json, len, "\"from\":\"km\"", "the conversion names its units");
|
||||
|
||||
/* The config struct crosses the boundary with its fields where the header says. */
|
||||
tally_config config;
|
||||
check(tally_session_config(session, &config) == TALLY_OK, "reading the config");
|
||||
check(config.bits == 64, "default width is 64");
|
||||
config.bits = 8;
|
||||
config.is_signed = 1;
|
||||
check(tally_session_configure(session, &config) == TALLY_OK, "configuring 8-bit signed");
|
||||
|
||||
const char *ff = "0xFF";
|
||||
check(tally_eval(session, (const uint8_t *)ff, strlen(ff),
|
||||
TALLY_MODE_PROGRAMMER, &json, &len) == TALLY_OK, "programmer mode");
|
||||
check_contains(json, len, "\"dec_signed\":\"-1\"",
|
||||
"0xFF read as 8-bit signed is -1, so bits and is_signed both landed");
|
||||
check_contains(json, len, "\"bits\":8", "the width reached the result");
|
||||
|
||||
config.bits = 24;
|
||||
check(tally_session_configure(session, &config) == TALLY_INVALID_ARGUMENT,
|
||||
"a width the engine has no name for is refused");
|
||||
|
||||
/* The catalogue a picker is built from. */
|
||||
check(tally_unit_catalog(session, &json, &len) == TALLY_OK, "unit catalogue");
|
||||
check_contains(json, len, "\"base_unit\":\"m\"", "length's base unit");
|
||||
check_contains(json, len, "kilometer", "aliases come along for searching");
|
||||
/* check_contains reads the first 4KB; length is the first category. */
|
||||
check_contains(json, len, "{\"name\":\"km\",\"label\":\"Kilometer\"", "units carry a display name");
|
||||
|
||||
/* One value in every unit of its category, for a converter screen. */
|
||||
const char *hundred = "100";
|
||||
const char *celsius = "C";
|
||||
check(tally_convert(session, (const uint8_t *)hundred, strlen(hundred),
|
||||
(const uint8_t *)celsius, strlen(celsius), &json, &len) == TALLY_OK,
|
||||
"tally_convert");
|
||||
check_contains(json, len, "{\"unit\":\"F\",\"display\":\"212\"", "100 C is 212 F");
|
||||
check_contains(json, len, "{\"unit\":\"K\",\"display\":\"373.15\"", "and 373.15 K");
|
||||
|
||||
/* Save one session and load it into another: x and Ans come across exactly. */
|
||||
const char *keep = "x = 2^100";
|
||||
check(tally_eval(session, (const uint8_t *)keep, strlen(keep),
|
||||
TALLY_MODE_STANDARD, &json, &len) == TALLY_OK, "assign before save");
|
||||
check(tally_session_save(session, &json, &len) == TALLY_OK, "tally_session_save");
|
||||
check_contains(json, len, "\"name\":\"x\",\"value\":{\"exact\":\"1267650600228229401496703205376\"}",
|
||||
"an exact value is saved with every digit");
|
||||
tally_session *restored = tally_session_new();
|
||||
check(restored != NULL, "a second session");
|
||||
if (restored != NULL) {
|
||||
/* The saved bytes belong to the first session, so they are still valid here. */
|
||||
check(tally_session_load(restored, json, len) == TALLY_OK, "tally_session_load");
|
||||
const char *use_x = "x + 1";
|
||||
check(tally_eval(restored, (const uint8_t *)use_x, strlen(use_x),
|
||||
TALLY_MODE_STANDARD, &json, &len) == TALLY_OK, "x after load");
|
||||
check_contains(json, len, "1,267,650,600,228,229,401,496,703,205,377",
|
||||
"the loaded variable is the saved one");
|
||||
const char *junk = "{\"format\":1}";
|
||||
check(tally_session_load(restored, (const uint8_t *)junk, strlen(junk)) == TALLY_INVALID_ARGUMENT,
|
||||
"a malformed state is refused");
|
||||
tally_session_free(restored);
|
||||
}
|
||||
|
||||
/* Null handling, since a cleanup path should not need its own checks. */
|
||||
check(tally_eval(NULL, (const uint8_t *)use, strlen(use),
|
||||
TALLY_MODE_STANDARD, &json, &len) == TALLY_INVALID_ARGUMENT,
|
||||
"a null session is refused");
|
||||
tally_session_free(NULL);
|
||||
|
||||
tally_session_free(session);
|
||||
|
||||
if (failures == 0) {
|
||||
printf("c_abi_test: all checks passed\n");
|
||||
return 0;
|
||||
}
|
||||
printf("c_abi_test: %d check(s) failed\n", failures);
|
||||
return 1;
|
||||
}
|
||||
137
engine/test/java/dev/lerch/tally/TallyEngine.java
Normal file
137
engine/test/java/dev/lerch/tally/TallyEngine.java
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
package dev.lerch.tally;
|
||||
|
||||
/**
|
||||
* The JNI layer, tested against a real JVM on the host.
|
||||
*
|
||||
* `engine/src/jni.zig` reaches the JNI function table by index, because the library is
|
||||
* built without the NDK and so without `jni.h`. Four of those indices have to be right,
|
||||
* and a wrong one is a jump into the wrong function rather than a compile error. The Zig
|
||||
* tests cover the marshalling against a synthetic table; they cannot check the indices,
|
||||
* because they supply the table themselves.
|
||||
*
|
||||
* A JVM can. JNI's function table is fixed by the specification rather than by the
|
||||
* platform, so a desktop JVM resolves the same indices an Android runtime does: if this
|
||||
* passes here, the numbers are right there too. That turns the one thing said to need a
|
||||
* device into something `zig build jvm-test` answers in a second.
|
||||
*
|
||||
* This class is deliberately a copy of the Kotlin `TallyEngine`'s native declarations
|
||||
* rather than the real thing: compiling Kotlin would drag Gradle and the Android SDK into
|
||||
* a test whose whole point is that it needs neither.
|
||||
*/
|
||||
public final class TallyEngine {
|
||||
static {
|
||||
// Runs JNI_OnLoad, which checks the version and round-trips a string through
|
||||
// NewStringUTF and GetStringUTFChars. A wrong index fails here, loudly.
|
||||
System.loadLibrary("tally");
|
||||
}
|
||||
|
||||
private static native long sessionNew();
|
||||
|
||||
private static native void sessionFree(long handle);
|
||||
|
||||
private static native String eval(long handle, String expr, int mode);
|
||||
|
||||
private static native String preview(long handle, String expr, int mode);
|
||||
|
||||
private static native String unitCatalog(long handle);
|
||||
|
||||
private static native String convert(long handle, String value, String unit);
|
||||
|
||||
private static native int configureDisplay(long handle, int fractionDigits, int significantDigits);
|
||||
|
||||
private static native String sessionSave(long handle);
|
||||
|
||||
private static native int sessionLoad(long handle, String state);
|
||||
|
||||
private static native String versionString();
|
||||
|
||||
private static native int abiVersion();
|
||||
|
||||
private static int failures = 0;
|
||||
|
||||
private static void check(boolean condition, String what) {
|
||||
if (!condition) {
|
||||
System.out.println("FAIL: " + what);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
|
||||
private static void checkContains(String haystack, String needle, String what) {
|
||||
if (haystack == null || !haystack.contains(needle)) {
|
||||
System.out.println("FAIL: " + what + "\n wanted: " + needle + "\n got: " + haystack);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
check(abiVersion() == 5, "ABI version");
|
||||
check("0.1.0".equals(versionString()), "version string crosses as a Java string");
|
||||
|
||||
long session = sessionNew();
|
||||
check(session != 0, "session created");
|
||||
|
||||
// A session that remembers, which is the reason the handle exists.
|
||||
checkContains(eval(session, "x = 2^100", 0), "\"ok\":true", "assignment");
|
||||
checkContains(
|
||||
eval(session, "x + 1", 0),
|
||||
"1,267,650,600,228,229,401,496,703,205,377",
|
||||
"exact 128-bit arithmetic through JNI");
|
||||
checkContains(
|
||||
eval(session, "Ans / 2", 0),
|
||||
"633,825,300,114,114,700,748,351,602,688.5",
|
||||
"Ans, and half of an odd number staying exact");
|
||||
|
||||
// A preview answers and stores nothing, which is what a live result needs.
|
||||
checkContains(preview(session, "y = 40 + 2", 0), "\"display\":\"42\"", "preview answers");
|
||||
checkContains(eval(session, "y", 0), "unknown variable", "a previewed assignment stored nothing");
|
||||
checkContains(preview(session, "Ans * 0", 0), "\"display\":\"0\"", "preview reads Ans");
|
||||
checkContains(
|
||||
eval(session, "Ans", 0),
|
||||
"633,825,300,114,114,700,748,351,602,688.5",
|
||||
"Ans unchanged by a preview");
|
||||
check(preview(0, "1", 0) == null, "a preview with no session is refused");
|
||||
|
||||
// An error is JSON with the engine's own wording, not an exception.
|
||||
checkContains(eval(session, "1 / 0", 0), "division by zero", "error wording");
|
||||
checkContains(eval(session, "2 * 3", 0), "\"display\":\"6\"", "session still usable after an error");
|
||||
|
||||
// Unit conversion from the one field, and programmer mode.
|
||||
checkContains(eval(session, "100 km to mi", 0), "\"from\":\"km\"", "conversion");
|
||||
checkContains(eval(session, "0xFF and 0x0F", 1), "\"dec_unsigned\":\"15\"", "programmer mode");
|
||||
checkContains(unitCatalog(session), "\"base_unit\":\"m\"", "units catalogue");
|
||||
checkContains(unitCatalog(session), "\"label\":\"Meter per second\"", "units carry a display name");
|
||||
checkContains(convert(session, "100", "C"), "{\"unit\":\"F\",\"display\":\"212\"", "convert, two strings in");
|
||||
checkContains(convert(session, "1", "smoot"), "unknown unit", "convert reports an unknown unit");
|
||||
check(convert(0, "1", "m") == null, "a conversion with no session is refused");
|
||||
check(configureDisplay(session, 4, 17) == 0, "configureDisplay, two ints in");
|
||||
checkContains(eval(session, "2 / 3", 0), "\"display\":\"0.6667\"", "the display budget took");
|
||||
check(configureDisplay(session, -1, 17) == 3, "a negative budget is refused");
|
||||
|
||||
// Save one session, load it into another: the variable comes across exactly.
|
||||
checkContains(eval(session, "saved = 1/3", 0), "\"ok\":true", "assign before save");
|
||||
String state = sessionSave(session);
|
||||
checkContains(state, "{\"name\":\"saved\",\"value\":{\"exact\":\"1/3\"}}", "save, one string out");
|
||||
long other = sessionNew();
|
||||
check(sessionLoad(other, state) == 0, "load, a string in");
|
||||
checkContains(eval(other, "saved * 3", 0), "\"display\":\"1\",\"exact\":true", "loaded exactly");
|
||||
check(sessionLoad(other, "{}") == 3, "a malformed state is refused");
|
||||
sessionFree(other);
|
||||
|
||||
// A long string, to make sure nothing assumed a small buffer across the boundary.
|
||||
String catalog = unitCatalog(session);
|
||||
check(catalog != null && catalog.length() > 4096, "the catalogue is large and survives whole");
|
||||
|
||||
// No session is refused rather than dereferenced.
|
||||
check(eval(0, "2 + 2", 0) == null, "a null session is refused");
|
||||
sessionFree(0);
|
||||
|
||||
sessionFree(session);
|
||||
|
||||
if (failures == 0) {
|
||||
System.out.println("jvm jni test: all checks passed");
|
||||
return;
|
||||
}
|
||||
System.out.println("jvm jni test: " + failures + " check(s) failed");
|
||||
System.exit(1);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue