1
// Skipped under Miri: these tests compile+run wasm via wasmtime, whose
2
// Cranelift backend refuses to run under Miri.
3
#![cfg(not(miri))]
4

            
5
use nms::interpreter::Interpreter;
6
use scripting::nomiscript::{Fraction, Value};
7
use scripting::parser::EntityData;
8
use scripting::runtime::{EngineOpts, build_engine};
9
use scripting::{EntityHeaderArgs, MemorySerializer, ScriptExecutor, SplitArgs, TransactionArgs};
10
use scripting_format::{ContextType, EntityType, Operation};
11
use wasmtime::{Engine, Module};
12

            
13
4
fn num(n: i64) -> Value {
14
4
    Value::Number(Fraction::new(n, 1))
15
4
}
16

            
17
/// A true comparison / predicate result. Runtime booleans (`=`, `null?`, …)
18
/// carry `WasmType::Bool` and serialize as `Bool(true)`, not `Number(1)`.
19
14
fn truthy() -> Value {
20
14
    Value::Bool(true)
21
14
}
22

            
23
16
fn gc_engine() -> Engine {
24
16
    build_engine(EngineOpts::baseline()).unwrap()
25
16
}
26

            
27
2
fn wasm_export_names(wasm: &[u8], engine: &Engine) -> Vec<String> {
28
2
    let module = Module::new(engine, wasm).unwrap();
29
15
    module.exports().map(|e| e.name().to_string()).collect()
30
2
}
31

            
32
4
fn build_minimal_input(output_size: u32) -> Vec<u8> {
33
4
    let mut ser = MemorySerializer::new();
34
4
    ser.set_context(ContextType::BatchProcess, EntityType::Transaction);
35
4
    ser.finalize(output_size)
36
4
}
37

            
38
#[test]
39
1
fn test_compiled_wasm_has_standard_exports() {
40
1
    let mut interp = Interpreter::new(false).unwrap();
41
1
    let wasm = interp.compile_to_wasm("42").unwrap();
42
1
    let engine = gc_engine();
43
1
    let exports = wasm_export_names(&wasm, &engine);
44
1
    assert!(exports.contains(&"should_apply".to_string()));
45
1
    assert!(exports.contains(&"process".to_string()));
46
1
    assert!(exports.contains(&"memory".to_string()));
47
1
}
48

            
49
#[test]
50
1
fn test_groceries_wasm_has_standard_exports() {
51
1
    let wasm = include_bytes!("../../../web/static/wasm/groceries_markup.wasm");
52
1
    let exports = wasm_export_names(wasm, &Engine::default());
53
1
    assert!(exports.contains(&"should_apply".to_string()));
54
1
    assert!(exports.contains(&"process".to_string()));
55
1
    assert!(exports.contains(&"memory".to_string()));
56
1
}
57

            
58
#[test]
59
1
fn test_compile_and_load_roundtrip() {
60
1
    let mut interp = Interpreter::new(false).unwrap();
61
1
    let wasm = interp.compile_to_wasm("(+ 1 2 3)").unwrap();
62
1
    let result = interp.run_wasm(&wasm).unwrap();
63
1
    assert_eq!(result, Value::Number(Fraction::from_integer(6)));
64
1
}
65

            
66
#[test]
67
1
fn test_compile_and_load_roundtrip_string() {
68
1
    let mut interp = Interpreter::new(false).unwrap();
69
1
    let wasm = interp.compile_to_wasm(r#""hello""#).unwrap();
70
1
    let result = interp.run_wasm(&wasm).unwrap();
71
1
    assert_eq!(result, Value::String("hello".to_string()));
72
1
}
73

            
74
#[test]
75
1
fn test_top_level_error_throws_through_boundary_in_script_mode() {
76
    // End-to-end script-mode path NEW in Tier 3.2: a top-level `(error)`
77
    // lowers to `throw $nomi_error`; the boundary `try_table` the compiler
78
    // wraps `process` in catches the uncaught throw and bridges it to the
79
    // `__nomi_raise` host fn (registered in `define_host_functions`). Before
80
    // 3.2 `(error)` never worked on the ScriptExecutor/nms linker world — it
81
    // would fail to link. So compile-then-run reaching an *execution* Err
82
    // (not a compile Err, not a swallowed value) is what proves the throw →
83
    // boundary → host-bridge path is wired end-to-end. The code/message
84
    // *content* of the marker is classified one layer up (rpc / the
85
    // `err_code_and_message` unit tests); nms flattens the wasmtime error
86
    // chain via `to_string()`, so the marker cause is not observable here.
87
1
    let mut interp = Interpreter::new(false).unwrap();
88
1
    let wasm = interp
89
1
        .compile_to_wasm(r#"(error 'boom "the message")"#)
90
1
        .expect("(error) must compile + link in script mode post-3.2");
91
1
    let outcome = interp.run_wasm(&wasm);
92
1
    assert!(
93
1
        outcome.is_err(),
94
        "an uncaught top-level (error) must surface as an Err, not a returned value"
95
    );
96
1
}
97

            
98
#[test]
99
1
fn test_compile_and_load_roundtrip_defun() {
100
1
    let mut interp = Interpreter::new(false).unwrap();
101
1
    let wasm = interp
102
1
        .compile_to_wasm("(defun add (a b) (+ a b)) (add 10 20)")
103
1
        .unwrap();
104
1
    let result = interp.run_wasm(&wasm).unwrap();
105
1
    assert_eq!(result, Value::Number(Fraction::from_integer(30)));
106
1
}
107

            
108
#[test]
109
1
fn test_nms_wasm_through_executor() {
110
1
    let mut interp = Interpreter::new(false).unwrap();
111
1
    let wasm = interp.compile_to_wasm("(+ 10 20)").unwrap();
112

            
113
1
    let input = build_minimal_input(4096);
114
1
    let executor = ScriptExecutor::with_engine(gc_engine());
115
1
    let entities = executor.execute(&wasm, &input, Some(4096)).unwrap();
116
1
    assert_eq!(
117
1
        entities.len(),
118
        1,
119
        "NMS WASM should produce one DebugValue entity"
120
    );
121
1
    assert_eq!(entities[0].entity_type, EntityType::DebugValue);
122
1
}
123

            
124
#[test]
125
1
fn test_groceries_wasm_through_executor_empty_input() {
126
1
    let wasm = include_bytes!("../../../web/static/wasm/groceries_markup.wasm");
127
1
    let input = build_minimal_input(4096);
128
1
    let executor = ScriptExecutor::new();
129
1
    let entities = executor
130
1
        .execute(wasm.as_slice(), &input, Some(4096))
131
1
        .unwrap();
132
1
    assert!(
133
1
        entities.is_empty(),
134
        "groceries WASM with empty input should produce no entities"
135
    );
136
1
}
137

            
138
#[test]
139
1
fn test_groceries_wasm_tags_splits() {
140
1
    let wasm = include_bytes!("../../../web/static/wasm/groceries_markup.wasm");
141

            
142
1
    let mut ser = MemorySerializer::new();
143
1
    ser.set_context(ContextType::EntityCreate, EntityType::Transaction);
144

            
145
1
    let tx_id = [1u8; 16];
146
1
    let account1 = [2u8; 16];
147
1
    let account2 = [3u8; 16];
148
1
    let commodity = [4u8; 16];
149
1
    let split1_id = [5u8; 16];
150
1
    let split2_id = [6u8; 16];
151
1
    let tag_id = [7u8; 16];
152

            
153
1
    let tx_idx = ser.add_transaction(
154
1
        EntityHeaderArgs {
155
1
            id: tx_id,
156
1
            parent_idx: -1,
157
1
            is_primary: true,
158
1
            is_context: false,
159
1
        },
160
1
        TransactionArgs {
161
1
            post_date: 0,
162
1
            enter_date: 0,
163
1
            split_count: 2,
164
1
            tag_count: 1,
165
1
            is_multi_currency: false,
166
1
        },
167
    );
168
1
    ser.set_primary(tx_idx);
169
1
    let split1_idx = ser.add_split(
170
1
        EntityHeaderArgs {
171
1
            id: split1_id,
172
1
            parent_idx: tx_idx as i32,
173
1
            is_primary: false,
174
1
            is_context: false,
175
1
        },
176
1
        SplitArgs {
177
1
            account_id: account1,
178
1
            commodity_id: commodity,
179
1
            value_num: -5000,
180
1
            value_denom: 100,
181
1
            reconcile_state: 0,
182
1
            reconcile_date: 0,
183
1
            account_name: "Assets:Test",
184
1
        },
185
    );
186
1
    let split2_idx = ser.add_split(
187
1
        EntityHeaderArgs {
188
1
            id: split2_id,
189
1
            parent_idx: tx_idx as i32,
190
1
            is_primary: false,
191
1
            is_context: false,
192
1
        },
193
1
        SplitArgs {
194
1
            account_id: account2,
195
1
            commodity_id: commodity,
196
1
            value_num: 5000,
197
1
            value_denom: 100,
198
1
            reconcile_state: 0,
199
1
            reconcile_date: 0,
200
1
            account_name: "Assets:Test",
201
1
        },
202
    );
203
1
    ser.add_tag(tag_id, tx_idx as i32, false, false, "note", "groceries");
204

            
205
1
    let input = ser.finalize(4096);
206
1
    let executor = ScriptExecutor::new();
207
1
    let entities = executor
208
1
        .execute(wasm.as_slice(), &input, Some(4096))
209
1
        .unwrap();
210

            
211
1
    assert_eq!(
212
1
        entities.len(),
213
        2,
214
        "expected 2 category tags (one per split)"
215
    );
216
2
    for entity in &entities {
217
2
        assert_eq!(entity.entity_type, EntityType::Tag);
218
2
        if let EntityData::Tag { name, value } = &entity.data {
219
2
            assert_eq!(name, "category");
220
2
            assert_eq!(value, "groceries");
221
        } else {
222
            panic!("expected Tag data, got {:?}", entity.data);
223
        }
224
    }
225

            
226
1
    let parents: Vec<i32> = entities.iter().map(|e| e.parent_idx).collect();
227
1
    assert!(parents.contains(&(split1_idx as i32)));
228
1
    assert!(parents.contains(&(split2_idx as i32)));
229
1
}
230

            
231
#[test]
232
1
fn test_entity_count_through_executor() {
233
1
    let mut interp = Interpreter::new(false).unwrap();
234
1
    let wasm = interp.compile_to_wasm("(entity-count)").unwrap();
235

            
236
1
    let mut ser = MemorySerializer::new();
237
1
    ser.set_context(ContextType::EntityCreate, EntityType::Transaction);
238
1
    let tx_id = [1u8; 16];
239
1
    let tx_idx = ser.add_transaction(
240
1
        EntityHeaderArgs {
241
1
            id: tx_id,
242
1
            parent_idx: -1,
243
1
            is_primary: true,
244
1
            is_context: false,
245
1
        },
246
1
        TransactionArgs {
247
1
            post_date: 0,
248
1
            enter_date: 0,
249
1
            split_count: 0,
250
1
            tag_count: 0,
251
1
            is_multi_currency: false,
252
1
        },
253
    );
254
1
    ser.set_primary(tx_idx);
255
1
    ser.add_split(
256
1
        EntityHeaderArgs {
257
1
            id: [2u8; 16],
258
1
            parent_idx: tx_idx as i32,
259
1
            is_primary: false,
260
1
            is_context: false,
261
1
        },
262
1
        SplitArgs {
263
1
            account_id: [3u8; 16],
264
1
            commodity_id: [4u8; 16],
265
1
            value_num: 100,
266
1
            value_denom: 1,
267
1
            reconcile_state: 0,
268
1
            reconcile_date: 0,
269
1
            account_name: "Assets:Test",
270
1
        },
271
    );
272

            
273
1
    let input = ser.finalize(4096);
274
1
    let executor = ScriptExecutor::with_engine(gc_engine());
275
1
    let entities = executor.execute(&wasm, &input, Some(4096)).unwrap();
276

            
277
1
    assert_eq!(entities.len(), 1, "should produce one DebugValue entity");
278
1
    assert_eq!(entities[0].entity_type, EntityType::DebugValue);
279
1
}
280

            
281
#[test]
282
1
fn test_entity_count_conditional_through_executor() {
283
1
    let mut interp = Interpreter::new(false).unwrap();
284
1
    let script = r#"
285
1
        (if (= (entity-count) 4)
286
1
            "four-entities"
287
1
            "not-four")
288
1
    "#;
289
1
    let wasm = interp.compile_to_wasm(script).unwrap();
290

            
291
1
    let mut ser = MemorySerializer::new();
292
1
    ser.set_context(ContextType::EntityCreate, EntityType::Transaction);
293
1
    let tx_id = [1u8; 16];
294
1
    let tx_idx = ser.add_transaction(
295
1
        EntityHeaderArgs {
296
1
            id: tx_id,
297
1
            parent_idx: -1,
298
1
            is_primary: true,
299
1
            is_context: false,
300
1
        },
301
1
        TransactionArgs {
302
1
            post_date: 0,
303
1
            enter_date: 0,
304
1
            split_count: 2,
305
1
            tag_count: 1,
306
1
            is_multi_currency: false,
307
1
        },
308
    );
309
1
    ser.set_primary(tx_idx);
310
1
    ser.add_split(
311
1
        EntityHeaderArgs {
312
1
            id: [5u8; 16],
313
1
            parent_idx: tx_idx as i32,
314
1
            is_primary: false,
315
1
            is_context: false,
316
1
        },
317
1
        SplitArgs {
318
1
            account_id: [2u8; 16],
319
1
            commodity_id: [4u8; 16],
320
1
            value_num: -5000,
321
1
            value_denom: 100,
322
1
            reconcile_state: 0,
323
1
            reconcile_date: 0,
324
1
            account_name: "Assets:Test",
325
1
        },
326
    );
327
1
    ser.add_split(
328
1
        EntityHeaderArgs {
329
1
            id: [6u8; 16],
330
1
            parent_idx: tx_idx as i32,
331
1
            is_primary: false,
332
1
            is_context: false,
333
1
        },
334
1
        SplitArgs {
335
1
            account_id: [3u8; 16],
336
1
            commodity_id: [4u8; 16],
337
1
            value_num: 5000,
338
1
            value_denom: 100,
339
1
            reconcile_state: 0,
340
1
            reconcile_date: 0,
341
1
            account_name: "Assets:Test",
342
1
        },
343
    );
344
1
    ser.add_tag([7u8; 16], tx_idx as i32, false, false, "note", "test");
345

            
346
1
    let input = ser.finalize(4096);
347
1
    let executor = ScriptExecutor::with_engine(gc_engine());
348
1
    let entities = executor.execute(&wasm, &input, Some(4096)).unwrap();
349

            
350
1
    assert_eq!(entities.len(), 1);
351
1
    assert_eq!(entities[0].entity_type, EntityType::DebugValue);
352
1
}
353

            
354
#[test]
355
1
fn test_runtime_do_loop_through_executor() {
356
1
    let mut interp = Interpreter::new(false).unwrap();
357
1
    let script = r"
358
1
        (let* ((n (entity-count))
359
1
               (sum 0))
360
1
            (do ((i 0 (+ i 1)))
361
1
                ((>= i n) sum)
362
1
                (setf sum (+ sum 1))))
363
1
    ";
364
1
    let wasm = interp.compile_to_wasm(script).unwrap();
365

            
366
1
    let mut ser = MemorySerializer::new();
367
1
    ser.set_context(ContextType::BatchProcess, EntityType::Transaction);
368
1
    let tx_id = [1u8; 16];
369
1
    let tx_idx = ser.add_transaction(
370
1
        EntityHeaderArgs {
371
1
            id: tx_id,
372
1
            parent_idx: -1,
373
1
            is_primary: true,
374
1
            is_context: false,
375
1
        },
376
1
        TransactionArgs {
377
1
            post_date: 0,
378
1
            enter_date: 0,
379
1
            split_count: 0,
380
1
            tag_count: 0,
381
1
            is_multi_currency: false,
382
1
        },
383
    );
384
1
    ser.set_primary(tx_idx);
385
1
    ser.add_split(
386
1
        EntityHeaderArgs {
387
1
            id: [2u8; 16],
388
1
            parent_idx: tx_idx as i32,
389
1
            is_primary: false,
390
1
            is_context: false,
391
1
        },
392
1
        SplitArgs {
393
1
            account_id: [3u8; 16],
394
1
            commodity_id: [4u8; 16],
395
1
            value_num: 100,
396
1
            value_denom: 1,
397
1
            reconcile_state: 0,
398
1
            reconcile_date: 0,
399
1
            account_name: "Assets:Test",
400
1
        },
401
    );
402
1
    ser.add_split(
403
1
        EntityHeaderArgs {
404
1
            id: [5u8; 16],
405
1
            parent_idx: tx_idx as i32,
406
1
            is_primary: false,
407
1
            is_context: false,
408
1
        },
409
1
        SplitArgs {
410
1
            account_id: [6u8; 16],
411
1
            commodity_id: [4u8; 16],
412
1
            value_num: -100,
413
1
            value_denom: 1,
414
1
            reconcile_state: 0,
415
1
            reconcile_date: 0,
416
1
            account_name: "Assets:Test",
417
1
        },
418
    );
419

            
420
1
    let input = ser.finalize(4096);
421
1
    let executor = ScriptExecutor::with_engine(gc_engine());
422
1
    let entities = executor.execute(&wasm, &input, Some(4096)).unwrap();
423

            
424
1
    assert_eq!(entities.len(), 1);
425
1
    assert_eq!(entities[0].entity_type, EntityType::DebugValue);
426
1
}
427

            
428
#[test]
429
1
fn test_primary_entity_idx_through_executor() {
430
1
    let mut interp = Interpreter::new(false).unwrap();
431
1
    let wasm = interp.compile_to_wasm("(primary-entity-idx)").unwrap();
432

            
433
1
    let mut ser = MemorySerializer::new();
434
1
    ser.set_context(ContextType::EntityCreate, EntityType::Transaction);
435
1
    let tx_id = [1u8; 16];
436
1
    let tx_idx = ser.add_transaction(
437
1
        EntityHeaderArgs {
438
1
            id: tx_id,
439
1
            parent_idx: -1,
440
1
            is_primary: true,
441
1
            is_context: false,
442
1
        },
443
1
        TransactionArgs {
444
1
            post_date: 0,
445
1
            enter_date: 0,
446
1
            split_count: 0,
447
1
            tag_count: 0,
448
1
            is_multi_currency: false,
449
1
        },
450
    );
451
1
    ser.set_primary(tx_idx);
452

            
453
1
    let input = ser.finalize(4096);
454
1
    let executor = ScriptExecutor::with_engine(gc_engine());
455
1
    let entities = executor.execute(&wasm, &input, Some(4096)).unwrap();
456

            
457
1
    assert_eq!(entities.len(), 1);
458
1
    assert_eq!(entities[0].entity_type, EntityType::DebugValue);
459
1
}
460

            
461
26
fn build_tx_with_splits() -> (MemorySerializer, u32, u32, u32) {
462
26
    let mut ser = MemorySerializer::new();
463
26
    ser.set_context(ContextType::EntityCreate, EntityType::Transaction);
464
26
    let tx_idx = ser.add_transaction(
465
26
        EntityHeaderArgs {
466
26
            id: [1u8; 16],
467
26
            parent_idx: -1,
468
26
            is_primary: true,
469
26
            is_context: false,
470
26
        },
471
26
        TransactionArgs {
472
26
            post_date: 1000,
473
26
            enter_date: 2000,
474
26
            split_count: 2,
475
26
            tag_count: 1,
476
26
            is_multi_currency: false,
477
26
        },
478
    );
479
26
    ser.set_primary(tx_idx);
480
26
    let s0 = ser.add_split(
481
26
        EntityHeaderArgs {
482
26
            id: [2u8; 16],
483
26
            parent_idx: tx_idx as i32,
484
26
            is_primary: false,
485
26
            is_context: false,
486
26
        },
487
26
        SplitArgs {
488
26
            account_id: [10u8; 16],
489
26
            commodity_id: [20u8; 16],
490
26
            value_num: -5000,
491
26
            value_denom: 100,
492
26
            reconcile_state: 0,
493
26
            reconcile_date: 0,
494
26
            account_name: "Assets:Test",
495
26
        },
496
    );
497
26
    let s1 = ser.add_split(
498
26
        EntityHeaderArgs {
499
26
            id: [3u8; 16],
500
26
            parent_idx: tx_idx as i32,
501
26
            is_primary: false,
502
26
            is_context: false,
503
26
        },
504
26
        SplitArgs {
505
26
            account_id: [11u8; 16],
506
26
            commodity_id: [20u8; 16],
507
26
            value_num: 5000,
508
26
            value_denom: 100,
509
26
            reconcile_state: 0,
510
26
            reconcile_date: 0,
511
26
            account_name: "Assets:Test",
512
26
        },
513
    );
514
26
    ser.add_tag([4u8; 16], tx_idx as i32, false, false, "note", "test");
515
26
    (ser, tx_idx, s0, s1)
516
26
}
517

            
518
#[test]
519
1
fn test_transaction_split_count_accessor() {
520
1
    let mut interp = Interpreter::new(false).unwrap();
521
1
    let wasm = interp
522
1
        .compile_to_wasm(r"(= (transaction-split-count 0) 2)")
523
1
        .unwrap();
524

            
525
1
    let (ser, ..) = build_tx_with_splits();
526
1
    let input = ser.finalize(4096);
527
1
    let result = interp.run_wasm_with_input(&wasm, &input).unwrap();
528
1
    assert_eq!(result, truthy());
529
1
}
530

            
531
#[test]
532
1
fn test_transaction_tag_count_accessor() {
533
1
    let mut interp = Interpreter::new(false).unwrap();
534
1
    let wasm = interp
535
1
        .compile_to_wasm(r"(= (transaction-tag-count 0) 1)")
536
1
        .unwrap();
537

            
538
1
    let (ser, ..) = build_tx_with_splits();
539
1
    let input = ser.finalize(4096);
540
1
    let result = interp.run_wasm_with_input(&wasm, &input).unwrap();
541
1
    assert_eq!(result, truthy());
542
1
}
543

            
544
#[test]
545
1
fn test_transaction_is_multi_currency_accessor() {
546
1
    let mut interp = Interpreter::new(false).unwrap();
547
1
    let wasm = interp
548
1
        .compile_to_wasm(r"(= (transaction-is-multi-currency 0) 0)")
549
1
        .unwrap();
550

            
551
1
    let (ser, ..) = build_tx_with_splits();
552
1
    let input = ser.finalize(4096);
553
1
    let result = interp.run_wasm_with_input(&wasm, &input).unwrap();
554
1
    assert_eq!(result, truthy());
555
1
}
556

            
557
#[test]
558
1
fn test_transaction_post_date_accessor() {
559
1
    let mut interp = Interpreter::new(false).unwrap();
560
1
    let wasm = interp
561
1
        .compile_to_wasm(r"(= (transaction-post-date 0) 1000)")
562
1
        .unwrap();
563

            
564
1
    let (ser, ..) = build_tx_with_splits();
565
1
    let input = ser.finalize(4096);
566
1
    let result = interp.run_wasm_with_input(&wasm, &input).unwrap();
567
1
    assert_eq!(result, truthy());
568
1
}
569

            
570
#[test]
571
1
fn test_split_value_accessor() {
572
1
    let mut interp = Interpreter::new(false).unwrap();
573
    // Split at index 1 has value_num=-5000, value_denom=100 → ratio -5000/100 = -50/1
574
1
    let wasm = interp.compile_to_wasm(r"(= (split-value 1) -50)").unwrap();
575

            
576
1
    let (ser, ..) = build_tx_with_splits();
577
1
    let input = ser.finalize(4096);
578
1
    let result = interp.run_wasm_with_input(&wasm, &input).unwrap();
579
1
    assert_eq!(result, truthy());
580
1
}
581

            
582
#[test]
583
1
fn test_split_value_num_accessor() {
584
1
    let mut interp = Interpreter::new(false).unwrap();
585
1
    let wasm = interp
586
1
        .compile_to_wasm(r"(= (split-value-num 1) -5000)")
587
1
        .unwrap();
588

            
589
1
    let (ser, ..) = build_tx_with_splits();
590
1
    let input = ser.finalize(4096);
591
1
    let result = interp.run_wasm_with_input(&wasm, &input).unwrap();
592
1
    assert_eq!(result, truthy());
593
1
}
594

            
595
#[test]
596
1
fn test_split_value_denom_accessor() {
597
1
    let mut interp = Interpreter::new(false).unwrap();
598
1
    let wasm = interp
599
1
        .compile_to_wasm(r"(= (split-value-denom 1) 100)")
600
1
        .unwrap();
601

            
602
1
    let (ser, ..) = build_tx_with_splits();
603
1
    let input = ser.finalize(4096);
604
1
    let result = interp.run_wasm_with_input(&wasm, &input).unwrap();
605
1
    assert_eq!(result, truthy());
606
1
}
607

            
608
#[test]
609
1
fn test_entity_type_with_constants() {
610
1
    let mut interp = Interpreter::new(false).unwrap();
611
1
    let wasm = interp
612
1
        .compile_to_wasm(
613
1
            r"(and (= (entity-type 0) +entity-transaction+)
614
1
                    (= (entity-type 1) +entity-split+))",
615
        )
616
1
        .unwrap();
617

            
618
1
    let (ser, ..) = build_tx_with_splits();
619
1
    let input = ser.finalize(4096);
620
1
    let result = interp.run_wasm_with_input(&wasm, &input).unwrap();
621
1
    assert_eq!(result, truthy());
622
1
}
623

            
624
#[test]
625
1
fn test_entity_parent_idx_accessor() {
626
1
    let mut interp = Interpreter::new(false).unwrap();
627
    // Split at index 1 should have parent_idx = 0 (the transaction)
628
1
    let wasm = interp
629
1
        .compile_to_wasm(r"(= (entity-parent-idx 1) 0)")
630
1
        .unwrap();
631

            
632
1
    let (ser, ..) = build_tx_with_splits();
633
1
    let input = ser.finalize(4096);
634
1
    let result = interp.run_wasm_with_input(&wasm, &input).unwrap();
635
1
    assert_eq!(result, truthy());
636
1
}
637

            
638
#[test]
639
1
fn test_transaction_enter_date_accessor() {
640
1
    let mut interp = Interpreter::new(false).unwrap();
641
1
    let wasm = interp
642
1
        .compile_to_wasm(r"(= (transaction-enter-date 0) 2000)")
643
1
        .unwrap();
644

            
645
1
    let (ser, ..) = build_tx_with_splits();
646
1
    let input = ser.finalize(4096);
647
1
    let result = interp.run_wasm_with_input(&wasm, &input).unwrap();
648
1
    assert_eq!(result, truthy());
649
1
}
650

            
651
#[test]
652
1
fn test_split_reconcile_state_accessor() {
653
1
    let mut interp = Interpreter::new(false).unwrap();
654
1
    let wasm = interp
655
1
        .compile_to_wasm(r"(= (split-reconcile-state 1) 0)")
656
1
        .unwrap();
657

            
658
1
    let (ser, ..) = build_tx_with_splits();
659
1
    let input = ser.finalize(4096);
660
1
    let result = interp.run_wasm_with_input(&wasm, &input).unwrap();
661
1
    assert_eq!(result, truthy());
662
1
}
663

            
664
#[test]
665
1
fn test_split_reconcile_date_accessor() {
666
1
    let mut interp = Interpreter::new(false).unwrap();
667
1
    let wasm = interp
668
1
        .compile_to_wasm(r"(= (split-reconcile-date 1) 0)")
669
1
        .unwrap();
670

            
671
1
    let (ser, ..) = build_tx_with_splits();
672
1
    let input = ser.finalize(4096);
673
1
    let result = interp.run_wasm_with_input(&wasm, &input).unwrap();
674
1
    assert_eq!(result, truthy());
675
1
}
676

            
677
#[test]
678
1
fn test_runtime_cons_car() {
679
1
    let mut interp = Interpreter::new(false).unwrap();
680
    // Build a runtime cons list from entity indices and get the first element
681
1
    let wasm = interp
682
1
        .compile_to_wasm(
683
1
            r"(let ((idx (primary-entity-idx)))
684
1
                   (= (car (cons idx nil)) 0))",
685
        )
686
1
        .unwrap();
687

            
688
1
    let (ser, ..) = build_tx_with_splits();
689
1
    let input = ser.finalize(4096);
690
1
    let result = interp.run_wasm_with_input(&wasm, &input).unwrap();
691
1
    assert_eq!(result, truthy());
692
1
}
693

            
694
#[test]
695
1
fn test_runtime_cons_null_cdr() {
696
1
    let mut interp = Interpreter::new(false).unwrap();
697
    // CDR of a single-element cons list should be null
698
1
    let wasm = interp
699
1
        .compile_to_wasm(
700
1
            r"(let ((idx (primary-entity-idx)))
701
1
                   (null? (cdr (cons idx nil))))",
702
        )
