1
use super::*;
2
use crate::ctx::ScriptLimits;
3
use nomiscript::{Fraction, Reader};
4

            
5
mod cl_boolean_tests;
6
mod list_tests;
7
mod nil_boxing_tests;
8

            
9
194
async fn handle_form_smoke(frame: &str) -> String {
10
194
    let ctx = ScriptCtx::new(uuid::Uuid::nil());
11
194
    let mut session = Session::new(ctx).expect("Session::new");
12
194
    session.handle_form(frame).await
13
194
}
14

            
15
142
async fn eval_value_of(form: &str) -> String {
16
142
    handle_form_smoke(&format!("(:id 4 :form {form})")).await
17
142
}
18

            
19
37
async fn assert_values(cases: &[(&str, &str)]) {
20
104
    for (form, value) in cases {
21
104
        assert_eq!(
22
104
            eval_value_of(form).await,
23
104
            format!("(:id 4 :value {value})"),
24
            "form {form}"
25
        );
26
    }
27
37
}
28

            
29
9
async fn assert_error_code(form: &str, code: &str) {
30
9
    let response = eval_value_of(form).await;
31
9
    assert!(
32
9
        response.contains(&format!(":code {code}")),
33
        "{form} must raise {code}, got: {response}"
34
    );
35
9
}
36

            
37
2
fn parse_to_value(input: &str) -> Result<Value, NomiError> {
38
2
    let program = Reader::parse(input)?;
39
2
    let mut symbols = SymbolTable::with_builtins();
40
2
    nomiscript::eval_program(&mut symbols, &program)
41
2
}
42

            
43
#[tokio::test]
44
1
async fn evaluates_arithmetic_and_returns_value_envelope() {
45
1
    let response = handle_form_smoke("(:id 1 :form (+ 1 2))").await;
46
1
    assert_eq!(response, "(:id 1 :value 3)");
47
1
}
48

            
49
#[tokio::test]
50
1
async fn evaluates_nested_arithmetic() {
51
1
    let response = handle_form_smoke("(:id 5 :form (* (+ 1 2) (- 10 4)))").await;
52
1
    assert_eq!(response, "(:id 5 :value 18)");
53
1
}
54

            
55
#[tokio::test]
56
1
async fn print_in_eval_mode_does_not_panic() {
57
    // Regression: PRINT / DISPLAY / NEWLINE / DEBUG lower to `env.log`,
58
    // which was script-mode-only. In eval mode the missing func index used
59
    // to SIGABRT the compiler (`registry.rs` `HashMap[key]`). The eval-mode
60
    // `log` import + the rpc `env.log` host fn now make it compile + run.
61
1
    let response = handle_form_smoke("(:id 1 :form (print \"hi\"))").await;
62
1
    assert!(response.contains(":id 1"), "got: {response}");
63
1
    assert!(!response.contains(":code"), "must not error: {response}");
64
1
}
65

            
66
#[tokio::test]
67
1
async fn dolist_with_print_in_eval_mode_runs() {
68
    // The exact shape from the Metro script that first surfaced the panic.
69
1
    let response = handle_form_smoke("(:id 2 :form (dolist (x (list 1 2 3)) (print x)))").await;
70
1
    assert!(response.contains(":id 2"), "got: {response}");
71
1
    assert!(!response.contains(":code"), "must not error: {response}");
72
1
}
73

            
74
#[tokio::test]
75
1
async fn handle_request_captures_output_and_value() {
76
    // `(print "hi")` writes "hi" to the per-request buffer (via env.log) and
77
    // its own return value is nil; the structured outcome must carry the
78
    // captured text in `output` AND the value payload — the SLYNK mREPL
79
    // renders these as `:write-string` + `:write-values` respectively.
80
1
    let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
81
1
    let outcome = session.handle_request("(print \"hi\")").await;
82
1
    assert!(
83
1
        outcome.output.contains("hi"),
84
        "captured output should contain the printed text, got: {:?}",
85
        outcome.output
86
    );
87
1
    assert!(
88
1
        matches!(outcome.payload, ResponsePayload::Value(_)),
89
1
        "payload should be a Value, got: {:?}",
90
1
        outcome.payload
91
1
    );
92
1
}
93

            
94
#[tokio::test]
95
1
async fn handle_request_value_only_has_empty_output() {
96
1
    let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
97
1
    let outcome = session.handle_request("(+ 1 2)").await;
98
1
    assert!(outcome.output.is_empty(), "got: {:?}", outcome.output);
99
1
    assert_eq!(
100
1
        outcome.payload,
101
1
        ResponsePayload::Value(Value::Number(Fraction::from_integer(3)))
102
1
    );
103
1
}
104

            
105
#[tokio::test]
106
1
async fn handle_request_rejects_plist_shaped_injection() {
107
    // Adversarial review: the source must be parsed as a standalone AST, not
108
    // interpolated into a `(:id 0 :form …)` envelope string — otherwise
109
    // `1 :form (+ 2 3)` would re-read as a plist and eval to `1`. It must
110
    // instead error (more than one top-level form), never silently return 1.
111
1
    let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
112
1
    let outcome = session.handle_request("1 :form (+ 2 3)").await;
113
1
    assert!(
114
1
        matches!(outcome.payload, ResponsePayload::Error { .. }),
115
1
        "plist-shaped input must error, got: {:?}",
116
1
        outcome.payload
117
1
    );
118
1
}
119

            
120
#[tokio::test]
121
1
async fn handle_request_interrupt_latch_aborts_before_eval() {
122
    // An interrupt latched before the request (the SLYNK reader arms it on
123
    // `(:emacs-interrupt)`) must abort the eval at the pre-start check, even
124
    // for a form that would otherwise compile fine.
125
1
    let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
126
1
    session.interrupt_handle().interrupt();
127
1
    let outcome = session.handle_request("(+ 1 2)").await;
128
1
    match outcome.payload {
129
1
        ResponsePayload::Error { code, .. } => {
130
1
            assert_eq!(code.as_symbol(), ErrorCode::INTERRUPTED);
131
1
        }
132
1
        other => panic!("expected interrupted error, got: {other:?}"),
133
1
    }
134
1
}
135

            
136
#[tokio::test]
137
1
async fn handle_file_evaluates_all_forms_and_persists_state() {
138
    // A file with a defun + a call to it: state accumulates across forms,
139
    // and the load returns a summary value (not the last form's value).
140
1
    let dir = std::env::temp_dir();
141
1
    let path = dir.join(format!("nms_load_test_{}.nms", std::process::id()));
142
1
    std::fs::write(&path, "(defun dbl (x) (* x 2))\n(dbl 21)\n").unwrap();
143
1
    let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
144
1
    let outcome = session.handle_file(path.to_str().unwrap()).await;
145
1
    std::fs::remove_file(&path).ok();
146
1
    match outcome.payload {
147
1
        ResponsePayload::Value(Value::String(s)) => {
148
1
            assert!(s.contains("loaded"), "summary: {s}");
149
1
            assert!(s.contains("2 forms"), "summary: {s}");
150
1
        }
151
1
        other => panic!("expected a load summary string, got: {other:?}"),
152
1
    }
153
1
}
154

            
155
#[tokio::test]
156
1
async fn handle_file_missing_path_errors() {
157
1
    let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
158
1
    let outcome = session.handle_file("/no/such/nms/file.nms").await;
159
1
    assert!(
160
1
        matches!(outcome.payload, ResponsePayload::Error { .. }),
161
1
        "got: {:?}",
162
1
        outcome.payload
163
1
    );
164
1
}
165

            
166
#[tokio::test]
167
1
async fn handle_file_aborts_on_a_bad_form() {
168
1
    let dir = std::env::temp_dir();
169
1
    let path = dir.join(format!("nms_load_bad_{}.nms", std::process::id()));
170
1
    std::fs::write(&path, "(+ 1 2)\n(undefined-symbol-here)\n").unwrap();
171
1
    let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
172
1
    let outcome = session.handle_file(path.to_str().unwrap()).await;
173
1
    std::fs::remove_file(&path).ok();
174
1
    assert!(
175
1
        matches!(outcome.payload, ResponsePayload::Error { .. }),
176
1
        "a bad form must abort the load, got: {:?}",
177
1
        outcome.payload
178
1
    );
179
1
}
180

            
181
#[tokio::test]
182
1
async fn handle_file_honours_interrupt_armed_before_load() {
183
    // A `C-g` that lands before/during the synchronous read+parse must abort
184
    // the load (the pre-read latch check), not be ignored until the first
185
    // form reaches the Wasm call.
186
1
    let dir = std::env::temp_dir();
187
1
    let path = dir.join(format!("nms_load_intr_{}.nms", std::process::id()));
188
1
    std::fs::write(&path, "(+ 1 2)\n(+ 3 4)\n").unwrap();
189
1
    let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
190
1
    session.interrupt_handle().interrupt();
191
1
    let outcome = session.handle_file(path.to_str().unwrap()).await;
192
1
    std::fs::remove_file(&path).ok();
193
1
    match outcome.payload {
194
1
        ResponsePayload::Error { code, .. } => {
195
1
            assert_eq!(code.as_symbol(), ErrorCode::INTERRUPTED, "got: {code:?}");
196
1
        }
197
1
        other => panic!("interrupt should abort the load, got: {other:?}"),
198
1
    }
199
1
}
200

            
201
#[tokio::test]
202
1
async fn handle_request_surfaces_error_payload() {
203
1
    let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
204
1
    let outcome = session.handle_request("does-not-exist").await;
205
1
    assert!(
206
1
        matches!(outcome.payload, ResponsePayload::Error { .. }),
207
1
        "payload should be an Error, got: {:?}",
208
1
        outcome.payload
209
1
    );
210
1
}
211

            
212
#[tokio::test]
213
1
async fn handle_request_clears_output_between_calls() {
214
    // The buffer must not leak across requests: a print then a pure value.
215
1
    let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
216
1
    let _ = session.handle_request("(print \"first\")").await;
217
1
    let second = session.handle_request("(+ 1 1)").await;
218
1
    assert!(
219
1
        second.output.is_empty(),
220
1
        "output leaked from prior request: {:?}",
221
1
        second.output
222
1
    );
223
1
}
224

            
225
#[tokio::test]
226
1
async fn returns_value_for_literal_form() {
227
1
    let response = handle_form_smoke("(:id 9 :form 42)").await;
228
1
    assert_eq!(response, "(:id 9 :value 42)");
229
1
}
230

            
231
#[tokio::test]
232
1
async fn returns_value_for_string_literal() {
233
1
    let response = handle_form_smoke("(:id 9 :form \"hello\")").await;
234
1
    assert_eq!(response, "(:id 9 :value \"hello\")");
235
1
}
236

            
237
#[test]
238
1
fn round_trips_bytes_through_eval() {
239
1
    let value = parse_to_value("'#u8(1 2 3)").unwrap();
240
1
    assert_eq!(value, Value::Bytes(vec![1, 2, 3]));
241
1
}
242

            
243
#[tokio::test]
244
1
async fn bad_envelope_emits_envelope_error() {
245
1
    let response = handle_form_smoke("(:form (+ 1 2))").await;
246
1
    assert!(response.contains(":code args"));
247
1
    assert!(response.contains(":id 0"));
248
1
}
249

            
250
#[tokio::test]
251
1
async fn malformed_envelope_emits_parse_error() {
252
1
    let response = handle_form_smoke("(((((").await;
253
1
    assert!(response.contains(":code parse"));
254
1
}
255

            
256
#[tokio::test]
257
1
async fn undefined_symbol_emits_compile_error() {
258
1
    let response = handle_form_smoke("(:id 7 :form does-not-exist)").await;
259
1
    assert!(response.contains(":id 7"));
260
1
    assert!(response.contains(":code compile"));
261
1
}
262

            
263
#[tokio::test]
264
1
async fn user_function_arity_violation_emits_args_error() {
265
1
    let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
266
1
    let _ = session
267
1
        .handle_form("(:id 1 :form (defun id-fn (x) x))")
268
1
        .await;
269
1
    let response = session.handle_form("(:id 2 :form (id-fn))").await;
270
1
    assert!(response.contains(":id 2"));
271
1
    assert!(response.contains(":code args"));
272
1
}
273

            
274
#[test]
275
1
fn completions_match_case_insensitively_and_skip_internal() {
276
1
    let session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
277
    // Lower-case input matches the upper-case folded symbol; the canonical
278
    // upper-case name is returned.
279
1
    let defuns = session.completions("def");
280
1
    assert!(defuns.contains(&"DEFUN".to_string()), "got: {defuns:?}");
281
1
    assert!(
282
7
        defuns.iter().all(|n| n.starts_with("DEF")),
283
        "got: {defuns:?}"
284
    );
285
    // Sorted; no internal `$`/`__` or `(SETF …)` place names.
286
1
    let all = session.completions("");
287
223
    assert!(all.windows(2).all(|w| w[0] <= w[1]), "must be sorted");
288
1
    assert!(
289
1
        all.iter()
290
224
            .all(|n| !n.starts_with('$') && !n.starts_with("__") && !n.starts_with("(SETF")),
291
        "internal/setf symbols must be filtered: {all:?}"
292
    );
293
1
}
294

            
295
#[tokio::test]
296
1
async fn completions_include_a_user_defined_symbol() {
297
1
    let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
298
1
    let _ = session
299
1
        .handle_form("(:id 1 :form (defun my-helper (x) x))")
300
1
        .await;
301
    // The defun's name is folded to upper-case; a lower-case prefix finds it.
302
1
    let hits = session.completions("my-");
303
1
    assert!(hits.contains(&"MY-HELPER".to_string()), "got: {hits:?}");
304
1
}
305

            
306
#[test]
307
1
fn completions_unknown_prefix_is_empty() {
308
1
    let session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
309
1
    assert!(session.completions("zzz-no-such-symbol-").is_empty());
310
1
}
311

            
312
#[tokio::test]
313
1
async fn interrupt_before_form_short_circuits_with_interrupted() {
314
1
    let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
315
1
    let handle = session.interrupt_handle();
316
1
    handle.interrupt();
317
1
    let response = session.handle_form("(:id 11 :form (+ 1 2))").await;
318
1
    assert!(response.contains(":id 11"));
319
1
    assert!(response.contains(":code interrupted"));
320
1
}
321

            
322
#[tokio::test]
323
1
async fn coalesced_interrupts_abort_one_form_each_in_order() {
324
    // Two `C-g`s observed together before a form cancel THAT form and are
325
    // coalesced (they do not pre-arm cancellation of a later one); a fresh,
326
    // distinct `C-g` later still cancels the next form. This pins the REPL
327
    // contract against both a "lost interrupt" and an "over-eager next-form
328
    // abort" regression.
329
1
    let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
330
1
    let handle = session.interrupt_handle();
331
1
    handle.interrupt();
332
1
    handle.interrupt(); // two presses before the form
333
1
    let first = session.handle_form("(:id 60 :form (+ 1 2))").await;
334
1
    assert!(
335
1
        first.contains(":code interrupted"),
336
        "first form must abort: {first}"
337
    );
338
    // Both presses were coalesced into the one abort — the next form is clean.
339
1
    let second = session.handle_form("(:id 61 :form (+ 1 2))").await;
340
1
    assert_eq!(
341
        second, "(:id 61 :value 3)",
342
        "next form coalesced-poisoned: {second}"
343
    );
344
    // A fresh, distinct press still aborts the following form.
345
1
    handle.interrupt();
346
1
    let third = session.handle_form("(:id 62 :form (+ 1 2))").await;
347
1
    assert!(
348
1
        third.contains(":code interrupted"),
349
1
        "a distinct later interrupt must still abort: {third}"
350
1
    );
351
1
}
352

            
353
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
354
1
async fn inflight_interrupt_does_not_poison_next_form() {
355
    // The SLYNK reader arms BOTH the epoch bump and the interrupt signal on
356
    // `(:emacs-interrupt)` (mod.rs). When that lands mid-eval the epoch trap
357
    // cancels the running call; that interrupt generation must then be acked
358
    // so the SAME `C-g` can't also abort the next request.
359
1
    let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
360
1
    let bumper = session.epoch_bumper();
361
1
    let interrupt = session.interrupt_handle();
362
1
    let cancel_task = tokio::spawn(async move {
363
1
        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
364
1
        bumper.bump();
365
1
        interrupt.interrupt();
366
1
    });
367
1
    let cancelled = session
368
1
        .handle_form("(:id 30 :form (do ((i 0 (+ i 1))) ((>= i 1000000) i)))")
369
1
        .await;
370
1
    cancel_task.await.unwrap();
371
1
    assert!(
372
1
        cancelled.contains(":code interrupted") || cancelled.contains(":code runtime"),
373
        "in-flight eval should have been cancelled: {cancelled}"
374
    );
375
    // The next form must evaluate normally — the acked interrupt must not abort it.
376
1
    let next = session.handle_form("(:id 31 :form (+ 1 2))").await;
377
1
    assert_eq!(next, "(:id 31 :value 3)", "next form was poisoned: {next}");
378
1
}
379

            
380
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
381
1
async fn non_interrupt_failure_still_consumes_a_concurrent_interrupt() {
382
    // Race: an interrupt is signalled WHILE a form is in flight, but the
383
    // form loses to its own terminal error (out-of-fuel) before any epoch
384
    // bump could win. The in-flight form is finished, so the interrupt must
385
    // be acked on the error path too — otherwise it poisons the next form.
386
1
    let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
387
1
    let interrupt = session.interrupt_handle();
388
1
    let latch_task = tokio::spawn(async move {
389
        // Latch only (no epoch bump): the form trips out-of-fuel on its own.
390
1
        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
391
1
        interrupt.interrupt();
392
1
    });
393
1
    let failed = session
394
1
        .handle_form("(:id 50 :form (do ((i 0 (+ i 1))) ((>= i 100000000) i)))")
395
1
        .await;
396
1
    latch_task.await.unwrap();
397
1
    assert!(
398
1
        failed.contains(":code runtime") || failed.contains(":code interrupted"),
399
        "in-flight form should fail terminally: {failed}"
400
    );
401
1
    let next = session.handle_form("(:id 51 :form (+ 1 2))").await;
402
1
    assert_eq!(next, "(:id 51 :value 3)", "next form was poisoned: {next}");
403
1
}
404

            
405
#[tokio::test]
406
1
async fn interrupt_after_clean_eval_aborts_next_form() {
407
    // The mirror of the above: after a form completes NORMALLY, an interrupt
408
    // armed before the next form must still abort it. The in-flight cleanup
409
    // only consumes the latch on an actual epoch-interrupt trap, so a `C-g`
410
    // that lands post-completion is NOT swallowed.
411
1
    let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
412
1
    let clean = session.handle_form("(:id 40 :form (+ 1 2))").await;
413
1
    assert_eq!(clean, "(:id 40 :value 3)");
414
1
    session.interrupt_handle().interrupt();
415
1
    let interrupted = session.handle_form("(:id 41 :form (+ 4 5))").await;
416
1
    assert!(
417
1
        interrupted.contains(":code interrupted"),
418
1
        "post-completion interrupt must abort the next form: {interrupted}"
419
1
    );
420
1
}
421

            
422
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
423
1
async fn epoch_bumper_cancels_inflight_long_eval() {
424
    // The one test that pins the EPOCH-TRAP cancel path specifically.
425
    //
426
    // Two things can end an in-flight eval with `:code interrupted`: the
427
    // pre-start `check_interrupt` latch, and the epoch trap inside Wasm.
428
    // They are easy to confuse, and a test that accepts either silently
429
    // stops covering the trap the moment timing drifts — which is exactly
430
    // what a coverage-instrumented build does to it.
431
    //
432
    // This isolates the trap structurally rather than by timing:
433
    //   - the interrupt handle is never touched, so `check_interrupt` can
434
    //     not fire at all and the pre-start path is unreachable;
435
    //   - fuel is effectively unbounded and the loop bound is just under
436
    //     i32::MAX, so the form can neither finish nor exhaust fuel, ruling
437
    //     out `:code runtime`;
438
    //   - the bumper runs in a loop rather than after a fixed sleep, so a
439
    //     slow host widens the window instead of missing it.
440
    // The assertions then pin the trap by its wire signature: EpochInterrupt
441
    // carries a `:detail`, whereas the pre-start latch reports
442
    // "evaluation interrupted before start" with none.
443
1
    let ctx = ScriptCtx::new(uuid::Uuid::nil()).with_limits(ScriptLimits {
444
1
        fuel: u64::MAX,
445
1
        ..ScriptLimits::default()
446
1
    });
447
1
    let mut session = Session::new(ctx).expect("Session::new");
448
1
    let bumper = session.epoch_bumper();
449
1
    let done = Arc::new(std::sync::atomic::AtomicBool::new(false));
450
1
    let bump_until_done = done.clone();
451
1
    let bump_task = tokio::spawn(async move {
452
238
        while !bump_until_done.load(std::sync::atomic::Ordering::Relaxed) {
453
237
            bumper.bump();
454
237
            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
455
        }
456
1
    });
457
    // Bounded: if epoch interruption regresses, the form runs to a bound just
458
    // under i32::MAX with unbounded fuel and never returns. Without this the
459
    // test would stop being a test and become a job that burns until the k8s
460
    // deadline kills it — silently, since it produces no output meanwhile.
461
1
    let response = tokio::time::timeout(
462
1
        std::time::Duration::from_secs(60),
463
1
        session.handle_form("(:id 22 :form (do ((i 0 (+ i 1))) ((>= i 2000000000) i)))"),
464
1
    )
465
1
    .await
466
1
    .expect("epoch bump did not cancel the eval within 60s — interruption has regressed");
467
1
    done.store(true, std::sync::atomic::Ordering::Relaxed);
468
1
    bump_task.await.unwrap();
469

            
470
1
    assert!(response.contains(":id 22"), "{response}");
471
1
    assert!(
472
1
        response.contains(":code interrupted"),
473
        "epoch bump must cancel the in-flight eval: {response}"
474
    );
475
1
    assert!(
476
1
        !response.contains("evaluation interrupted before start"),
477
        "took the pre-start latch path, so the epoch trap went untested: {response}"
478
    );
479
1
    assert!(
480
1
        response.contains(":detail"),
481
1
        "EpochInterrupt must surface its detail; got: {response}"
482
1
    );
483
1
}
484

            
485
#[test]
486
1
fn host_prelude_helper_is_loaded_and_compiles() {
487
    // ADR-0029 host-dependent prelude: split:list-for-transaction is loaded
488
    // on the Session path (after register_host_fns) and is callable. We
489
    // compile a form referencing it — proving load + name resolution + the
490
    // qualified native dispatch wire up — WITHOUT running it (it bottoms out
491
    // in the DB-backed list-splits-by-transaction native; execution is
492
    // covered by the db-gated integration test).
493
1
    let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
494
1
    assert!(
495
1
        session.symbols.contains("SPLIT:LIST-FOR-TRANSACTION"),
496
        "host prelude helper not registered"
497
    );
498
    // Compile (not run) a real call: lowering emits the qualified-name
499
    // dispatch + the native import, proving resolution wires up. Host fns
500
    // never execute at compile time, so no DB is touched.
501
1
    let program = Reader::parse("(split:list-for-transaction (car (list-transactions \"\")))")
502
1
        .expect("parse");
503
1
    if let Err(e) = session
504
1
        .compiler
505
1
        .compile_eval_with_type(&program, &mut session.symbols)
506
    {
507
        panic!("host prelude helper failed to compile: {e:?}");
508
1
    }
509
1
}
510

            
511
#[tokio::test]
512
1
async fn car_of_quoted_constant_list_compiles_on_eval_path() {
513
    // Regression: CAR/CDR of a quoted CONSTANT list folds on the codegen
514
    // path but used to trap on the eval-with-type (Session) path because
515
    // the stack handler called compile_for_stack on the quoted arg, which
516
    // has no Quote arm. The stack handlers now const-fold first.
517
1
    let response = handle_form_smoke("(:id 1 :form (car '(1 2 3)))").await;
518
1
    assert_eq!(response, "(:id 1 :value 1)");
519
1
}
520

            
521
#[tokio::test]
522
1
async fn car_of_quoted_heterogeneous_list_compiles_on_eval_path() {
523
    // Mixed number + string quoted list — the element is extracted by fold.
524
1
    let response = handle_form_smoke("(:id 1 :form (car '(7 \"x\")))").await;
525
1
    assert_eq!(response, "(:id 1 :value 7)");
526
1
}
527

            
528
#[tokio::test]
529
1
async fn car_of_cdr_of_quoted_constant_compiles_on_eval_path() {
530
    // CDR folds to the quoted tail, then CAR extracts its head — exercises
531
    // the cdr fold-first path feeding car.
532
1
    let response = handle_form_smoke("(:id 1 :form (car (cdr '(1 2 3))))").await;
533
1
    assert_eq!(response, "(:id 1 :value 2)");
534
1
}
535

            
536
#[tokio::test]
537
1
async fn cdr_of_quoted_constant_renders_tail_on_eval_path() {
538
    // The bare-CDR case (no enclosing CAR): folds to a quoted tail and
539
    // renders to its printed form rather than trapping.
540
1
    assert_eq!(
541
1
        handle_form_smoke("(:id 1 :form (cdr '(1 2 3)))").await,
542
        "(:id 1 :value \"(2 3)\")"
543
    );
544
1
    assert_eq!(
545
1
        handle_form_smoke("(:id 1 :form (cdr '(1)))").await,
546
1
        "(:id 1 :value nil)"
547
1
    );
548
1
}
549

            
550
#[tokio::test]
551
1
async fn car_of_quoted_compound_and_symbol_heads_render_as_data() {
552
    // A compound or symbol head is quoted DATA, not code — it renders to
553
    // its printed form (not resolved as a call / variable).
554
1
    assert_eq!(
555
1
        handle_form_smoke("(:id 1 :form (car '((1 2) 3)))").await,
556
        "(:id 1 :value \"(1 2)\")"
557
    );
558
1
    assert_eq!(
559
1
        handle_form_smoke("(:id 1 :form (car '(x y)))").await,
560
1
        "(:id 1 :value \"X\")"
561
1
    );
562
1
}
563

            
564
#[tokio::test]
565
1
async fn reverse_of_constant_list_renders_on_eval_path() {
566
    // Regression (same class as CAR/CDR): REVERSE of a constant or
567
    // runtime-builder list folded but the stack handler rejected the
568
    // non-runtime-pair result on the eval-with-type path. Now it renders
569
    // the reversed datum on both surfaces.
570
1
    assert_eq!(
571
1
        handle_form_smoke("(:id 1 :form (reverse '(1 2 3)))").await,
572
        "(:id 1 :value \"(3 2 1)\")"
573
    );
574
1
    assert_eq!(
575
1
        handle_form_smoke("(:id 1 :form (reverse (list 1 2 3)))").await,
576
        "(:id 1 :value \"(3 2 1)\")"
577
    );
578
    // Composition still folds through to the element.
579
1
    assert_eq!(
580
1
        handle_form_smoke("(:id 1 :form (car (reverse '(1 2 3))))").await,
581
1
        "(:id 1 :value 3)"
582
1
    );
583
1
}
584

            
585
#[tokio::test]
586
1
async fn cons_onto_constant_list_renders_on_eval_path() {
587
    // Regression: CONS with a constant / runtime-builder list cdr trapped
588
    // in push_pair_cdr on the eval-with-type path. A fully-constant cons
589
    // now folds and renders the list datum; a dotted pair renders too.
590
1
    assert_eq!(
591
1
        handle_form_smoke("(:id 1 :form (cons 0 '(1 2 3)))").await,
592
        "(:id 1 :value \"(0 1 2 3)\")"
593
    );
594
1
    assert_eq!(
595
1
        handle_form_smoke("(:id 1 :form (cons 0 (list 1 2 3)))").await,
596
        "(:id 1 :value \"(0 1 2 3)\")"
597
    );
598
1
    assert_eq!(
599
1
        handle_form_smoke("(:id 1 :form (cons 1 2))").await,
600
1
        "(:id 1 :value \"(1 . 2)\")"
601
1
    );
602
1
}
603

            
604
#[tokio::test]
605
1
async fn append_of_constant_lists_renders_on_eval_path() {
606
    // Regression: all-constant APPEND folds to a quoted list; the stack
607
    // handler used to force it through runtime materialization (which can't
608
    // represent symbols) instead of rendering the folded datum.
609
1
    assert_eq!(
610
1
        handle_form_smoke("(:id 1 :form (append '(1 2) '(3)))").await,
611
        "(:id 1 :value \"(1 2 3)\")"
612
    );
613
1
    assert_eq!(
614
1
        handle_form_smoke("(:id 1 :form (append '(a b) '(c)))").await,
615
1
        "(:id 1 :value \"(A B C)\")"
616
1
    );
617
1
}
618

            
619
#[tokio::test]
620
1
async fn universal_prelude_helper_runs_end_to_end() {
621
    // The universal prelude is loaded on the Session path too; a math:*
622
    // helper executes through wasm and returns its value. No DB.
623
1
    let response = handle_form_smoke("(:id 9 :form (math:square 9))").await;
624
1
    assert_eq!(response, "(:id 9 :value 81)");
625
1
}
626

            
627
#[tokio::test]
628
1
async fn pp_form_at_value_position_returns_string() {
629
    // Exercises compile_pp_for_stack — the path nms / emacs see
630
    // when `(pp 42)` is the request form.
631
1
    let resp = handle_form_smoke("(:id 7 :form (pp 42))").await;
632
1
    assert!(resp.contains(":id 7"), "{resp}");
633
1
    assert!(resp.contains("\"42\""), "{resp}");
634
1
}
635

            
636
#[tokio::test]
637
1
async fn describe_form_at_value_position_returns_doc() {
638
    // compile_describe_for_stack — same shape.
639
1
    let resp = handle_form_smoke("(:id 8 :form (describe '+))").await;
640
1
    assert!(resp.contains(":id 8"), "{resp}");
641
1
    assert!(!resp.contains(":error"), "{resp}");
642
1
}
643

            
644
#[tokio::test]
645
1
async fn apropos_form_at_value_position_returns_list() {
646
1
    let resp = handle_form_smoke("(:id 9 :form (apropos \"entity\"))").await;
647
1
    assert!(resp.contains(":id 9"), "{resp}");
648
1
    assert!(!resp.contains(":error"), "{resp}");
649
1
}
650

            
651
#[tokio::test]
652
1
async fn deftest_form_at_value_position_returns_quoted_name() {
653
1
    let resp = handle_form_smoke("(:id 10 :form (deftest sanity (assert-equal 1 1)))").await;
654
1
    assert!(resp.contains(":id 10"), "{resp}");
655
1
    assert!(!resp.contains(":error"), "{resp}");
656
1
}
657

            
658
#[tokio::test]
659
1
async fn assert_equal_pass_form_at_value_position() {
660
1
    let resp = handle_form_smoke("(:id 11 :form (assert-equal 2 2))").await;
661
1
    assert!(resp.contains(":id 11"), "{resp}");
662
1
    assert!(!resp.contains(":error"), "{resp}");
663
1
}
664

            
665
#[tokio::test]
666
1
async fn assert_equal_fail_surfaces_as_error() {
667
    // assert_equal returns Err on mismatch; Session maps to
668
    // :code compile error envelope (it's a NomiError::Compile).
669
1
    let resp = handle_form_smoke("(:id 12 :form (assert-equal 1 2))").await;
670
1
    assert!(resp.contains(":id 12"), "{resp}");
671
1
    assert!(resp.contains(":error"), "{resp}");
672
1
}
673

            
674
#[tokio::test]
675
1
async fn coverage_dump_lists_called_natives() {
676
1
    let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
677
1
    let _ = session
678
1
        .handle_form("(:id 1 :form (rpc-protocol-version))")
679
1
        .await;
680
1
    let dump = session.handle_form("(:id 2 :form (coverage-dump))").await;
681
1
    assert!(dump.contains("RPC-PROTOCOL-VERSION"), "{dump}");
682
1
    assert!(dump.contains(":id 2"), "{dump}");
683
1
}
684

            
685
#[tokio::test]
686
1
async fn coverage_dump_reports_pre_warmed_natives() {
687
    // Session::new pre-compiles every zero-arg native fn (phase 4
688
    // fast-path stubs), so coverage-dump is non-empty even before
689
    // a user form lands. This is the desired semantic: it
690
    // reflects compile-time reference counts, including pre-warm
691
    // compilations, which is what the parity contract gates on.
692
1
    let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
693
1
    let dump = session.handle_form("(:id 1 :form (coverage-dump))").await;
694
1
    assert!(dump.contains(":id 1"), "{dump}");
695
1
    assert!(dump.contains("RPC-PROTOCOL-VERSION"), "{dump}");
696
1
}
697

            
698
#[tokio::test]
699
1
async fn interrupt_does_not_persist_across_forms() {
700
1
    let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
701
1
    session.interrupt_handle().interrupt();
702
1
    let _ = session.handle_form("(:id 11 :form (+ 1 2))").await;
703
1
    let response = session.handle_form("(:id 12 :form (+ 1 2))").await;
704
1
    assert_eq!(response, "(:id 12 :value 3)");
705
1
}
706

            
707
#[tokio::test]
708
1
async fn session_state_persists_across_forms() {
709
1
    let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
710
1
    let defun = session
711
1
        .handle_form("(:id 1 :form (defun double (x) (* 2 x)))")
712
1
        .await;
713
1
    assert!(defun.contains(":id 1"));
714
1
    let call = session.handle_form("(:id 2 :form (double 21))").await;
715
1
    assert_eq!(call, "(:id 2 :value 42)");
716
1
}
717

            
718
#[tokio::test]
719
1
async fn fraction_results_format_canonically() {
720
    // A fractional (Scalar) literal renders canonically as `n/d`. (Integer
721
    // `(/ 1 4)` is now Index division → 0 per ADR-0028; the canonical
722
    // fraction idiom is the `1/4` Scalar literal.)
723
1
    let response = handle_form_smoke("(:id 3 :form 1/4)").await;
724
1
    assert_eq!(response, "(:id 3 :value 1/4)");
725
1
}
726

            
727
#[test]
728
1
fn nomi_runtime_value_carries_through() {
729
1
    let value = parse_to_value("(+ 0.5 0.25)").unwrap();
730
1
    assert_eq!(value, Value::Number(Fraction::new(3, 4)));
731
1
}
732

            
733
#[tokio::test]
734
1
async fn calls_meta_native_from_nomiscript_source() {
735
1
    let response = handle_form_smoke("(:id 1 :form (rpc-protocol-version))").await;
736
1
    let expected_version = crate::natives::meta::PROTOCOL_VERSION;
737
1
    assert_eq!(response, format!("(:id 1 :value {expected_version})"));
738
1
}
739

            
740
#[tokio::test]
741
1
async fn calls_server_get_version_from_nomiscript_source() {
742
1
    let response = handle_form_smoke("(:id 1 :form (get-version))").await;
743
    // GIT_HASH is baked at server crate build time via env!. We don't
744
    // assert its exact value (changes per build) — just that the
745
    // envelope round-trips a non-empty :value string.
746
1
    assert!(
747
1
        response.starts_with("(:id 1 :value \""),
748
        "expected string response, got: {response}"
749
    );
750
1
    assert!(response.ends_with("\")"));
751
1
}
752

            
753
#[tokio::test]
754
1
async fn calls_server_get_build_date_from_nomiscript_source() {
755
1
    let response = handle_form_smoke("(:id 2 :form (get-build-date))").await;
756
1
    assert!(
757
1
        response.starts_with("(:id 2 :value \""),
758
        "expected string response, got: {response}"
759
    );
760
1
    assert!(response.ends_with("\")"));
761
1
}
762

            
763
#[tokio::test]
764
1
async fn cons_list_surfaces_as_printable_string() {
765
    // First WasmGC sub-slice: eval-mode `(cons ...)` chains now
766
    // capture through pending_string instead of erroring at compile
767
    // time. The result is a textual `(1 2 3)` value the emacs client
768
    // can (read) back into a real list. Heterogeneous car types ride
769
    // a follow-up slice once Pair/Vector/Closure/Struct share a
770
    // tagged union — today's cons cell stores i32 payloads only.
771
1
    let response = handle_form_smoke("(:id 12 :form (cons 1 (cons 2 (cons 3 nil))))").await;
772
1
    assert!(
773
1
        response.contains(":value \"(1 2 3)\""),
774
1
        "expected :value \"(1 2 3)\", got: {response}"
775
1
    );
776
1
}
777

            
778
#[tokio::test]
779
1
async fn count_native_cannot_mix_with_ratio_arithmetic() {
780
    // account-count returns i32 (a count / Index, not a Scalar). Mixing it
781
    // with a fractional Scalar literal must fail to compile — the design
782
    // forbids accidental arithmetic across the Index/Scalar strata. (An
783
    // integer literal like `10` is itself an Index now (ADR-0028), so
784
    // `(+ 10 (account-count))` is valid Index arithmetic; the genuine
785
    // stratum clash needs a fractional `1/2` Scalar operand.) The explicit
786
    // `index->scalar` bridge is the only legal crossing.
787
1
    let response = handle_form_smoke("(:id 11 :form (+ 1/2 (account-count)))").await;
788
1
    assert!(response.contains(":code compile"), "got: {response}");
789
1
    assert!(
790
1
        response.contains("scalar") && response.contains("index"),
791
1
        "expected Index/Scalar stratum-separation error, got: {response}"
792
1
    );
793
1
}
794

            
795
#[tokio::test]
796
1
async fn get_commodity_with_non_uuid_arg_falls_back_to_symbol_lookup() {
797
    // get-commodity now accepts a uuid OR a symbol (mirroring get-account's
798
    // name fallback), so a non-uuid arg is NO LONGER an "invalid uuid"
799
    // error — it's treated as a symbol and routed to a DB lookup. On this
800
    // no-DB smoke harness that lookup surfaces a runtime DB-access error
801
    // (not a parse error); a DB-backed test
802
    // (`get_commodity_resolves_by_symbol` in tests-integration) covers the
803
    // successful resolution.
804
1
    let response = handle_form_smoke("(:id 9 :form (get-commodity \"USD\"))").await;
805
1
    assert!(response.contains(":id 9"), "got: {response}");
806
1
    assert!(
807
1
        response.contains(":code runtime") && response.contains("get-commodity"),
808
        "expected a get-commodity runtime error (symbol path hits the DB), got: {response}"
809
    );
810
1
    assert!(
811
1
        !response.contains("invalid uuid"),
812
1
        "a non-uuid arg must no longer short-circuit as an invalid-uuid error: {response}"
813
1
    );
814
1
}
815

            
816
#[test]
817
1
fn meta_native_unknown_in_script_mode_compile() {
818
    // host_fns are only registered in eval-mode contexts; the compiler
819
    // built without with_host_fns shouldn't see them. Sanity that the
820
    // mode flag actually gates the registration.
821
    use nomiscript::CompileMode;
822
1
    let mut compiler = Compiler::new();
823
1
    let mut symbols = SymbolTable::with_builtins();
824
1
    let program = nomiscript::Reader::parse("(rpc-protocol-version)").unwrap();
825
1
    let result = compiler.compile_with_mode(&program, &mut symbols, CompileMode::Script);
826
1
    assert!(
827
1
        result.is_err(),
828
        "host fn should not be callable when compiler has no specs"
829
    );
830
1
}
831

            
832
#[tokio::test]
833
1
async fn get_config_missing_name_yields_error_envelope() {
834
1
    let resp = handle_form_smoke("(:id 20 :form (get-config \"\"))").await;
835
1
    assert!(resp.contains(":id 20"), "{resp}");
836
1
    assert!(resp.contains(":error"), "{resp}");
837
1
    assert!(!resp.contains(":value"), "{resp}");
838
1
}
839

            
840
#[tokio::test]
841
1
async fn get_config_success_shape_is_config_value() {
842
    // Passes a non-empty name; the DB call fails (no pool) and surfaces
843
    // as an :error envelope — we verify the SUCCESS wire shape is only
844
    // emitted when the command actually succeeds. Here we just confirm
845
    // the argument-validation path (empty string) always errors.
846
1
    let resp = handle_form_smoke("(:id 21 :form (get-config \"\"))").await;
847
1
    assert!(resp.contains(":id 21"), "{resp}");
848
1
    assert!(!resp.contains(":value"), "{resp}");
849
1
}