1
use super::*;
2

            
3
mod bool_decode_tests;
4

            
5
#[test]
6
1
fn err_code_uses_script_raised_symbol_verbatim() {
7
1
    let (code, msg) = err_code_and_message(&EngineError::ScriptRaised {
8
1
        code: "no-such-account".to_string(),
9
1
        message: "id=42".to_string(),
10
1
    });
11
1
    assert_eq!(code, "no-such-account");
12
1
    assert_eq!(msg, "id=42");
13
1
}
14

            
15
#[test]
16
1
fn err_code_maps_commodity_mismatch_script_raise_to_symbol() {
17
    // Commodity mismatch now `throw`s `$nomi_error` in-guest (ADR-0026);
18
    // uncaught, the boundary wrapper bridges it to `__nomi_raise` and the
19
    // classifier yields `ScriptRaised`. The code is the reader-folded
20
    // (upper-cased) symbol `COMMODITY-MISMATCH`, like any script raise —
21
    // `err_code_and_message` passes a `ScriptRaised` code through verbatim.
22
1
    let (code, msg) = err_code_and_message(&EngineError::ScriptRaised {
23
1
        code: "COMMODITY-MISMATCH".to_string(),
24
1
        message: "USD vs EUR".to_string(),
25
1
    });
26
1
    assert_eq!(code, "COMMODITY-MISMATCH");
27
1
    assert_eq!(msg, "USD vs EUR");
28
1
}
29

            
30
#[test]
31
1
fn err_code_maps_no_conversion_to_kebab_symbol() {
32
1
    let (code, msg) = err_code_and_message(&EngineError::NoConversion("missing price".to_string()));
33
1
    assert_eq!(code, "no-conversion");
34
1
    assert_eq!(msg, "missing price");
35
1
}
36

            
37
#[test]
38
1
fn err_code_falls_back_to_runtime_for_generic_traps() {
39
1
    let (code, msg) = err_code_and_message(&EngineError::Trap("oops".to_string()));
40
1
    assert_eq!(code, "runtime");
41
1
    assert_eq!(msg, "oops");
42
1
}
43

            
44
#[test]
45
1
fn err_code_maps_out_of_fuel_to_runtime_with_diagnostic_message() {
46
1
    let (code, msg) = err_code_and_message(&EngineError::OutOfFuel);
47
1
    assert_eq!(code, "runtime");
48
1
    assert_eq!(msg, "fuel exhausted");
49
1
}
50

            
51
3
fn store_with_fuel<T: Default>(engine: &Engine, fuel: u64) -> Store<T> {
52
3
    let mut store = Store::new(engine, T::default());
53
3
    store
54
3
        .set_fuel(fuel)
55
3
        .expect("set_fuel must succeed for fresh store");
56
3
    store.set_epoch_deadline(1);
57
3
    store
58
3
}
59

            
60
#[test]
61
1
fn baseline_engine_omits_fuel() {
62
1
    let opts = EngineOpts::baseline();
63
1
    assert!(!opts.fuel);
64
1
    let _engine = build_engine(opts).expect("baseline engine must build");
65
1
}
66

            
67
#[test]
68
1
fn with_fuel_engine_supports_set_fuel() {
69
1
    let engine = build_engine(EngineOpts::baseline().with_fuel()).unwrap();
70
1
    let mut store: Store<()> = Store::new(&engine, ());
71
1
    store
72
1
        .set_fuel(1_000)
73
1
        .expect("set_fuel works only when consume_fuel is on");
74
1
}
75

            
76
#[test]
77
1
fn module_cache_returns_same_module_for_same_bytecode() {
78
1
    let engine = build_engine(EngineOpts::baseline()).unwrap();
79
1
    let cache = ModuleCache::new();
80
1
    let wat = r#"(module (func (export "answer") (result i64) (i64.const 42)))"#;
81
1
    let bytes = wat::parse_str(wat).unwrap();
82
1
    assert_eq!(cache.len().unwrap(), 0);
83
1
    let _first = cache.get_or_compile(&engine, &bytes).unwrap();
84
1
    assert_eq!(cache.len().unwrap(), 1);
85
1
    let _second = cache.get_or_compile(&engine, &bytes).unwrap();
86
1
    assert_eq!(cache.len().unwrap(), 1);
87
1
}
88

            
89
#[test]
90
1
fn module_cache_clones_share_storage() {
91
1
    let engine = build_engine(EngineOpts::baseline()).unwrap();
92
1
    let cache_a = ModuleCache::new();
93
1
    let cache_b = cache_a.clone();
94
1
    let wat = r#"(module (func (export "answer") (result i64) (i64.const 42)))"#;
95
1
    let bytes = wat::parse_str(wat).unwrap();
96
1
    let _ = cache_a.get_or_compile(&engine, &bytes).unwrap();
97
1
    assert_eq!(cache_b.len().unwrap(), 1);
98
1
}
99

            
100
#[test]
101
1
fn runs_trivial_i64_export() {
102
1
    let engine = build_engine(EngineOpts::baseline().with_fuel()).unwrap();
103
1
    let module = compile_wat(
104
1
        &engine,
105
1
        r#"(module (func (export "answer") (result i64) (i64.const 42)))"#,
106
    )
107
1
    .unwrap();
108
1
    let mut store: Store<()> = store_with_fuel(&engine, 100_000);
109
1
    let result = call_i64_export(&engine, &mut store, &module, "answer").unwrap();
110
1
    assert_eq!(result, 42);
111
1
}
112

            
113
#[test]
114
1
fn missing_export_returns_typed_error() {
115
1
    let engine = build_engine(EngineOpts::baseline().with_fuel()).unwrap();
116
1
    let module = compile_wat(
117
1
        &engine,
118
1
        r#"(module (func (export "answer") (result i64) (i64.const 42)))"#,
119
    )
120
1
    .unwrap();
121
1
    let mut store: Store<()> = store_with_fuel(&engine, 100_000);
122
1
    let err = call_i64_export(&engine, &mut store, &module, "missing").unwrap_err();
123
1
    assert!(matches!(err, EngineError::MissingExport(name) if name == "missing"));
124
1
}
125

            
126
#[test]
127
1
fn fuel_exhaustion_yields_typed_error() {
128
1
    let engine = build_engine(EngineOpts::baseline().with_fuel()).unwrap();
129
1
    let module = compile_wat(
130
1
        &engine,
131
1
        r#"
132
1
            (module
133
1
              (func (export "spin") (result i64)
134
1
                (loop (br 0))
135
1
                (i64.const 0)))
136
1
            "#,
137
    )
138
1
    .unwrap();
139
1
    let mut store: Store<()> = store_with_fuel(&engine, 1_000);
140
1
    let err = call_i64_export(&engine, &mut store, &module, "spin").unwrap_err();
141
1
    assert!(matches!(err, EngineError::OutOfFuel), "got: {err:?}");
142
1
}
143

            
144
#[test]
145
1
fn epoch_interrupt_yields_typed_error() {
146
1
    let engine = build_engine(EngineOpts::baseline().with_fuel()).unwrap();
147
1
    let module = compile_wat(
148
1
        &engine,
149
1
        r#"
150
1
            (module
151
1
              (func (export "spin") (result i64)
152
1
                (loop (br 0))
153
1
                (i64.const 0)))
154
1
            "#,
155
    )
156
1
    .unwrap();
157
1
    let mut store: Store<()> = Store::new(&engine, ());
158
1
    store.set_fuel(1_000_000_000).unwrap();
159
1
    store.set_epoch_deadline(1);
160
1
    engine.increment_epoch();
161
1
    engine.increment_epoch();
162
1
    let err = call_i64_export(&engine, &mut store, &module, "spin").unwrap_err();
163
1
    assert!(
164
1
        matches!(err, EngineError::EpochInterrupt | EngineError::OutOfFuel),
165
        "got: {err:?}"
166
    );
167
1
}
168

            
169
#[test]
170
1
fn malformed_module_bytes_yield_compile_error() {
171
1
    let engine = build_engine(EngineOpts::baseline()).unwrap();
172
1
    let err = compile_module(&engine, b"not wasm bytes").unwrap_err();
173
1
    assert!(matches!(err, EngineError::Compile(_)));
174
1
}
175

            
176
#[tokio::test(flavor = "current_thread")]
177
1
async fn alloc_pair_chain_builds_list_head_in_order() {
178
    use wasmtime::I31;
179

            
180
    // Self-recursive $pair shape matching `CompileContext::new_skeleton`.
181
    // The module exports `pair_new` (the helper alloc_pair_chain re-enters
182
    // for each element) and a `go` entry that asks the test host fn for a
183
    // 3-element chain, then walks it to confirm the host-built structure.
184
1
    let wat = r#"
185
1
        (module
186
1
          (rec
187
1
            (type $pair (struct (field anyref) (field (ref null $pair)))))
188
1
          (import "test" "make_chain"
189
1
            (func $make_chain (result (ref null struct))))
190
1
          (func $pair_new (export "pair_new")
191
1
            (param $car anyref) (param $cdr (ref null $pair))
192
1
            (result (ref null $pair))
193
1
            (struct.new $pair (local.get $car) (local.get $cdr)))
194
1
          (func $length (param $head (ref null $pair)) (result i32)
195
1
            (local $count i32)
196
1
            (block $exit
197
1
              (loop $more
198
1
                (br_if $exit (ref.is_null (local.get $head)))
199
1
                (local.set $count (i32.add (local.get $count) (i32.const 1)))
200
1
                (local.set $head
201
1
                  (struct.get $pair 1 (local.get $head)))
202
1
                (br $more)))
203
1
            (local.get $count))
204
1
          (func (export "go") (result i32)
205
1
            (local $head (ref null $pair))
206
1
            (local.set $head
207
1
              (ref.cast (ref null $pair) (call $make_chain)))
208
1
            (call $length (local.get $head))))
209
1
        "#;
210

            
211
1
    let engine = build_engine(EngineOpts::baseline()).unwrap();
212
1
    let module = compile_wat(&engine, wat).unwrap();
213
1
    let mut linker: Linker<()> = Linker::new(&engine);
214
1
    linker
215
1
        .func_wrap_async("test", "make_chain", |mut caller: Caller<'_, ()>, ()| {
216
1
            Box::new(async move {
217
1
                let items: Vec<Rooted<AnyRef>> = (0..3)
218
3
                    .map(|i| AnyRef::from_i31(caller.as_context_mut(), I31::wrapping_u32(i)))
219
1
                    .collect();
220
1
                alloc_pair_chain(&mut caller, items).await
221
1
            })
222
1
        })