703
1
        .unwrap();
704

            
705
1
    let (ser, ..) = build_tx_with_splits();
706
1
    let input = ser.finalize(4096);
707
1
    let result = interp.run_wasm_with_input(&wasm, &input).unwrap();
708
1
    assert_eq!(result, truthy());
709
1
}
710

            
711
#[test]
712
1
fn test_runtime_do_loop_cons_list() {
713
1
    let mut interp = Interpreter::new(false).unwrap();
714
    // Build a list of split entity indices by filtering entity-type = split(1)
715
    // Entities: 0=tx, 1=split, 2=split, 3=tag → splits at indices 1, 2
716
1
    let wasm = interp
717
1
        .compile_to_wasm(
718
1
            r"(do ((i 0 (+ i 1))
719
1
                     (result nil (if (= (entity-type i) +entity-split+)
720
1
                                     (cons i result)
721
1
                                     result)))
722
1
                    ((>= i (entity-count)) (car result)))",
723
        )
724
1
        .unwrap();
725

            
726
1
    let (ser, ..) = build_tx_with_splits();
727
1
    let input = ser.finalize(4096);
728
1
    let result = interp.run_wasm_with_input(&wasm, &input).unwrap();
729
    // Last split added = index 2, so car(result) = 2
730
1
    assert_eq!(result, Value::Number(Fraction::new(2, 1)));
