Lines
99.3 %
Functions
100 %
Branches
use nms::interpreter::Interpreter;
use scripting::nomiscript::{Fraction, Value};
use scripting::parser::EntityData;
use scripting::{MemorySerializer, ScriptExecutor};
use scripting_format::{ContextType, EntityType, Operation};
use wasmtime::{Config, Engine, Module};
fn num(n: i64) -> Value {
Value::Number(Fraction::new(n, 1))
}
fn gc_engine() -> Engine {
let mut config = Config::new();
config.wasm_gc(true);
Engine::new(&config).unwrap()
fn wasm_export_names(wasm: &[u8], engine: &Engine) -> Vec<String> {
let module = Module::new(engine, wasm).unwrap();
module.exports().map(|e| e.name().to_string()).collect()
fn build_minimal_input(output_size: u32) -> Vec<u8> {
let mut ser = MemorySerializer::new();
ser.set_context(ContextType::BatchProcess, EntityType::Transaction);
ser.finalize(output_size)
#[test]
fn test_compiled_wasm_has_standard_exports() {
let mut interp = Interpreter::new(false).unwrap();
let wasm = interp.compile_to_wasm("42").unwrap();
let engine = gc_engine();
let exports = wasm_export_names(&wasm, &engine);
assert!(exports.contains(&"should_apply".to_string()));
assert!(exports.contains(&"process".to_string()));
assert!(exports.contains(&"memory".to_string()));
fn test_groceries_wasm_has_standard_exports() {
let wasm = include_bytes!("../../../web/static/wasm/groceries_markup.wasm");
let exports = wasm_export_names(wasm, &Engine::default());
fn test_compile_and_load_roundtrip() {
let wasm = interp.compile_to_wasm("(+ 1 2 3)").unwrap();
let result = interp.run_wasm(&wasm).unwrap();
assert_eq!(result, Value::Number(Fraction::from_integer(6)));
fn test_compile_and_load_roundtrip_string() {
let wasm = interp.compile_to_wasm(r#""hello""#).unwrap();
assert_eq!(result, Value::String("hello".to_string()));
fn test_compile_and_load_roundtrip_defun() {
let wasm = interp
.compile_to_wasm("(defun add (a b) (+ a b)) (add 10 20)")
.unwrap();
assert_eq!(result, Value::Number(Fraction::from_integer(30)));
fn test_nms_wasm_through_executor() {
let wasm = interp.compile_to_wasm("(+ 10 20)").unwrap();
let input = build_minimal_input(4096);
let executor = ScriptExecutor::with_engine(gc_engine());
let entities = executor.execute(&wasm, &input, Some(4096)).unwrap();
assert_eq!(
entities.len(),
1,
"NMS WASM should produce one DebugValue entity"
);
assert_eq!(entities[0].entity_type, EntityType::DebugValue);
fn test_groceries_wasm_through_executor_empty_input() {
let executor = ScriptExecutor::new();
let entities = executor
.execute(wasm.as_slice(), &input, Some(4096))
assert!(
entities.is_empty(),
"groceries WASM with empty input should produce no entities"
fn test_groceries_wasm_tags_splits() {
ser.set_context(ContextType::EntityCreate, EntityType::Transaction);
let tx_id = [1u8; 16];
let account1 = [2u8; 16];
let account2 = [3u8; 16];
let commodity = [4u8; 16];
let split1_id = [5u8; 16];
let split2_id = [6u8; 16];
let tag_id = [7u8; 16];
let tx_idx = ser.add_transaction(tx_id, -1, true, false, 0, 0, 2, 1, false);
ser.set_primary(tx_idx);
let split1_idx = ser.add_split(
split1_id,
tx_idx as i32,
false,
account1,
commodity,
-5000,
100,
0,
let split2_idx = ser.add_split(
split2_id,
account2,
5000,
ser.add_tag(tag_id, tx_idx as i32, false, false, "note", "groceries");
let input = ser.finalize(4096);
2,
"expected 2 category tags (one per split)"
for entity in &entities {
assert_eq!(entity.entity_type, EntityType::Tag);
if let EntityData::Tag { name, value } = &entity.data {
assert_eq!(name, "category");
assert_eq!(value, "groceries");
} else {
panic!("expected Tag data, got {:?}", entity.data);
let parents: Vec<i32> = entities.iter().map(|e| e.parent_idx).collect();
assert!(parents.contains(&(split1_idx as i32)));
assert!(parents.contains(&(split2_idx as i32)));
fn test_entity_count_through_executor() {
let wasm = interp.compile_to_wasm("(entity-count)").unwrap();
let tx_idx = ser.add_transaction(tx_id, -1, true, false, 0, 0, 0, 0, false);
ser.add_split(
[2u8; 16],
[3u8; 16],
[4u8; 16],
assert_eq!(entities.len(), 1, "should produce one DebugValue entity");
fn test_entity_count_conditional_through_executor() {
let script = r#"
(if (= (entity-count) 4)
"four-entities"
"not-four")
"#;
let wasm = interp.compile_to_wasm(script).unwrap();
[5u8; 16],
[6u8; 16],
ser.add_tag([7u8; 16], tx_idx as i32, false, false, "note", "test");
assert_eq!(entities.len(), 1);
fn test_runtime_do_loop_through_executor() {
let script = r"
(let* ((n (entity-count))
(sum 0))
(do ((i 0 (+ i 1)))
((>= i n) sum)
(setf sum (+ sum 1))))
";
-100,
fn test_primary_entity_idx_through_executor() {
let wasm = interp.compile_to_wasm("(primary-entity-idx)").unwrap();
fn build_tx_with_splits() -> (MemorySerializer, u32, u32, u32) {
let tx_idx = ser.add_transaction([1u8; 16], -1, true, false, 1000, 2000, 2, 1, false);
let s0 = ser.add_split(
[10u8; 16],
[20u8; 16],
let s1 = ser.add_split(
[11u8; 16],
ser.add_tag([4u8; 16], tx_idx as i32, false, false, "note", "test");
(ser, tx_idx, s0, s1)
fn test_transaction_split_count_accessor() {
.compile_to_wasm(r"(= (transaction-split-count 0) 2)")
let (ser, ..) = build_tx_with_splits();
let result = interp.run_wasm_with_input(&wasm, &input).unwrap();
assert_eq!(result, num(1));
fn test_transaction_tag_count_accessor() {
.compile_to_wasm(r"(= (transaction-tag-count 0) 1)")
fn test_transaction_is_multi_currency_accessor() {
.compile_to_wasm(r"(= (transaction-is-multi-currency 0) 0)")
fn test_transaction_post_date_accessor() {
.compile_to_wasm(r"(= (transaction-post-date 0) 1000)")
fn test_split_value_accessor() {
// Split at index 1 has value_num=-5000, value_denom=100 → ratio -5000/100 = -50/1
let wasm = interp.compile_to_wasm(r"(= (split-value 1) -50)").unwrap();
fn test_split_value_num_accessor() {
.compile_to_wasm(r"(= (split-value-num 1) -5000)")
fn test_split_value_denom_accessor() {
.compile_to_wasm(r"(= (split-value-denom 1) 100)")
fn test_entity_type_with_constants() {
.compile_to_wasm(
r"(and (= (entity-type 0) +entity-transaction+)
(= (entity-type 1) +entity-split+))",
)
fn test_entity_parent_idx_accessor() {
// Split at index 1 should have parent_idx = 0 (the transaction)
.compile_to_wasm(r"(= (entity-parent-idx 1) 0)")
fn test_transaction_enter_date_accessor() {
.compile_to_wasm(r"(= (transaction-enter-date 0) 2000)")
fn test_split_reconcile_state_accessor() {
.compile_to_wasm(r"(= (split-reconcile-state 1) 0)")
fn test_split_reconcile_date_accessor() {
.compile_to_wasm(r"(= (split-reconcile-date 1) 0)")
fn test_runtime_cons_car() {
// Build a runtime cons list from entity indices and get the first element
r"(let ((idx (primary-entity-idx)))
(= (car (cons idx nil)) 0))",
fn test_runtime_cons_null_cdr() {
// CDR of a single-element cons list should be null
(null? (cdr (cons idx nil))))",
fn test_runtime_do_loop_cons_list() {
// Build a list of split entity indices by filtering entity-type = split(1)
// Entities: 0=tx, 1=split, 2=split, 3=tag → splits at indices 1, 2
r"(do ((i 0 (+ i 1))
(result nil (if (= (entity-type i) +entity-split+)
(cons i result)
result)))
((>= i (entity-count)) (car result)))",
// Last split added = index 2, so car(result) = 2
assert_eq!(result, Value::Number(Fraction::new(2, 1)));
fn test_create_tag_output() {
.compile_to_wasm(r#"(create-tag 0 "note" "test-value")"#)
let (ser, tx_idx, ..) = build_tx_with_splits();
assert_eq!(entities.len(), 1, "expected one tag entity");
assert_eq!(entities[0].entity_type, EntityType::Tag);
assert_eq!(entities[0].parent_idx, tx_idx as i32);
if let EntityData::Tag { name, value } = &entities[0].data {
assert_eq!(name, "note");
assert_eq!(value, "test-value");
panic!("expected Tag data, got {:?}", entities[0].data);
fn test_create_tag_with_runtime_parent() {
.compile_to_wasm(r#"(create-tag (primary-entity-idx) "category" "food")"#)
assert_eq!(value, "food");
fn test_create_tag_as_side_effect() {
// create-tag used as intermediate expression, not last
r#"(let ((idx (primary-entity-idx)))
(create-tag idx "side" "effect")
42)"#,
// Should have both the tag and the debug value (42)
assert_eq!(entities.len(), 2, "expected tag + debug value");
let tag = entities.iter().find(|e| e.entity_type == EntityType::Tag);
assert!(tag.is_some(), "expected a tag entity in output");
let tag = tag.unwrap();
assert_eq!(tag.parent_idx, tx_idx as i32);
if let EntityData::Tag { name, value } = &tag.data {
assert_eq!(name, "side");
assert_eq!(value, "effect");
panic!("expected Tag data");
fn test_runtime_dolist_over_cons_list() {
// Build a cons list of split indices via DO, then iterate with DOLIST counting elements
r"(let* ((splits (do ((i 0 (+ i 1))
((>= i (entity-count)) result)))
(count 0))
(dolist (s splits)
(setf count (+ count 1)))
count)",
// 2 splits in the input (indices 1, 2)
assert_eq!(result, num(2));
fn test_delete_entity_output() {
let wasm = interp.compile_to_wasm(r"(delete-entity 0)").unwrap();
let (ser, _tx_idx, ..) = build_tx_with_splits();
assert_eq!(entities.len(), 1, "expected one delete entity");
assert_eq!(entities[0].entity_type, EntityType::Transaction);
assert_eq!(entities[0].operation, Operation::Delete);
assert_eq!(entities[0].id, [1u8; 16]);
assert_eq!(entities[0].parent_idx, -1);
fn test_delete_entity_with_runtime_idx() {
.compile_to_wasm(r"(delete-entity (primary-entity-idx))")
fn test_delete_entity_split() {
let wasm = interp.compile_to_wasm(r"(delete-entity 1)").unwrap();
assert_eq!(entities[0].entity_type, EntityType::Split);
assert_eq!(entities[0].id, [2u8; 16]);
fn test_should_apply_default() {
let wasm = interp.compile_to_wasm(r"42").unwrap();
let module = Module::new(&gc_engine(), &wasm).unwrap();
let exports: Vec<_> = module.exports().map(|e| e.name().to_string()).collect();
fn test_should_apply_custom() {
r#"(defun should-apply ()
(= (primary-entity-type) +entity-transaction+))
(create-tag 0 "test" "value")"#,
!entities.is_empty(),
"should_apply should return true for transactions"
fn test_should_apply_rejects() {
r"(defun should-apply ()
(= (primary-entity-type) +entity-account+))
42",
"should_apply should return false for non-accounts"
fn test_get_input_entities() {
// Count entities by iterating the cons list from get-input-entities
r"(let ((count 0))
(dolist (e (get-input-entities))
// 4 entities: 1 transaction + 2 splits + 1 tag
assert_eq!(result, num(4));
// Large constant-init DO loop (> MAX_STATIC_LOOP_ITERS=64): must fall back to runtime WASM
// path rather than attempting compile-time unrolling that would hang the compiler.
fn test_do_large_constant_loop_compiles_and_runs() {
.compile_to_wasm("(do ((i 0 (+ i 1)) (sum 0 (+ sum i))) ((= i 100) sum))")
assert_eq!(result, num(4950));
fn test_do_star_large_constant_loop_compiles_and_runs() {
// DO* steps sequentially: acc's step sees the already-incremented i, so sum = 1+2+...+100
.compile_to_wasm("(do* ((i 0 (+ i 1)) (acc 0 (+ acc i))) ((= i 100) acc))")
assert_eq!(result, num(5050));