223
1
        .unwrap();
224
1
    let mut store: Store<()> = Store::new(&engine, ());
225
1
    store.set_epoch_deadline(1_000);
226
1
    let instance = linker.instantiate_async(&mut store, &module).await.unwrap();
227
1
    let go = instance.get_func(&mut store, "go").unwrap();
228
1
    let mut results = [Val::I32(0)];
229
1
    go.call_async(&mut store, &[], &mut results).await.unwrap();
230
1
    assert_eq!(results[0].i32(), Some(3));
231
1
}
232

            
233
#[tokio::test(flavor = "current_thread")]
234
1
async fn alloc_pair_chain_errors_without_pair_new_export() {
235
    use wasmtime::Func;
236

            
237
    // No `pair_new` export — the host fn must surface the missing-export
238
    // contract violation rather than panic or silently succeed.
239
1
    let wat = r#"
240
1
        (module
241
1
          (import "test" "try_chain"
242
1
            (func $try))
243
1
          (func (export "go") (call $try)))
244
1
        "#;
245
1
    let engine = build_engine(EngineOpts::baseline()).unwrap();
246
1
    let module = compile_wat(&engine, wat).unwrap();
247
1
    let mut linker: Linker<()> = Linker::new(&engine);
248
1
    linker
249
1
        .func_wrap_async("test", "try_chain", |mut caller: Caller<'_, ()>, ()| {
250
1
            Box::new(async move {
251
1
                let empty: Vec<Rooted<AnyRef>> = Vec::new();
252
1
                let result = alloc_pair_chain(&mut caller, empty).await;
253
1
                match result {
254
1
                    Err(e) => {
255
1
                        let msg = e.to_string();
256
1
                        assert!(
257
1
                            msg.contains("pair_new"),
258
                            "expected pair_new-missing error, got: {msg}"
259
                        );
260
1
                        Ok(())
261
                    }
262
                    Ok(_) => Err(wasmtime::Error::msg(
263
                        "alloc_pair_chain unexpectedly succeeded without pair_new",
264
                    )),
265
                }
266
1
            })
267
1
        })