731
1
}
732

            
733
#[test]
734
1
fn test_create_tag_output() {
735
1
    let mut interp = Interpreter::new(false).unwrap();
736
1
    let wasm = interp
737
1
        .compile_to_wasm(r#"(create-tag 0 "note" "test-value")"#)
738
1
        .unwrap();
739

            
740
1
    let (ser, tx_idx, ..) = build_tx_with_splits();
741
1
    let input = ser.finalize(4096);
742
1
    let executor = ScriptExecutor::with_engine(gc_engine());
743
1
    let entities = executor.execute(&wasm, &input, Some(4096)).unwrap();
744

            
745
1
    assert_eq!(entities.len(), 1, "expected one tag entity");
746
1
    assert_eq!(entities[0].entity_type, EntityType::Tag);
747
1
    assert_eq!(entities[0].parent_idx, tx_idx as i32);
748
1
    if let EntityData::Tag { name, value } = &entities[0].data {
749
1
        assert_eq!(name, "note");
750
1
        assert_eq!(value, "test-value");
751
    } else {
752
        panic!("expected Tag data, got {:?}", entities[0].data);
753
    }
754
1
}
755

            
756
#[test]
757
1
fn test_create_tag_with_runtime_parent() {
758
1
    let mut interp = Interpreter::new(false).unwrap();
759
1
    let wasm = interp
760
1
        .compile_to_wasm(r#"(create-tag (primary-entity-idx) "category" "food")"#)
761
1
        .unwrap();
762

            
763
1
    let (ser, tx_idx, ..) = build_tx_with_splits();
764
1
    let input = ser.finalize(4096);
765
1
    let executor = ScriptExecutor::with_engine(gc_engine());
766
1
    let entities = executor.execute(&wasm, &input, Some(4096)).unwrap();
767

            
768
1
    assert_eq!(entities.len(), 1, "expected one tag entity");
769
1
    assert_eq!(entities[0].entity_type, EntityType::Tag);
770
1
    assert_eq!(entities[0].parent_idx, tx_idx as i32);
771
1
    if let EntityData::Tag { name, value } = &entities[0].data {
772
1
        assert_eq!(name, "category");
773
1
        assert_eq!(value, "food");
774
    } else {
775
        panic!("expected Tag data, got {:?}", entities[0].data);
776
    }
777
1
}
778

            
779
#[test]
780
1
fn test_create_tag_as_side_effect() {
781
1
    let mut interp = Interpreter::new(false).unwrap();
782
    // create-tag used as intermediate expression, not last
783
1
    let wasm = interp
784
1
        .compile_to_wasm(
785
1
            r#"(let ((idx (primary-entity-idx)))
786
1
                   (create-tag idx "side" "effect")
787
1
                   42)"#,
788
        )
789
1
        .unwrap();
790

            
791
1
    let (ser, tx_idx, ..) = build_tx_with_splits();
792
1
    let input = ser.finalize(4096);
793
1
    let executor = ScriptExecutor::with_engine(gc_engine());
794
1
    let entities = executor.execute(&wasm, &input, Some(4096)).unwrap();
795

            
796
    // Should have both the tag and the debug value (42)
797
1
    assert_eq!(entities.len(), 2, "expected tag + debug value");
798
1
    let tag = entities.iter().find(|e| e.entity_type == EntityType::Tag);
799
1
    assert!(tag.is_some(), "expected a tag entity in output");
800
1
    let tag = tag.unwrap();
801
1
    assert_eq!(tag.parent_idx, tx_idx as i32);
802
1
    if let EntityData::Tag { name, value } = &tag.data {
803
1
        assert_eq!(name, "side");
804
1
        assert_eq!(value, "effect");
805
    } else {
806
        panic!("expected Tag data");
807
    }
808
1
}
809

            
810
#[test]
811
1
fn test_create_tag_then_string_result_both_decode() {
812
    // Mixed output stream: a create-tag (tag strings grow DOWN from the buffer
813
    // end, read by the entity parser via strings_offset) followed by a STRING
814
    // program result (decoded inline from its own entity). Both must survive in
815
    // one buffer — the regression for the single-path output unification.
816
1
    let mut interp = Interpreter::new(false).unwrap();
817
1
    let wasm = interp
818
1
        .compile_to_wasm(
819
1
            r#"(let ((idx (primary-entity-idx)))
820
1
                   (create-tag idx "k" "v")
821
1
                   "done")"#,
822
        )
823
1
        .unwrap();
824

            
825
1
    let (ser, tx_idx, ..) = build_tx_with_splits();
826
1
    let input = ser.finalize(4096);
827
1
    let entities = ScriptExecutor::with_engine(gc_engine())
828
1
        .execute(&wasm, &input, Some(4096))
829
1
        .unwrap();
830

            
831
1
    let tag = entities
832
1
        .iter()
833
1
        .find(|e| e.entity_type == EntityType::Tag)
834
1
        .expect("tag entity present");
835
1
    assert_eq!(tag.parent_idx, tx_idx as i32);
836
1
    let EntityData::Tag { name, value } = &tag.data else {
837
        panic!("expected Tag data");
838
    };
839
1
    assert_eq!(name, "k");
840
1
    assert_eq!(value, "v");
841

            
842
    // The interpreter eval path must still decode the trailing string result,
843
    // selecting the DebugValue entity past the create-tag.
844
1
    let result = interp.run_wasm_with_input(&wasm, &input).unwrap();
845
1
    assert_eq!(result, Value::String("done".to_string()));
846
1
}
847

            
848
#[test]
849
1
fn test_runtime_dolist_over_cons_list() {
850
1
    let mut interp = Interpreter::new(false).unwrap();
851
    // Build a cons list of split indices via DO, then iterate with DOLIST counting elements
852
1
    let wasm = interp
853
1
        .compile_to_wasm(
854
1
            r"(let* ((splits (do ((i 0 (+ i 1))
855
1
                                  (result nil (if (= (entity-type i) +entity-split+)
856
1
                                                  (cons i result)
857
1
                                                  result)))
858
1
                                 ((>= i (entity-count)) result)))
