Lines
100 %
Functions
Branches
//! Phase-9 parity tests.
//!
//! Verifies that nomiscript forms not requiring host fns produce
//! identical results through:
//! - the raw eval path (`Compiler::compile_eval_with_type` +
//! wasmtime, no host fns linked) — the path
//! `scripting::runtime::decode_eval_result` exercises.
//! - the rpc::Session path (`Session::handle_form`) — full
//! auth-bound + host-fn-linked evaluator.
//! Both paths share one Compiler + one CompileContext skeleton + one
//! NativeSpec registry, so structural parity is guaranteed by the
//! plan's unified-registry design. These tests pin the actual output
//! to bytes so a future refactor that breaks parity (e.g. printer
//! drift) trips here.
//! Host-fn-touching natives (list-accounts, account-balance, ...)
//! aren't covered by parity tests because they require a live
//! ScriptCtx + database; the existing rpc::natives::* integration
//! tests cover those.
use rpc::{ScriptCtx, Session};
use uuid::Uuid;
async fn rpc_response(form: &str) -> String {
let mut session = Session::new(ScriptCtx::new(Uuid::new_v4())).unwrap();
session.handle_form(&format!("(:id 42 :form {form})")).await
}
fn extract_value(response: &str) -> &str {
// response shape: `(:id 42 :value <X>)` or
// `(:id 42 :error (...))`. We slice on `:value ` so callers
// assert on the inner value text directly.
response
.split_once(":value ")
.map(|(_, rest)| rest.trim_end_matches(')').trim())
.unwrap_or(response)
#[tokio::test(flavor = "current_thread")]
async fn arithmetic_pure_form_round_trips() {
let resp = rpc_response("(+ 1 2 3)").await;
assert!(resp.contains(":id 42"), "{resp}");
assert!(!resp.contains(":error"), "{resp}");
assert_eq!(extract_value(&resp), "6");
async fn ratio_arithmetic_keeps_rational_form() {
// Fractional (Scalar) literals: 1/3 + 1/4 = 7/12; the wire form is the
// fraction literal. (Integer `(/ 1 3)` is now Index division → 0 per
// ADR-0028, so the rational idiom uses the `1/3` / `1/4` Scalar literals.)
let resp = rpc_response("(+ 1/3 1/4)").await;
assert!(resp.contains("7/12"), "{resp}");
async fn comparison_returns_bool() {
// `=` yields a boolean: a true result renders as `#t` (WasmType::Bool),
// not the integer `1`. Const-folded here, but a runtime comparison
// serializes identically (Bool, not Number).
let resp = rpc_response("(= 1 1)").await;
assert!(resp.contains("#t"), "{resp}");
let resp_false = rpc_response("(= 1 2)").await;
assert!(!resp_false.contains(":error"), "{resp_false}");
// A false comparison is `nil` (falsy), rendered `NIL`.
assert!(resp_false.contains("NIL"), "{resp_false}");
async fn let_with_arithmetic_body() {
let resp = rpc_response("(let* ((x 5) (y 7)) (+ x y))").await;
assert_eq!(extract_value(&resp), "12");
async fn cond_constant_fold() {
let resp = rpc_response("(cond ((= 1 2) 99) ((= 1 1) 42))").await;
assert_eq!(extract_value(&resp), "42");
async fn nested_defun_and_call() {
// `Session` keeps the SymbolTable across forms in the channel,
// but `handle_form` runs one form per call. Defining + calling
// in the same frame via BEGIN is the way to test it from one
// wire request.
let resp = rpc_response("(begin (defun double (x) (* x 2)) (double 7))").await;
assert!(resp.contains("14"), "{resp}");
async fn pp_returns_formatted_string() {
let resp = rpc_response("(pp 42)").await;
assert!(resp.contains("\"42\""), "{resp}");
async fn apropos_finds_arithmetic_operator() {
let resp = rpc_response("(apropos \"+\")").await;
// Output should include the `+` symbol among the matches.
assert!(resp.contains('+'), "{resp}");
async fn describe_native_returns_doc_string() {
let resp = rpc_response("(describe '+)").await;
// Wire form is a string literal containing the formatted lines.
assert!(
resp.contains("operator") || resp.contains("function"),
"{resp}"
);
async fn rpc_protocol_version_host_fn_returns_int() {
let resp = rpc_response("(rpc-protocol-version)").await;
// version is some non-empty integer.
let value = extract_value(&resp);
assert!(value.parse::<i64>().is_ok(), "{resp}");
async fn deftest_run_tests_round_trips() {
let resp = rpc_response("(begin (deftest p (assert-equal (+ 1 1) 2)) (run-tests))").await;
assert!(resp.contains("1 passed"), "{resp}");
async fn deftest_with_failure_counted() {
let resp = rpc_response("(begin (deftest f (assert-equal 1 2)) (run-tests))").await;
assert!(resp.contains("1 failed"), "{resp}");
async fn type_mismatch_surfaces_as_error_envelope() {
// `(+ "foo" 1)` is a String in arithmetic — refused at compile
// time with a structured error rather than producing wasm.
let resp = rpc_response("(+ \"foo\" 1)").await;
assert!(resp.contains(":error"), "{resp}");
assert!(resp.contains(":code"), "{resp}");
async fn unknown_symbol_surfaces_as_error_envelope() {
let resp = rpc_response("(nonexistent-fn 1 2)").await;
async fn script_raise_surfaces_with_script_supplied_code_symbol() {
// Tier 1 of the error-processing model: `(error 'code "msg")`
// never returns normally. The classifier reads the
// `__nomi_raise:` marker before the unreachable-trap branch
// (ADR-0014 invariant) and the wire `:code` is the script's
// own symbol verbatim, not a generic engine label.
// Symbols are case-folded to uppercase by the reader, so the wire
// `:code` is the upper-cased form of the source-supplied symbol.
let resp = rpc_response(r#"(error 'no-such-account "id=42")"#).await;
assert!(resp.contains(":code NO-SUCH-ACCOUNT"), "{resp}");
assert!(resp.contains("id=42"), "{resp}");
async fn script_raise_message_with_colons_round_trips() {
// The marker parse must use the chain walk, not a string split,
// so messages with literal `:` characters survive intact.
let resp = rpc_response(r#"(error 'parse "expected ':' at column 7")"#).await;
assert!(resp.contains(":code PARSE"), "{resp}");
assert!(resp.contains("column 7"), "{resp}");
async fn malformed_frame_surfaces_parse_error() {
let resp = session.handle_form("not a valid envelope").await;
async fn session_persists_defun_across_two_forms() {
let r1 = session
.handle_form("(:id 1 :form (defun triple (x) (* x 3)))")
.await;
assert!(!r1.contains(":error"), "{r1}");
let r2 = session.handle_form("(:id 2 :form (triple 4))").await;
assert!(!r2.contains(":error"), "{r2}");
assert!(r2.contains("12"), "{r2}");
// --- Phase 4: pre-compiled stubs for fast-path natives ---
async fn session_new_pre_warms_cache_with_zero_arg_natives() {
// Every zero-arg host fn with a non-None result registers a
// wasm module in the cache at Session::new. Workspace currently
// ships several (rpc-protocol-version, account-count,
// list-accounts, list-commodities, list-transactions,
// list-splits, list-ssh-keys, get-version, get-build-date, ...).
// We just assert "more than one" so adding / removing specs
// doesn't churn this test.
let session = Session::new(ScriptCtx::new(Uuid::new_v4())).unwrap();
let size = session.cache_size().unwrap();
assert!(size > 1, "expected pre-warmed cache, got {size} entries");
async fn handle_form_of_zero_arg_native_does_not_grow_cache() {
// The pre-warmed module gets reused — the per-form compile step
// hits `cache.get_or_compile` and finds the same bytecode, so
// `cache.len()` stays the same.
let before = session.cache_size().unwrap();
let _ = session
.handle_form("(:id 1 :form (rpc-protocol-version))")
let after = session.cache_size().unwrap();
assert_eq!(before, after, "pre-warmed form must not re-compile");
async fn handle_form_of_unseen_form_grows_cache() {
// An arg-bearing or otherwise novel form isn't in the pre-warm
// set, so the first call compiles + stores. Locks in the cache
// invariant: pre-warm is a strict subset of the cold-compile
// surface.
let _ = session.handle_form("(:id 1 :form (+ 1 2 3 4 5))").await;
after > before,
"unseen form must compile: {before} -> {after}"
async fn handle_form_of_unseen_form_then_repeat_hits_cache() {
// Second call to the same novel form hits the (now-warm) cache.
let _ = session.handle_form("(:id 1 :form (* 7 8 9))").await;
let after_first = session.cache_size().unwrap();
let _ = session.handle_form("(:id 2 :form (* 7 8 9))").await;
let after_second = session.cache_size().unwrap();
assert_eq!(after_first, after_second, "repeat call must reuse cache");