268
1
        .unwrap();
269
1
    let mut store: Store<()> = Store::new(&engine, ());
270
1
    store.set_epoch_deadline(1_000);
271
1
    let instance = linker.instantiate_async(&mut store, &module).await.unwrap();
272
1
    let go: Func = instance.get_func(&mut store, "go").unwrap();
273
1
    let mut results: [Val; 0] = [];
274
1
    go.call_async(&mut store, &[], &mut results).await.unwrap();
275
1
}
276

            
277
#[tokio::test(flavor = "current_thread")]
278
1
async fn alloc_commodity_ref_builds_atomic_via_reentry() {
279
    // ADR-0028 E0: the host builds a commodity by re-entering the module's
280
    // exported `commodity_new`, which writes a NULL unit-term (= atomic).
281
    // The `$commodity` shape matches `CompileContext::new_skeleton` (5
282
    // fields, the 5th a `(ref null $unit_term)`). `go` asks the host for a
283
    // 7/2 commodity with UUID hi=1/lo=2, then reads numer, hi, lo, and
284
    // whether the term is null.
285
1
    let wat = r#"
286
1
        (module
287
1
          (type $unit_term (array (mut i64)))
288
1
          (type $commodity
289
1
            (struct (field i64) (field i64) (field i64) (field i64)
290
1
                    (field (ref null $unit_term))))
291
1
          (import "test" "make_commodity"
292
1
            (func $make_commodity (result (ref null struct))))
293
1
          (func $commodity_new (export "commodity_new")
294
1
            (param $n i64) (param $d i64) (param $hi i64) (param $lo i64)
295
1
            (result (ref $commodity))
296
1
            (struct.new $commodity
297
1
              (local.get $n) (local.get $d) (local.get $hi) (local.get $lo)
298
1
              (ref.null $unit_term)))
299
1
          (func (export "go") (result i64 i64 i64 i32)
300
1
            (local $c (ref $commodity))
301
1
            (local.set $c
302
1
              (ref.cast (ref $commodity) (call $make_commodity)))
303
1
            (struct.get $commodity 0 (local.get $c))
304
1
            (struct.get $commodity 2 (local.get $c))
305
1
            (struct.get $commodity 3 (local.get $c))
306
1
            (ref.is_null (struct.get $commodity 4 (local.get $c)))))
307
1
        "#;
308

            
309
1
    let engine = build_engine(EngineOpts::baseline()).unwrap();
310
1
    let module = compile_wat(&engine, wat).unwrap();
311
1
    let mut linker: Linker<()> = Linker::new(&engine);
312
1
    linker
313
1
        .func_wrap_async(
314
1
            "test",
315
1
            "make_commodity",
316
1
            |mut caller: Caller<'_, ()>, ()| {
317
1
                Box::new(async move {
318
1
                    let id = Uuid::from_u128((1u128 << 64) | 2u128);
319
1
                    Ok(Some(alloc_commodity_ref(&mut caller, 7, 2, id).await?))
320
1
                })
321
1
            },
322
        )
323
1
        .unwrap();
324
1
    let mut store: Store<()> = Store::new(&engine, ());
325
1
    store.set_epoch_deadline(1_000);
326
1
    let instance = linker.instantiate_async(&mut store, &module).await.unwrap();
327
1
    let go = instance.get_func(&mut store, "go").unwrap();
328
1
    let mut results = [Val::I64(0), Val::I64(0), Val::I64(0), Val::I32(0)];
329
1
    go.call_async(&mut store, &[], &mut results).await.unwrap();
330
1
    assert_eq!(results[0].i64(), Some(7), "numer");
331
1
    assert_eq!(results[1].i64(), Some(1), "commodity_hi");
332
1
    assert_eq!(results[2].i64(), Some(2), "commodity_lo");
333
1
    assert_eq!(results[3].i32(), Some(1), "atomic ⇒ null unit-term");
334
1
}
335

            
336
#[tokio::test(flavor = "current_thread")]
337
1
async fn alloc_commodity_ref_errors_without_commodity_new_export() {
338
    use wasmtime::Func;
339

            
340
    // No `commodity_new` export — the host fn must surface the missing-
341
    // export contract violation rather than panic or silently succeed.
342
1
    let wat = r#"
343
1
        (module
344
1
          (import "test" "try_make"
345
1
            (func $try))
346
1
          (func (export "go") (call $try)))
347
1
        "#;
348
1
    let engine = build_engine(EngineOpts::baseline()).unwrap();
349
1
    let module = compile_wat(&engine, wat).unwrap();
350
1
    let mut linker: Linker<()> = Linker::new(&engine);
351
1
    linker
352
1
        .func_wrap_async("test", "try_make", |mut caller: Caller<'_, ()>, ()| {
353
1
            Box::new(async move {
354
1
                let id = Uuid::from_u128(0);
355
1
                match alloc_commodity_ref(&mut caller, 1, 1, id).await {
356
1
                    Err(e) => {
357
1
                        let msg = e.to_string();
358
1
                        assert!(
359
1
                            msg.contains("commodity_new"),
360
                            "expected commodity_new-missing error, got: {msg}"
361
                        );
362
1
                        Ok(())
363
                    }
364
                    Ok(_) => Err(wasmtime::Error::msg(
365
                        "alloc_commodity_ref unexpectedly succeeded without commodity_new",
366
                    )),
367
                }
368
1
            })
369
1
        })
370
1
        .unwrap();
371
1
    let mut store: Store<()> = Store::new(&engine, ());