859
1
                    (count 0))
860
1
                   (dolist (s splits)
861
1
                       (setf count (+ count 1)))
862
1
                   count)",
863
        )
864
1
        .unwrap();
865

            
866
1
    let (ser, ..) = build_tx_with_splits();
867
1
    let input = ser.finalize(4096);
868
1
    let result = interp.run_wasm_with_input(&wasm, &input).unwrap();
869
    // 2 splits in the input (indices 1, 2)
870
1
    assert_eq!(result, num(2));
871
1
}
872

            
873
#[test]
874
1
fn test_delete_entity_output() {
875
1
    let mut interp = Interpreter::new(false).unwrap();
876
1
    let wasm = interp.compile_to_wasm(r"(delete-entity 0)").unwrap();
877

            
878
1
    let (ser, _tx_idx, ..) = build_tx_with_splits();
879
1
    let input = ser.finalize(4096);
880
1
    let executor = ScriptExecutor::with_engine(gc_engine());
881
1
    let entities = executor.execute(&wasm, &input, Some(4096)).unwrap();
882

            
883
1
    assert_eq!(entities.len(), 1, "expected one delete entity");
884
1
    assert_eq!(entities[0].entity_type, EntityType::Transaction);
885
1
    assert_eq!(entities[0].operation, Operation::Delete);
886
1
    assert_eq!(entities[0].id, [1u8; 16]);
887
1
    assert_eq!(entities[0].parent_idx, -1);
888
1
}
889

            
890
#[test]
891
1
fn test_delete_entity_with_runtime_idx() {
892
1
    let mut interp = Interpreter::new(false).unwrap();
893
1
    let wasm = interp
894
1
        .compile_to_wasm(r"(delete-entity (primary-entity-idx))")
895
1
        .unwrap();
896

            
897
1
    let (ser, ..) = build_tx_with_splits();
898
1
    let input = ser.finalize(4096);
899
1
    let executor = ScriptExecutor::with_engine(gc_engine());
900
1
    let entities = executor.execute(&wasm, &input, Some(4096)).unwrap();
901

            
902
1
    assert_eq!(entities.len(), 1);
903
1
    assert_eq!(entities[0].entity_type, EntityType::Transaction);
904
1
    assert_eq!(entities[0].operation, Operation::Delete);
905
1
    assert_eq!(entities[0].id, [1u8; 16]);
906
1
}
907

            
908
#[test]
909
1
fn test_delete_entity_split() {
910
1
    let mut interp = Interpreter::new(false).unwrap();
911
1
    let wasm = interp.compile_to_wasm(r"(delete-entity 1)").unwrap();
912

            
913
1
    let (ser, ..) = build_tx_with_splits();
914
1
    let input = ser.finalize(4096);
915
1
    let executor = ScriptExecutor::with_engine(gc_engine());
916
1
    let entities = executor.execute(&wasm, &input, Some(4096)).unwrap();
917

            
918
1
    assert_eq!(entities.len(), 1);
919
1
    assert_eq!(entities[0].entity_type, EntityType::Split);
920
1
    assert_eq!(entities[0].operation, Operation::Delete);
921
1
    assert_eq!(entities[0].id, [2u8; 16]);
922
1
}
923

            
924
#[test]
925
1
fn test_should_apply_default() {
926
1
    let mut interp = Interpreter::new(false).unwrap();
927
1
    let wasm = interp.compile_to_wasm(r"42").unwrap();
928
1
    let module = Module::new(&gc_engine(), &wasm).unwrap();
929

            
930
10
    let exports: Vec<_> = module.exports().map(|e| e.name().to_string()).collect();
931
1
    assert!(exports.contains(&"should_apply".to_string()));
932
1
    assert!(exports.contains(&"process".to_string()));
933
1
}
934

            
935
#[test]
936
1
fn test_should_apply_custom() {
937
1
    let mut interp = Interpreter::new(false).unwrap();
938
1
    let wasm = interp
939
1
        .compile_to_wasm(
940
1
            r#"(defun should-apply ()
941
1
                   (= (primary-entity-type) +entity-transaction+))
942
1
               (create-tag 0 "test" "value")"#,
943
        )
