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
6
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
6
    response
35
6
        .split_once(":value ")
36
6
        .map(|(_, rest)| rest.trim_end_matches(')').trim())
37
6
        .unwrap_or(response)
38
6
}
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_eq!(extract_value(&resp), "t");
65
1
    let resp_false = rpc_response("(= 1 2)").await;
66
1
    assert!(!resp_false.contains(":error"), "{resp_false}");
67
1
    assert_eq!(extract_value(&resp_false), "nil");
68
1
}
69

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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