372
1
    store.set_epoch_deadline(1_000);
373
1
    let instance = linker.instantiate_async(&mut store, &module).await.unwrap();
374
1
    let go: Func = instance.get_func(&mut store, "go").unwrap();
375
1
    let mut results: [Val; 0] = [];
376
1
    go.call_async(&mut store, &[], &mut results).await.unwrap();
377
1
}
378

            
379
#[test]
380
1
fn unit_term_algebra_merges_sorts_and_cancels() {
381
    use nomiscript::{Compiler, Reader, SymbolTable};
382

            
383
15
    fn ar(a: Rooted<AnyRef>) -> Val {
384
15
        Val::AnyRef(Some(a))
385
15
    }
386

            
387
    // Compile a trivial program just to obtain the skeleton, which exports
388
    // the unit-term helpers. Then exercise the sorted-merge directly: it is
389
    // the riskiest hand-written wasm in ADR-0028 E1/E2.
390
1
    let engine = build_engine(EngineOpts::baseline().with_fuel()).unwrap();
391
1
    let mut compiler = Compiler::new();
392
1
    let mut symbols = SymbolTable::with_builtins();
393
1
    let program = Reader::parse("0").unwrap();
394
1
    let (bytes, _) = compiler
395
1
        .compile_eval_with_type(&program, &mut symbols)
396
1
        .expect("eval compile");
397
1
    let module = compile_module(&engine, &bytes).expect("module");
398
1
    let mut linker: Linker<()> = Linker::new(&engine);
399
1
    link_nomi_raise_stub(&mut linker, &engine);
400
1
    let mut store: Store<()> = Store::new(&engine, ());
401
1
    store.set_fuel(100_000_000).unwrap();
402
1
    store.set_epoch_deadline(1);
403
1
    let instance = linker.instantiate(&mut store, &module).unwrap();
404

            
405
7
    let call_ref = |store: &mut Store<()>, name: &str, args: &[Val]| -> Rooted<AnyRef> {
406
7
        let f = instance.get_func(&mut *store, name).unwrap();
407
7
        let mut res = [Val::AnyRef(None)];
408
7
        f.call(&mut *store, args, &mut res).unwrap();
409
7
        match &res[0] {
410
7
            Val::AnyRef(Some(a)) => *a,
411
            other => panic!("{name} returned {other:?}"),
412
        }
413
7
    };
414
7
    let read_term = |store: &mut Store<()>, t: Rooted<AnyRef>| -> Vec<i64> {
415
7
        let arr = t.unwrap_array(&mut *store).unwrap();
416
7
        let len = arr.len(&mut *store).unwrap();
417
7
        (0..len)
418
24
            .map(|i| arr.get(&mut *store, i).unwrap().i64().unwrap())
419
7
            .collect()
420
7
    };
421
2
    let singleton = |store: &mut Store<()>, hi: i64, lo: i64| -> Rooted<AnyRef> {
422
2
        call_ref(store, "unit_singleton", &[Val::I64(hi), Val::I64(lo)])
423
2
    };
424

            
425
1
    let usd = singleton(&mut store, 10, 20);
426
1
    let eur = singleton(&mut store, 30, 40);
427
1
    assert_eq!(read_term(&mut store, usd), vec![10, 20, 1]);
428
1
    assert_eq!(read_term(&mut store, eur), vec![30, 40, 1]);
429

            
430
    // Disjoint merge is sorted by (hi,lo), order-independent.
431
1
    let usd_eur = call_ref(&mut store, "unit_mul", &[ar(usd), ar(eur)]);
432
1
    assert_eq!(read_term(&mut store, usd_eur), vec![10, 20, 1, 30, 40, 1]);
433
1
    let eur_usd = call_ref(&mut store, "unit_mul", &[ar(eur), ar(usd)]);
434
1
    assert_eq!(read_term(&mut store, eur_usd), vec![10, 20, 1, 30, 40, 1]);
435

            
436
    // Matching key sums exponents.
437
1
    let usd2 = call_ref(&mut store, "unit_mul", &[ar(usd), ar(usd)]);
438
1
    assert_eq!(read_term(&mut store, usd2), vec![10, 20, 2]);
439

            
440
    // Cancellation drops the zero-exponent entry → empty (dimensionless).
441
1
    let canceled = call_ref(&mut store, "unit_div", &[ar(usd), ar(usd)]);
442
1
    assert_eq!(read_term(&mut store, canceled), Vec::<i64>::new());
443

            
444
    // negate flips exponents.
445
1
    let neg_usd = call_ref(&mut store, "unit_negate", &[ar(usd)]);
446
1
    assert_eq!(read_term(&mut store, neg_usd), vec![10, 20, -1]);
447

            
448
3
    let eq = |store: &mut Store<()>, a: Rooted<AnyRef>, b: Rooted<AnyRef>| -> i32 {
449
3
        let f = instance.get_func(&mut *store, "unit_eq").unwrap();
450
3
        let mut res = [Val::I32(0)];
451
3
        f.call(&mut *store, &[ar(a), ar(b)], &mut res).unwrap();
452
3
        res[0].i32().unwrap()
453
3
    };
454
1
    assert_eq!(eq(&mut store, usd, usd), 1);
455
1
    assert_eq!(eq(&mut store, usd, eur), 0);
456
    // Same multiset, built two ways, compares equal.
457
1
    assert_eq!(eq(&mut store, usd_eur, eur_usd), 1);
458
1
}
459

            
460
#[tokio::test(flavor = "current_thread")]
461
1
async fn compound_money_arithmetic_end_to_end() {
462
    use nomiscript::{Compiler, HostFnSpec, Reader, SymbolTable, WasmType};
463

            
464
    // Two distinct currencies, each produced atomic at 3/1 by a host fn.
465
    const USD: u128 = 0x1111_1111_1111_1111_2222_2222_2222_2222;
466
    const EUR: u128 = 0x3333_3333_3333_3333_4444_4444_4444_4444;
467

            
468
10
    async fn run(src: &str) -> Result<EvalValue, String> {
469
10
        let specs = vec![
470
10
            HostFnSpec::new("usd", "test", "usd").returns(WasmType::Commodity),
471
10
            HostFnSpec::new("eur", "test", "eur").returns(WasmType::Commodity),
472
10
            HostFnSpec::new("sink", "test", "sink")
473
10
                .with_params(vec![WasmType::Commodity])
474
10
                .returns(WasmType::I32),
475
        ];
476
10
        let program = Reader::parse(src).unwrap();
477
10
        let mut compiler = Compiler::with_host_fns(specs.clone());
478
10
        let mut symbols = SymbolTable::with_builtins();
479
10
        symbols.register_host_fns(&specs);
480
10
        let (bytes, result_ty) = compiler
481
10
            .compile_eval_with_type(&program, &mut symbols)
482
10
            .map_err(|e| e.to_string())?;
483
10
        let engine = build_engine(EngineOpts::baseline().with_fuel()).unwrap();
484
10
        let module = compile_module(&engine, &bytes).map_err(|e| format!("{e:?}"))?;
485
10
        let mut linker: Linker<()> = Linker::new(&engine);
486
10
        link_nomi_raise_stub(&mut linker, &engine);
487
10
        linker
488
19
            .func_wrap_async("test", "usd", |mut caller: Caller<'_, ()>, ()| {
489
19
                Box::new(async move {
490
                    Ok(Some(
491
19
                        alloc_commodity_ref(&mut caller, 3, 1, Uuid::from_u128(USD)).await?,
492
                    ))
493
19
                })
494
19
            })
495
10
            .unwrap();
496
10
        linker
497
10
            .func_wrap_async("test", "eur", |mut caller: Caller<'_, ()>, ()| {
498
1
                Box::new(async move {
499
                    Ok(Some(
500
1
                        alloc_commodity_ref(&mut caller, 3, 1, Uuid::from_u128(EUR)).await?,
501
                    ))
502
1
                })
503
1
            })