944
1
        .unwrap();
945

            
946
1
    let (ser, ..) = build_tx_with_splits();
947
1
    let input = ser.finalize(4096);
948
1
    let executor = ScriptExecutor::with_engine(gc_engine());
949
1
    let entities = executor.execute(&wasm, &input, Some(4096)).unwrap();
950
1
    assert!(
951
1
        !entities.is_empty(),
952
        "should_apply should return true for transactions"
953
    );
954
1
}
955

            
956
#[test]
957
1
fn test_should_apply_rejects() {
958
1
    let mut interp = Interpreter::new(false).unwrap();
959
1
    let wasm = interp
960
1
        .compile_to_wasm(
961
1
            r"(defun should-apply ()
962
1
                   (= (primary-entity-type) +entity-account+))
963
1
               42",
964
        )
965
1
        .unwrap();
966

            
967
1
    let (ser, ..) = build_tx_with_splits();
968
1
    let input = ser.finalize(4096);
969
1
    let executor = ScriptExecutor::with_engine(gc_engine());
970
1
    let entities = executor.execute(&wasm, &input, Some(4096)).unwrap();
971
1
    assert!(
972
1
        entities.is_empty(),
973
        "should_apply should return false for non-accounts"
974
    );
975
1
}
976

            
977
#[test]
978
1
fn test_get_input_entities() {
979
1
    let mut interp = Interpreter::new(false).unwrap();
980
    // Count entities by iterating the cons list from get-input-entities
981
1
    let wasm = interp
982
1
        .compile_to_wasm(
983
1
            r"(let ((count 0))
984
1
                   (dolist (e (get-input-entities))
985
1
                       (setf count (+ count 1)))
986
1
                   count)",
987
        )
