1
//! Phase-9 parity tests.
2
//!
3
//! Verifies that nomiscript forms not requiring host fns produce
4
//! identical results through:
5
//!   - the raw eval path (`Compiler::compile_eval_with_type` +
6
//!     wasmtime, no host fns linked) — the path
7
//!     `scripting::runtime::decode_eval_result` exercises.
8
//!   - the rpc::Session path (`Session::handle_form`) — full
9
//!     auth-bound + host-fn-linked evaluator.
10
//!
11
//! Both paths share one Compiler + one CompileContext skeleton + one
12
//! NativeSpec registry, so structural parity is guaranteed by the
13
//! plan's unified-registry design. These tests pin the actual output
14
//! to bytes so a future refactor that breaks parity (e.g. printer
15
//! drift) trips here.
16
//!
17
//! Host-fn-touching natives (list-accounts, account-balance, ...)
18
//! aren't covered by parity tests because they require a live
19
//! ScriptCtx + database; the existing rpc::natives::* integration
20
//! tests cover those.
21

            
22
use rpc::{ScriptCtx, Session};
23
use uuid::Uuid;
24

            
25
17
async fn rpc_response(form: &str) -> String {
26
17
    let mut session = Session::new(ScriptCtx::new(Uuid::new_v4())).unwrap();
27
17
    session.handle_form(&format!("(:id 42 :form {form})")).await
28
17
}
29

            
30
4
fn extract_value(response: &str) -> &str {
31
    // response shape: `(:id 42 :value <X>)` or
32
    // `(:id 42 :error (...))`. We slice on `:value ` so callers
33
    // assert on the inner value text directly.
34
4
    response
35
4
        .split_once(":value ")
36
4
        .map(|(_, rest)| rest.trim_end_matches(')').trim())
37
4
        .unwrap_or(response)
38
4
}
39

            
40
#[tokio::test(flavor = "current_thread")]
41
1
async fn arithmetic_pure_form_round_trips() {
42
1
    let resp = rpc_response("(+ 1 2 3)").await;
43
1
    assert!(resp.contains(":id 42"), "{resp}");
44
1
    assert!(!resp.contains(":error"), "{resp}");
45
1
    assert_eq!(extract_value(&resp), "6");
46
1
}
47

            
48
#[tokio::test(flavor = "current_thread")]
49
1
async fn ratio_arithmetic_keeps_rational_form() {
50
    // Fractional (Scalar) literals: 1/3 + 1/4 = 7/12; the wire form is the
51
    // fraction literal. (Integer `(/ 1 3)` is now Index division → 0 per
52
    // ADR-0028, so the rational idiom uses the `1/3` / `1/4` Scalar literals.)
53
1
    let resp = rpc_response("(+ 1/3 1/4)").await;
54
1
    assert!(resp.contains("7/12"), "{resp}");
55
1
}
56

            
57
#[tokio::test(flavor = "current_thread")]
58
1
async fn comparison_returns_bool() {
59
    // `=` yields a boolean: a true result renders as `#t` (WasmType::Bool),
60
    // not the integer `1`. Const-folded here, but a runtime comparison
61
    // serializes identically (Bool, not Number).
62
1
    let resp = rpc_response("(= 1 1)").await;
63
1
    assert!(!resp.contains(":error"), "{resp}");
64
1
    assert!(resp.contains("#t"), "{resp}");
65
1
    let resp_false = rpc_response("(= 1 2)").await;
66
1
    assert!(!resp_false.contains(":error"), "{resp_false}");
67
    // A false comparison is `nil` (falsy), rendered `NIL`.
68
1
    assert!(resp_false.contains("NIL"), "{resp_false}");
69
1
}
70

            
71
#[tokio::test(flavor = "current_thread")]
72
1
async fn let_with_arithmetic_body() {
73
1
    let resp = rpc_response("(let* ((x 5) (y 7)) (+ x y))").await;
74
1
    assert!(!resp.contains(":error"), "{resp}");
75
1
    assert_eq!(extract_value(&resp), "12");
76
1
}
77

            
78
#[tokio::test(flavor = "current_thread")]
79
1
async fn cond_constant_fold() {
80
1
    let resp = rpc_response("(cond ((= 1 2) 99) ((= 1 1) 42))").await;
81
1
    assert!(!resp.contains(":error"), "{resp}");
82
1
    assert_eq!(extract_value(&resp), "42");
83
1
}
84

            
85
#[tokio::test(flavor = "current_thread")]
86
1
async fn nested_defun_and_call() {
87
    // `Session` keeps the SymbolTable across forms in the channel,
88
    // but `handle_form` runs one form per call. Defining + calling
89
    // in the same frame via BEGIN is the way to test it from one
90
    // wire request.
91
1
    let resp = rpc_response("(begin (defun double (x) (* x 2)) (double 7))").await;
92
1
    assert!(!resp.contains(":error"), "{resp}");
93
1
    assert!(resp.contains("14"), "{resp}");
94
1
}
95

            
96
#[tokio::test(flavor = "current_thread")]
97
1
async fn pp_returns_formatted_string() {
98
1
    let resp = rpc_response("(pp 42)").await;
99
1
    assert!(!resp.contains(":error"), "{resp}");
100
1
    assert!(resp.contains("\"42\""), "{resp}");
101
1
}
102

            
103
#[tokio::test(flavor = "current_thread")]
104
1
async fn apropos_finds_arithmetic_operator() {
105
1
    let resp = rpc_response("(apropos \"+\")").await;
106
1
    assert!(!resp.contains(":error"), "{resp}");
107
    // Output should include the `+` symbol among the matches.
108
1
    assert!(resp.contains('+'), "{resp}");
109
1
}
110

            
111
#[tokio::test(flavor = "current_thread")]
112
1
async fn describe_native_returns_doc_string() {
113
1
    let resp = rpc_response("(describe '+)").await;
114
1
    assert!(!resp.contains(":error"), "{resp}");
115
    // Wire form is a string literal containing the formatted lines.
116
1
    assert!(
117
1
        resp.contains("operator") || resp.contains("function"),
118
1
        "{resp}"
119
1
    );
120
1
}
121

            
122
#[tokio::test(flavor = "current_thread")]
123
1
async fn rpc_protocol_version_host_fn_returns_int() {
124
1
    let resp = rpc_response("(rpc-protocol-version)").await;
125
1
    assert!(!resp.contains(":error"), "{resp}");
126
    // version is some non-empty integer.
127
1
    let value = extract_value(&resp);
128
1
    assert!(value.parse::<i64>().is_ok(), "{resp}");
129
1
}
130

            
131
#[tokio::test(flavor = "current_thread")]
132
1
async fn deftest_run_tests_round_trips() {
133
1
    let resp = rpc_response("(begin (deftest p (assert-equal (+ 1 1) 2)) (run-tests))").await;
134
1
    assert!(!resp.contains(":error"), "{resp}");
135
1
    assert!(resp.contains("1 passed"), "{resp}");
136
1
}
137

            
138
#[tokio::test(flavor = "current_thread")]
139
1
async fn deftest_with_failure_counted() {
140
1
    let resp = rpc_response("(begin (deftest f (assert-equal 1 2)) (run-tests))").await;
141
1
    assert!(!resp.contains(":error"), "{resp}");
142
1
    assert!(resp.contains("1 failed"), "{resp}");
143
1
}
144

            
145
#[tokio::test(flavor = "current_thread")]
146
1
async fn type_mismatch_surfaces_as_error_envelope() {
147
    // `(+ "foo" 1)` is a String in arithmetic — refused at compile
148
    // time with a structured error rather than producing wasm.
149
1
    let resp = rpc_response("(+ \"foo\" 1)").await;
150
1
    assert!(resp.contains(":error"), "{resp}");
151
1
    assert!(resp.contains(":code"), "{resp}");
152
1
}
153

            
154
#[tokio::test(flavor = "current_thread")]
155
1
async fn unknown_symbol_surfaces_as_error_envelope() {
156
1
    let resp = rpc_response("(nonexistent-fn 1 2)").await;
157
1
    assert!(resp.contains(":error"), "{resp}");
158
1
}
159

            
160
#[tokio::test(flavor = "current_thread")]
161
1
async fn script_raise_surfaces_with_script_supplied_code_symbol() {
162
    // Tier 1 of the error-processing model: `(error 'code "msg")`
163
    // never returns normally. The classifier reads the
164
    // `__nomi_raise:` marker before the unreachable-trap branch
165
    // (ADR-0014 invariant) and the wire `:code` is the script's
166
    // own symbol verbatim, not a generic engine label.
167
    // Symbols are case-folded to uppercase by the reader, so the wire
168
    // `:code` is the upper-cased form of the source-supplied symbol.
169
1
    let resp = rpc_response(r#"(error 'no-such-account "id=42")"#).await;
170
1
    assert!(resp.contains(":error"), "{resp}");
171
1
    assert!(resp.contains(":code NO-SUCH-ACCOUNT"), "{resp}");
172
1
    assert!(resp.contains("id=42"), "{resp}");
173
1
}
174

            
175
#[tokio::test(flavor = "current_thread")]
176
1
async fn script_raise_message_with_colons_round_trips() {
177
    // The marker parse must use the chain walk, not a string split,
178
    // so messages with literal `:` characters survive intact.
179
1
    let resp = rpc_response(r#"(error 'parse "expected ':' at column 7")"#).await;
180
1
    assert!(resp.contains(":code PARSE"), "{resp}");
181
1
    assert!(resp.contains("column 7"), "{resp}");
182
1
}
183

            
184
#[tokio::test(flavor = "current_thread")]
185
1
async fn malformed_frame_surfaces_parse_error() {
186
1
    let mut session = Session::new(ScriptCtx::new(Uuid::new_v4())).unwrap();
187
1
    let resp = session.handle_form("not a valid envelope").await;
188
1
    assert!(resp.contains(":error"), "{resp}");
189
1
}
190

            
191
#[tokio::test(flavor = "current_thread")]
192
1
async fn session_persists_defun_across_two_forms() {
193
1
    let mut session = Session::new(ScriptCtx::new(Uuid::new_v4())).unwrap();
194
1
    let r1 = session
195
1
        .handle_form("(:id 1 :form (defun triple (x) (* x 3)))")
196
1
        .await;
197
1
    assert!(!r1.contains(":error"), "{r1}");
198
1
    let r2 = session.handle_form("(:id 2 :form (triple 4))").await;
199
1
    assert!(!r2.contains(":error"), "{r2}");
200
1
    assert!(r2.contains("12"), "{r2}");
201
1
}
202

            
203
// --- Phase 4: pre-compiled stubs for fast-path natives ---
204

            
205
#[tokio::test(flavor = "current_thread")]
206
1
async fn session_new_pre_warms_cache_with_zero_arg_natives() {
207
    // Every zero-arg host fn with a non-None result registers a
208
    // wasm module in the cache at Session::new. Workspace currently
209
    // ships several (rpc-protocol-version, account-count,
210
    // list-accounts, list-commodities, list-transactions,
211
    // list-splits, list-ssh-keys, get-version, get-build-date, ...).
212
    // We just assert "more than one" so adding / removing specs
213
    // doesn't churn this test.
214
1
    let session = Session::new(ScriptCtx::new(Uuid::new_v4())).unwrap();
215
1
    let size = session.cache_size().unwrap();
216
1
    assert!(size > 1, "expected pre-warmed cache, got {size} entries");
217
1
}
218

            
219
#[tokio::test(flavor = "current_thread")]
220
1
async fn handle_form_of_zero_arg_native_does_not_grow_cache() {
221
    // The pre-warmed module gets reused — the per-form compile step
222
    // hits `cache.get_or_compile` and finds the same bytecode, so
223
    // `cache.len()` stays the same.
224
1
    let mut session = Session::new(ScriptCtx::new(Uuid::new_v4())).unwrap();
225
1
    let before = session.cache_size().unwrap();
226
1
    let _ = session
227
1
        .handle_form("(:id 1 :form (rpc-protocol-version))")
228
1
        .await;
229
1
    let after = session.cache_size().unwrap();
230
1
    assert_eq!(before, after, "pre-warmed form must not re-compile");
231
1
}
232

            
233
#[tokio::test(flavor = "current_thread")]
234
1
async fn handle_form_of_unseen_form_grows_cache() {
235
    // An arg-bearing or otherwise novel form isn't in the pre-warm
236
    // set, so the first call compiles + stores. Locks in the cache
237
    // invariant: pre-warm is a strict subset of the cold-compile
238
    // surface.
239
1
    let mut session = Session::new(ScriptCtx::new(Uuid::new_v4())).unwrap();
240
1
    let before = session.cache_size().unwrap();
241
1
    let _ = session.handle_form("(:id 1 :form (+ 1 2 3 4 5))").await;
242
1
    let after = session.cache_size().unwrap();
243
1
    assert!(
244
1
        after > before,
245
1
        "unseen form must compile: {before} -> {after}"
246
1
    );
247
1
}
248

            
249
#[tokio::test(flavor = "current_thread")]
250
1
async fn handle_form_of_unseen_form_then_repeat_hits_cache() {
251
    // Second call to the same novel form hits the (now-warm) cache.
252
1
    let mut session = Session::new(ScriptCtx::new(Uuid::new_v4())).unwrap();
253
1
    let _ = session.handle_form("(:id 1 :form (* 7 8 9))").await;
254
1
    let after_first = session.cache_size().unwrap();
255
1
    let _ = session.handle_form("(:id 2 :form (* 7 8 9))").await;
256
1
    let after_second = session.cache_size().unwrap();
257
1
    assert_eq!(after_first, after_second, "repeat call must reuse cache");
258
1
}