504
10
            .unwrap();
505
        // The sink only reaches its body for ATOMIC args — a compound arg is
506
        // rejected by the `commodity_assert_atomic` guard before this runs.
507
10
        linker
508
10
            .func_wrap_async(
509
10
                "test",
510
10
                "sink",
511
2
                |mut caller: Caller<'_, ()>, (arg,): (Option<Rooted<StructRef>>,)| {
512
2
                    Box::new(async move {
513
2
                        read_commodity_arg(&mut caller, arg)?;
514
2
                        Ok(0i32)
515
2
                    })
516
2
                },
517
            )
518
10
            .unwrap();
519
10
        let mut store: Store<()> = Store::new(&engine, ());
520
10
        store.set_fuel(1_000_000_000).unwrap();
521
10
        store.set_epoch_deadline(1);
522
10
        let instance = linker
523
10
            .instantiate_async(&mut store, &module)
524
10
            .await
525
10
            .map_err(|e| format!("{e:?}"))?;
526
10
        let func = instance.get_func(&mut store, "nomi-eval").unwrap();
527
10
        let mut results = [Val::AnyRef(None)];
528
10
        func.call_async(&mut store, &[], &mut results)
529
10
            .await
530
10
            .map_err(|e| e.to_string())?;
531
8
        let any = match &results[0] {
532
8
            Val::AnyRef(a) => *a,
533
            _ => return Err("nomi-eval returned non-anyref".to_string()),
534
        };
535
8
        decode_eval_result(&mut store, any, result_ty).map_err(|e| e.to_string())
536
10
    }
537

            
538
    // money ÷ money, same currency → dimensionless → decodes as a Number.
539
1
    assert_eq!(
540
1
        run("(/ (usd) (usd))").await,
541
        Ok(EvalValue::Ratio { numer: 1, denom: 1 })
542
    );
543
    // money + money, same currency → atomic money (the null-term fast path).
544
1
    match run("(+ (usd) (usd))").await {
545
1
        Ok(EvalValue::Commodity { numer, denom, .. }) => assert_eq!((numer, denom), (6, 1)),
546
        other => panic!("expected atomic commodity 6/1, got {other:?}"),
547
    }
548
    // money + money, different currency → COMMODITY-MISMATCH throw.
549
1
    assert!(run("(+ (usd) (eur))").await.is_err());
550
    // money × money → compound, no host wire form yet → decode error.
551
1
    assert!(
552
1
        run("(* (usd) (usd))")
553
1
            .await
554
1
            .unwrap_err()
555
1
            .contains("compound")
556
    );
557
    // an ATOMIC money passes the host-border guard.
558
1
    assert_eq!(run("(sink (usd))").await, Ok(EvalValue::I32(0)));
559
    // a COMPOUND money is rejected at the host border by the guard.
560
1
    assert!(run("(sink (* (usd) (usd)))").await.is_err());
561
    // a COMPOUND money riding a $pair cell is rejected by the pair-car
562
    // renderer too — it must NOT slip through as id-zero atomic money
563
    // (the pair-decode border is field-4-aware, same as the top-level one).
564
1
    assert!(
565
1
        run("(list (* (usd) (usd)))")
566
1
            .await
567
1
            .unwrap_err()
568
1
            .contains("compound")
569
    );
570
    // an atomic money in a list cell still renders (sanity: the field-4
571
    // gate doesn't reject the null-term atomic case).
572
1
    assert!(run("(list (usd))").await.is_ok());
573
    // dimensionless × atomic → a `[(usd,1)]` singleton term that
574
    // `commodity_new_with_term` canonicalizes back to ATOMIC usd: it decodes
575
    // as an atomic commodity (3/1) and passes the host-border atomic guard.
576
1
    match run("(* (/ (usd) (usd)) (usd))").await {
577
1
        Ok(EvalValue::Commodity { numer, denom, .. }) => assert_eq!((numer, denom), (3, 1)),
578
1
        other => panic!("expected atomic commodity 3/1, got {other:?}"),
579
1
    }
580
1
    assert_eq!(
581
1
        run("(sink (* (/ (usd) (usd)) (usd)))").await,
582
1
        Ok(EvalValue::I32(0))
583
1
    );