988
1
        .unwrap();
989

            
990
1
    let (ser, ..) = build_tx_with_splits();
991
1
    let input = ser.finalize(4096);
992
1
    let result = interp.run_wasm_with_input(&wasm, &input).unwrap();
993
    // 4 entities: 1 transaction + 2 splits + 1 tag
994
1
    assert_eq!(result, num(4));
995
1
}
996

            
997
// Large constant-init DO loop (> MAX_STATIC_LOOP_ITERS=64): must fall back to runtime WASM
998
// path rather than attempting compile-time unrolling that would hang the compiler.
999

            
#[test]
1
fn test_do_large_constant_loop_compiles_and_runs() {
1
    let mut interp = Interpreter::new(false).unwrap();
1
    let wasm = interp
1
        .compile_to_wasm("(do ((i 0 (+ i 1)) (sum 0 (+ sum i))) ((= i 100) sum))")
1
        .unwrap();
1
    let input = build_minimal_input(4096);
1
    let result = interp.run_wasm_with_input(&wasm, &input).unwrap();
1
    assert_eq!(result, num(4950));
1
}
#[test]
1
fn test_do_star_large_constant_loop_compiles_and_runs() {
1
    let mut interp = Interpreter::new(false).unwrap();
    // DO* steps sequentially: acc's step sees the already-incremented i, so sum = 1+2+...+100
1
    let wasm = interp
1
        .compile_to_wasm("(do* ((i 0 (+ i 1)) (acc 0 (+ acc i))) ((= i 100) acc))")
1
        .unwrap();
1
    let input = build_minimal_input(4096);
1
    let result = interp.run_wasm_with_input(&wasm, &input).unwrap();
1
    assert_eq!(result, num(5050));
1
}