584
1
}
585

            
586
/// The eval-mode `CompileContext` declares `nomi.__nomi_raise` for
587
/// `(error 'code "msg")` lowering even when no `(error)` form is
588
/// present in the program. The host side lives in the rpc crate;
589
/// for the scripting-crate runtime tests we link a never-called
590
/// stub so `instantiate()` resolves the import.
591
29
fn link_nomi_raise_stub(linker: &mut Linker<()>, engine: &wasmtime::Engine) {
592
29
    linker
593
29
        .func_new(
594
29
            "nomi",
595
29
            "__nomi_raise",
596
29
            wasmtime::FuncType::new(
597
29
                engine,
598
29
                [
599
29
                    wasmtime::ValType::Ref(wasmtime::RefType::ARRAYREF),
600
29
                    wasmtime::ValType::Ref(wasmtime::RefType::ARRAYREF),
601
29
                ],
602
29
                [],
603
            ),
604
2
            |_, _, _| {
605
2
                Err(wasmtime::Error::msg(
606
2
                    "__nomi_raise stub: not linked in this test",
607
2
                ))
608
2
            },
609
        )
610
29
        .unwrap();
611
29
    link_log_stub(linker, engine);
612
29
    link_nomi_catch_each_stub(linker, engine);
613
29
}
614

            
615
/// `env.log` `(i32 level, i32 ptr, i32 len) -> ()` stub. Eval-mode modules
616
/// import `env.log` (PRINT / DISPLAY / NEWLINE / DEBUG lower to it); a test
617
/// linker that instantiates an eval module must define it or instantiation
618
/// fails with "unknown import". Production wires it via `scripting::host`
619
/// (script mode) / `rpc::natives::env_io` (eval mode); this no-op stub
620
/// suffices for tests that don't assert on logged output.
621
29
fn link_log_stub(linker: &mut Linker<()>, engine: &wasmtime::Engine) {
622
29
    linker
623
29
        .func_new(
624
29
            "env",
625
29
            "log",
626
29
            wasmtime::FuncType::new(
627
29
                engine,
628
29
                [
629
29
                    wasmtime::ValType::I32,
630
29
                    wasmtime::ValType::I32,
631
29
                    wasmtime::ValType::I32,
632
29
                ],
633
29
                [],
634
            ),
635
            |_, _, _| Ok(()),
636
        )
637
29
        .unwrap();
638
29
}
639

            
640
/// Companion to `link_nomi_raise_stub`. The eval compile context now
641
/// declares `__nomi_catch_each` up-front (so its import index is
642
/// stable before any user host fn is wired — matches `__nomi_raise`'s
643
/// shape), so even programs that don't use `(catch-each ...)` still
644
/// need the import resolvable at instantiation time. The stub traps
645
/// on call so a regression that accidentally invokes catch-each in
646
/// these unit tests surfaces loudly rather than silently no-oping.
647
29
fn link_nomi_catch_each_stub(linker: &mut Linker<()>, engine: &wasmtime::Engine) {
648
29
    let abstract_struct =
649
29
        wasmtime::ValType::Ref(wasmtime::RefType::new(true, wasmtime::HeapType::Struct));
650
29
    let funcref = wasmtime::ValType::Ref(wasmtime::RefType::FUNCREF);
651
29
    let anyref = wasmtime::ValType::Ref(wasmtime::RefType::ANYREF);
652
29
    linker
653
29
        .func_new(
654
29
            "nomi",
655
29
            "__nomi_catch_each",
656
29
            wasmtime::FuncType::new(
657
29
                engine,
658
29
                [funcref, anyref, abstract_struct.clone()],
659
29
                [abstract_struct],
660
            ),
661
            |_, _, _| {
662
                Err(wasmtime::Error::msg(
663
                    "__nomi_catch_each stub: not linked in this test",
664
                ))
665
            },
666
        )
667
29
        .unwrap();
668
29
}
669

            
670
/// End-to-end: nomiscript Compiler emits eval-mode bytecode that
671
/// returns the form's final value via nomi-eval's `(ref null any)`
672
/// return slot, the runtime instantiates it (no capture host fns
673
/// linked — they retired in A6.c), and `decode_eval_result` walks
674
/// the anyref into the structured `EvalValue` the rest of the host
675
/// renders from.
676
7
fn run_nomiscript_eval(program: &nomiscript::Program) -> Option<EvalValue> {
677
    use nomiscript::{Compiler, SymbolTable};
678
7
    let engine = build_engine(EngineOpts::baseline().with_fuel()).unwrap();
679
7
    let mut compiler = Compiler::new();
680
7
    let mut symbols = SymbolTable::with_builtins();
681
7
    let (bytes, result_ty) = compiler
682
7
        .compile_eval_with_type(program, &mut symbols)
683
7
        .expect("eval compile");
684
7
    let module = compile_module(&engine, &bytes).expect("module");
685
7
    let mut linker: Linker<()> = Linker::new(&engine);
686
7
    link_nomi_raise_stub(&mut linker, &engine);
687
7
    let mut store: Store<()> = Store::new(&engine, ());
688
7
    store.set_fuel(10_000_000).unwrap();
689
7
    store.set_epoch_deadline(1);
690
7
    let instance = linker.instantiate(&mut store, &module).unwrap();
691
7
    let func = instance.get_func(&mut store, "nomi-eval").unwrap();
692
7
    let mut results = [Val::AnyRef(None)];
693
7
    func.call(&mut store, &[], &mut results).unwrap();
694
7
    let any = match &results[0] {
695
7
        Val::AnyRef(a) => *a,
696
        _ => panic!("nomi-eval returned non-anyref"),
697
    };
698
7
    Some(decode_eval_result(&mut store, any, result_ty).expect("decode"))
699
7
}
700

            
701
#[test]
702
1
fn nomiscript_eval_captures_integer_literal() {
703
    use nomiscript::{Expr, Fraction, Program};
704
    // ADR-0028: an integer literal is an Index (I32), decoding as `I32`,
705
    // not the dimensionless `Ratio` it conflated with before the flip.
706
1
    let program = Program::new(vec![Expr::Number(Fraction::from_integer(7))]);
707
1
    assert_eq!(run_nomiscript_eval(&program), Some(EvalValue::I32(7)));
708
1
}
709

            
710
#[test]
711
1
fn nomiscript_eval_captures_arithmetic_result() {
712
    use nomiscript::{Expr, Fraction, Program};
713
    // All-integer (Index) arithmetic stays in the Index stratum: `(+ 1 2)`
714
    // decodes as `I32(3)`.
715
1
    let program = Program::new(vec![Expr::List(vec![
716
1
        Expr::Symbol("+".into()),
717
1
        Expr::Number(Fraction::from_integer(1)),
718
1
        Expr::Number(Fraction::from_integer(2)),
719
1
    ])]);
720
1
    assert_eq!(run_nomiscript_eval(&program), Some(EvalValue::I32(3)));
721
1
}
722

            
723
#[test]
724
1
fn nomiscript_eval_captures_fractional_result() {
725
    use nomiscript::{Expr, Fraction, Program};
726
    // A Scalar operand keeps rational division: `(/ 1/2 2) → 1/4`, decoding
727
    // as `Ratio`. (All-integer `(/ 1 4)` would be Index `0`.)
728
1
    let program = Program::new(vec![Expr::List(vec![
729
1
        Expr::Symbol("/".into()),
730
1
        Expr::Number(Fraction::new(1, 2)),
731
1
        Expr::Number(Fraction::from_integer(2)),
732
1
    ])]);
733
1
    assert_eq!(
734
1
        run_nomiscript_eval(&program),
735
        Some(EvalValue::Ratio { numer: 1, denom: 4 })
736
    );
737
1
}
738

            
739
#[test]
740
1
fn nomiscript_eval_captures_nil_for_empty_program() {
741
1
    let program = nomiscript::Program::default();
742
1
    assert_eq!(run_nomiscript_eval(&program), Some(EvalValue::Nil));
743
1
}
744

            
745
#[test]
746
1
fn nomiscript_eval_decodes_bool_as_bool() {
747
    use nomiscript::{Expr, Program};
748
1
    let program = Program::new(vec![Expr::T]);
749
    // `t` carries `WasmType::Bool` (i31-boxed); the decoder surfaces a
750
    // truthy bool as `Bool(true)` (a falsy one would be `Nil`), not the
751
    // raw integer the old i32-conflated path produced.
752
1
    assert_eq!(run_nomiscript_eval(&program), Some(EvalValue::T));
753
1
}
754

            
755
/// Drift-detector: compile-eval each script source, run it, and
756
/// verify the static `result_ty` hint reported by
757
/// `compile_eval_with_type` matches the decoded `EvalValue`'s
758
/// variant. Catches eval-vs-codegen drift across the compiler —
759
/// the exact bug class that produced the tag-sync test regressions.
760
/// Add a row whenever a new WasmType or Expr-shape is supported.
761
#[test]
762
1
fn nomiscript_eval_type_hint_matches_value_variant() {
763
    use nomiscript::{Compiler, Program, Reader, SymbolTable, WasmType};
764
1
    let cases: &[(&str, Option<WasmType>)] = &[
765
1
        // ADR-0028: integer literals + all-integer arithmetic are Index
766
1
        // (I32); a fractional literal is Scalar (Ratio).
767
1
        ("42", Some(WasmType::I32)),
768
1
        ("(+ 1 2)", Some(WasmType::I32)),
769
1
        ("(/ 1 4)", Some(WasmType::I32)),
770
1
        ("(/ 1/2 2)", Some(WasmType::Ratio)),
771
1
        ("(= 1 1)", Some(WasmType::Bool)),
772
1
        ("(< 1 2)", Some(WasmType::Bool)),
773
1
        ("t", Some(WasmType::Bool)),
774
1
        ("\"hello\"", Some(WasmType::StringRef)),
775
1
        ("(let ((x 1)) (+ x 1))", Some(WasmType::I32)),
776
1
        ("(let ((x 1)) \"tail\")", Some(WasmType::StringRef)),
777
1
        ("(if (= 1 1) 2 3)", Some(WasmType::I32)),
778
1
    ];
779
11
    for (src, expected_ty) in cases {
780
11
        let program: Program = Reader::parse(src).expect("parse");
781
11
        let engine = build_engine(EngineOpts::baseline().with_fuel()).unwrap();
782
11
        let mut compiler = Compiler::new();
783
11
        let mut symbols = SymbolTable::with_builtins();
784
11
        let (bytes, result_ty) = compiler
785
11
            .compile_eval_with_type(&program, &mut symbols)
786
11
            .unwrap_or_else(|e| panic!("compile {src:?}: {e}"));
787
11
        assert_eq!(
788
11
            &result_ty, expected_ty,
789
            "compile_eval_with_type reported wrong static type for {src:?}",
790
        );
791
11
        let module = compile_module(&engine, &bytes).expect("module");
792
11
        let mut linker: Linker<()> = Linker::new(&engine);
793
11
        link_nomi_raise_stub(&mut linker, &engine);
794
11
        let mut store: Store<()> = Store::new(&engine, ());
795
11
        store.set_fuel(10_000_000).unwrap();
796
11
        store.set_epoch_deadline(1);
797
11
        let instance = linker.instantiate(&mut store, &module).unwrap();
798
11
        let func = instance.get_func(&mut store, "nomi-eval").unwrap();
799
11
        let mut results = [Val::AnyRef(None)];
800
11
        func.call(&mut store, &[], &mut results)
801
11
            .unwrap_or_else(|e| panic!("run {src:?}: {e}"));
802
11
        let any = match &results[0] {
803
11
            Val::AnyRef(a) => *a,
804
            _ => panic!("nomi-eval returned non-anyref for {src:?}"),
805
        };
806
11
        let decoded = decode_eval_result(&mut store, any, result_ty)
807
11
            .unwrap_or_else(|e| panic!("decode {src:?}: {e}"));
808
        // The mapping below is the canonical EvalValue ↔ WasmType
809
        // contract. Any drift fails here.
810
11
        let ok = matches!(
811
11
            (&result_ty, &decoded),
812
            (None, EvalValue::Nil)
813
                    | (Some(WasmType::I32), EvalValue::I32(_))
814
                    // A Bool decodes to Bool(true) when truthy or Nil when falsy.
815
                    | (Some(WasmType::Bool), EvalValue::T | EvalValue::Nil)
816
                    | (Some(WasmType::Ratio), EvalValue::Ratio { .. })
817
                    | (Some(WasmType::Commodity), EvalValue::Commodity { .. })
818
                    | (
819
                        Some(WasmType::StringRef),
820
                        EvalValue::String(_) | EvalValue::Bytes(_)
821
                    ),
822
        );
823
11
        assert!(
824
11
            ok,
825
            "type/value drift for {src:?}: hint={result_ty:?}, decoded={decoded:?}",
826
        );
827
    }
828
1
}
829

            
830
#[tokio::test(flavor = "current_thread")]
831
1
async fn render_entity_emits_named_field_plist() {
832
    // Build a $commodity-shaped struct (3 string fields, matching the
833
    // Commodity layout's id/symbol/name slots) via WAT, then decode it with
834
    // `render_entity` — the same call the host eval path makes for a returned
835
    // entity. Proves the spec-driven decoder reads each slot by name.
836
1
    let wat = r#"
837
1
        (module
838
1
          (type $i8 (array (mut i8)))
839
1
          (type $commodity (struct
840
1
            (field (ref null $i8))
841
1
            (field (ref null $i8))
842
1
            (field (ref null $i8))))
843
1
          (data $id "uuid-123")
844
1
          (data $sym "USD")
845
1
          (data $name "US Dollar")
846
1
          (func (export "go") (result (ref null struct))
847
1
            (struct.new $commodity
848
1
              (array.new_data $i8 $id  (i32.const 0) (i32.const 8))
849
1
              (array.new_data $i8 $sym (i32.const 0) (i32.const 3))
850
1
              (array.new_data $i8 $name (i32.const 0) (i32.const 9)))))
851
1
        "#;
852
1
    let engine = build_engine(EngineOpts::baseline()).unwrap();
853
1
    let module = compile_wat(&engine, wat).unwrap();
854
1
    let linker: Linker<()> = Linker::new(&engine);
855
1
    let mut store: Store<()> = Store::new(&engine, ());
856
1
    store.set_epoch_deadline(1_000);
857
1
    let instance = linker.instantiate_async(&mut store, &module).await.unwrap();
858
1
    let go = instance.get_func(&mut store, "go").unwrap();
859
1
    let mut results = [Val::AnyRef(None)];
860
1
    go.call_async(&mut store, &[], &mut results).await.unwrap();
861
1
    let entity = match results[0] {
862
1
        Val::AnyRef(Some(a)) => a.unwrap_struct(&mut store).unwrap(),
863
        other => panic!("go did not return a struct: {other:?}"),
864
    };
865
1
    let rendered = render_entity(&mut store, entity, nomiscript::EntityKind::Commodity).unwrap();
866
1
    assert_eq!(
867
1
        rendered,
868
1
        "(:commodity :id \"uuid-123\" :symbol \"USD\" :name \"US Dollar\")"
869
1
    );
870
1
}
871

            
872
#[tokio::test(flavor = "current_thread")]
873
1
async fn read_entity_string_field_reads_named_slot() {
874
    // Same Commodity-shaped struct; prove the field reader resolves a named
875
    // slot from the layout (id = slot 0, name = slot 2) — the call
876
    // `draft-split` makes to turn an entity ref into a stable uuid string.
877
1
    let wat = r#"
878
1
        (module
879
1
          (type $i8 (array (mut i8)))
880
1
          (type $commodity (struct
881
1
            (field (ref null $i8))
882
1
            (field (ref null $i8))
883
1
            (field (ref null $i8))))
884
1
          (data $id "uuid-123")
885
1
          (data $sym "USD")
886
1
          (data $name "US Dollar")
887
1
          (func (export "go") (result (ref null struct))
888
1
            (struct.new $commodity
889
1
              (array.new_data $i8 $id  (i32.const 0) (i32.const 8))
890
1
              (array.new_data $i8 $sym (i32.const 0) (i32.const 3))
891
1
              (array.new_data $i8 $name (i32.const 0) (i32.const 9)))))
892
1
        "#;
893
1
    let engine = build_engine(EngineOpts::baseline()).unwrap();
894
1
    let module = compile_wat(&engine, wat).unwrap();
895
1
    let linker: Linker<()> = Linker::new(&engine);
896
1
    let mut store: Store<()> = Store::new(&engine, ());
897
1
    store.set_epoch_deadline(1_000);
898
1
    let instance = linker.instantiate_async(&mut store, &module).await.unwrap();
899
1
    let go = instance.get_func(&mut store, "go").unwrap();
900
1
    let mut results = [Val::AnyRef(None)];
901
1
    go.call_async(&mut store, &[], &mut results).await.unwrap();
902
1
    let entity = match results[0] {
903
1
        Val::AnyRef(Some(a)) => a.unwrap_struct(&mut store).unwrap(),
904
        other => panic!("go did not return a struct: {other:?}"),
905
    };
906

            
907
1
    let id =
908
1
        read_entity_string_field_ctx(&mut store, entity, nomiscript::EntityKind::Commodity, "id")
909
1
            .unwrap();
910
1
    assert_eq!(id, "uuid-123");
911

            
912
1
    let name = read_entity_string_field_ctx(
913
1
        &mut store,
914
1
        entity,
915
1
        nomiscript::EntityKind::Commodity,
916
1
        "name",
917
    )
918
1
    .unwrap();
919
1
    assert_eq!(name, "US Dollar");
920

            
921
    // A field that isn't a String slot in the layout is rejected.
922
1
    let bad = read_entity_string_field_ctx(
923
1
        &mut store,
924
1
        entity,
925
1
        nomiscript::EntityKind::Commodity,
926
1
        "nonexistent",
927
    );
928
1
    assert!(bad.is_err());
929
1
}