Cherry-picked and integrated the best code from 105 parallel epic branches into a clean workspace structure: - calcpad-engine/: Core Rust crate with lexer, parser, AST, interpreter, types, FFI (C ABI), pipeline, error handling, span tracking, eval context (from epic/1-5 base) - calcpad-engine/src/number.rs: Arbitrary precision arithmetic via dashu crate, WASM-compatible, exact decimals (from epic/1-4) - calcpad-engine/src/sheet_context.rs: SheetContext with dependency graph, dirty tracking, circular detection, cheap clone (from epic/1-8) - calcpad-wasm/: Thin WASM wrapper crate via wasm-bindgen, delegates to calcpad-engine (from epic/1-6) - Updated .gitignore for target/, node_modules/, build artifacts - Fixed run-pipeline.sh for macOS compat and CalcPad phases 79 tests passing across workspace.
446 lines
13 KiB
Rust
446 lines
13 KiB
Rust
use calcpad_engine::context::EvalContext;
|
|
use calcpad_engine::pipeline::{eval_line, eval_sheet};
|
|
use calcpad_engine::types::ResultType;
|
|
use calcpad_engine::ffi::{FfiResponse, FfiSheetResponse};
|
|
use std::ffi::{CStr, CString};
|
|
use std::os::raw::c_char;
|
|
use std::ptr;
|
|
|
|
// Re-declare FFI functions for testing
|
|
extern "C" {
|
|
fn calcpad_eval_line(input: *const c_char) -> *mut c_char;
|
|
fn calcpad_eval_sheet(lines: *const *const c_char, count: i32) -> *mut c_char;
|
|
fn calcpad_free_result(ptr: *mut c_char);
|
|
}
|
|
|
|
// ===== Pipeline tests =====
|
|
|
|
#[test]
|
|
fn pipeline_eval_simple_addition() {
|
|
let mut ctx = EvalContext::new();
|
|
let result = eval_line("2 + 3", &mut ctx);
|
|
assert_eq!(result.result_type(), ResultType::Number);
|
|
assert_eq!(result.metadata.display, "5");
|
|
assert_eq!(result.metadata.raw_value, Some(5.0));
|
|
}
|
|
|
|
#[test]
|
|
fn pipeline_eval_multiplication() {
|
|
let mut ctx = EvalContext::new();
|
|
let result = eval_line("6 * 7", &mut ctx);
|
|
assert_eq!(result.metadata.display, "42");
|
|
}
|
|
|
|
#[test]
|
|
fn pipeline_eval_division() {
|
|
let mut ctx = EvalContext::new();
|
|
let result = eval_line("10 / 4", &mut ctx);
|
|
assert_eq!(result.metadata.raw_value, Some(2.5));
|
|
}
|
|
|
|
#[test]
|
|
fn pipeline_eval_exponentiation() {
|
|
let mut ctx = EvalContext::new();
|
|
let result = eval_line("2 ^ 10", &mut ctx);
|
|
assert_eq!(result.metadata.display, "1024");
|
|
}
|
|
|
|
#[test]
|
|
fn pipeline_eval_parentheses() {
|
|
let mut ctx = EvalContext::new();
|
|
let result = eval_line("(2 + 3) * 4", &mut ctx);
|
|
assert_eq!(result.metadata.display, "20");
|
|
}
|
|
|
|
#[test]
|
|
fn pipeline_eval_unary_neg() {
|
|
let mut ctx = EvalContext::new();
|
|
let result = eval_line("-5 + 3", &mut ctx);
|
|
assert_eq!(result.metadata.display, "-2");
|
|
}
|
|
|
|
#[test]
|
|
fn pipeline_eval_percentage() {
|
|
let mut ctx = EvalContext::new();
|
|
let result = eval_line("100 - 20%", &mut ctx);
|
|
assert_eq!(result.metadata.display, "80");
|
|
}
|
|
|
|
#[test]
|
|
fn pipeline_eval_unit_number() {
|
|
let mut ctx = EvalContext::new();
|
|
let result = eval_line("5kg", &mut ctx);
|
|
assert_eq!(result.result_type(), ResultType::UnitValue);
|
|
assert_eq!(result.metadata.display, "5 kg");
|
|
}
|
|
|
|
#[test]
|
|
fn pipeline_eval_currency() {
|
|
let mut ctx = EvalContext::new();
|
|
let result = eval_line("$20", &mut ctx);
|
|
assert_eq!(result.result_type(), ResultType::CurrencyValue);
|
|
assert!(result.metadata.display.contains("20"));
|
|
}
|
|
|
|
#[test]
|
|
fn pipeline_eval_comparison() {
|
|
let mut ctx = EvalContext::new();
|
|
let result = eval_line("5 > 3", &mut ctx);
|
|
assert_eq!(result.result_type(), ResultType::Boolean);
|
|
assert_eq!(result.metadata.display, "true");
|
|
}
|
|
|
|
#[test]
|
|
fn pipeline_eval_division_by_zero() {
|
|
let mut ctx = EvalContext::new();
|
|
let result = eval_line("10 / 0", &mut ctx);
|
|
assert_eq!(result.result_type(), ResultType::Error);
|
|
}
|
|
|
|
#[test]
|
|
fn pipeline_eval_empty_input() {
|
|
let mut ctx = EvalContext::new();
|
|
let result = eval_line("", &mut ctx);
|
|
assert_eq!(result.result_type(), ResultType::Error);
|
|
}
|
|
|
|
#[test]
|
|
fn pipeline_eval_comment_only() {
|
|
let mut ctx = EvalContext::new();
|
|
let result = eval_line("// this is a comment", &mut ctx);
|
|
assert_eq!(result.result_type(), ResultType::Error);
|
|
}
|
|
|
|
// ===== Variable assignment tests (eval_sheet) =====
|
|
|
|
#[test]
|
|
fn pipeline_eval_sheet_basic() {
|
|
let mut ctx = EvalContext::new();
|
|
let results = eval_sheet(&["2 + 3", "10 * 2"], &mut ctx);
|
|
assert_eq!(results.len(), 2);
|
|
assert_eq!(results[0].metadata.display, "5");
|
|
assert_eq!(results[1].metadata.display, "20");
|
|
}
|
|
|
|
#[test]
|
|
fn pipeline_eval_sheet_variable_assignment() {
|
|
let mut ctx = EvalContext::new();
|
|
let results = eval_sheet(&["x = 5", "x + 3"], &mut ctx);
|
|
assert_eq!(results.len(), 2);
|
|
assert_eq!(results[0].metadata.display, "5");
|
|
assert_eq!(results[1].metadata.display, "8");
|
|
}
|
|
|
|
#[test]
|
|
fn pipeline_eval_sheet_multiple_variables() {
|
|
let mut ctx = EvalContext::new();
|
|
let results = eval_sheet(&["a = 10", "b = 20", "a + b"], &mut ctx);
|
|
assert_eq!(results.len(), 3);
|
|
assert_eq!(results[0].metadata.display, "10");
|
|
assert_eq!(results[1].metadata.display, "20");
|
|
assert_eq!(results[2].metadata.display, "30");
|
|
}
|
|
|
|
#[test]
|
|
fn pipeline_eval_sheet_variable_reassignment() {
|
|
let mut ctx = EvalContext::new();
|
|
let results = eval_sheet(&["x = 5", "x = 10", "x"], &mut ctx);
|
|
assert_eq!(results.len(), 3);
|
|
assert_eq!(results[2].metadata.display, "10");
|
|
}
|
|
|
|
#[test]
|
|
fn pipeline_eval_sheet_undefined_variable() {
|
|
let mut ctx = EvalContext::new();
|
|
let results = eval_sheet(&["y + 3"], &mut ctx);
|
|
assert_eq!(results[0].result_type(), ResultType::Error);
|
|
}
|
|
|
|
// ===== FFI function tests =====
|
|
|
|
#[test]
|
|
fn ffi_eval_line_basic() {
|
|
let input = CString::new("2 + 3").unwrap();
|
|
unsafe {
|
|
let result_ptr = calcpad_eval_line(input.as_ptr());
|
|
assert!(!result_ptr.is_null());
|
|
|
|
let result_str = CStr::from_ptr(result_ptr).to_str().unwrap();
|
|
let response: FfiResponse = serde_json::from_str(result_str).unwrap();
|
|
|
|
assert_eq!(response.schema_version, "1.0");
|
|
assert_eq!(response.result.result_type(), ResultType::Number);
|
|
assert_eq!(response.result.metadata.display, "5");
|
|
assert_eq!(response.result.metadata.raw_value, Some(5.0));
|
|
|
|
calcpad_free_result(result_ptr);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ffi_eval_line_complex_expression() {
|
|
let input = CString::new("(10 + 5) * 2").unwrap();
|
|
unsafe {
|
|
let result_ptr = calcpad_eval_line(input.as_ptr());
|
|
assert!(!result_ptr.is_null());
|
|
|
|
let result_str = CStr::from_ptr(result_ptr).to_str().unwrap();
|
|
let response: FfiResponse = serde_json::from_str(result_str).unwrap();
|
|
|
|
assert_eq!(response.result.metadata.display, "30");
|
|
|
|
calcpad_free_result(result_ptr);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ffi_eval_line_null_input() {
|
|
unsafe {
|
|
let result_ptr = calcpad_eval_line(ptr::null());
|
|
assert!(!result_ptr.is_null());
|
|
|
|
let result_str = CStr::from_ptr(result_ptr).to_str().unwrap();
|
|
let response: FfiResponse = serde_json::from_str(result_str).unwrap();
|
|
|
|
assert_eq!(response.result.result_type(), ResultType::Error);
|
|
|
|
calcpad_free_result(result_ptr);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ffi_eval_sheet_basic() {
|
|
let line1 = CString::new("2 + 3").unwrap();
|
|
let line2 = CString::new("10 * 2").unwrap();
|
|
let lines: Vec<*const c_char> = vec![line1.as_ptr(), line2.as_ptr()];
|
|
|
|
unsafe {
|
|
let result_ptr = calcpad_eval_sheet(lines.as_ptr(), 2);
|
|
assert!(!result_ptr.is_null());
|
|
|
|
let result_str = CStr::from_ptr(result_ptr).to_str().unwrap();
|
|
let response: FfiSheetResponse = serde_json::from_str(result_str).unwrap();
|
|
|
|
assert_eq!(response.schema_version, "1.0");
|
|
assert_eq!(response.results.len(), 2);
|
|
assert_eq!(response.results[0].metadata.display, "5");
|
|
assert_eq!(response.results[1].metadata.display, "20");
|
|
|
|
calcpad_free_result(result_ptr);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ffi_eval_sheet_with_variables() {
|
|
let line1 = CString::new("x = 5").unwrap();
|
|
let line2 = CString::new("x + 10").unwrap();
|
|
let lines: Vec<*const c_char> = vec![line1.as_ptr(), line2.as_ptr()];
|
|
|
|
unsafe {
|
|
let result_ptr = calcpad_eval_sheet(lines.as_ptr(), 2);
|
|
assert!(!result_ptr.is_null());
|
|
|
|
let result_str = CStr::from_ptr(result_ptr).to_str().unwrap();
|
|
let response: FfiSheetResponse = serde_json::from_str(result_str).unwrap();
|
|
|
|
assert_eq!(response.results.len(), 2);
|
|
assert_eq!(response.results[0].metadata.display, "5");
|
|
assert_eq!(response.results[1].metadata.display, "15");
|
|
|
|
calcpad_free_result(result_ptr);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ffi_eval_sheet_null_lines() {
|
|
unsafe {
|
|
let result_ptr = calcpad_eval_sheet(ptr::null(), 0);
|
|
assert!(!result_ptr.is_null());
|
|
|
|
let result_str = CStr::from_ptr(result_ptr).to_str().unwrap();
|
|
let response: FfiResponse = serde_json::from_str(result_str).unwrap();
|
|
|
|
assert_eq!(response.result.result_type(), ResultType::Error);
|
|
|
|
calcpad_free_result(result_ptr);
|
|
}
|
|
}
|
|
|
|
// ===== Panic safety tests =====
|
|
|
|
#[test]
|
|
fn ffi_panic_safety_eval_line() {
|
|
// The catch_unwind in eval_line should handle any internal panics.
|
|
// Test with various edge cases that might trigger unexpected behavior.
|
|
let input = CString::new("2 + 3").unwrap();
|
|
unsafe {
|
|
let result_ptr = calcpad_eval_line(input.as_ptr());
|
|
assert!(!result_ptr.is_null());
|
|
calcpad_free_result(result_ptr);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ffi_panic_safety_null_input() {
|
|
// Null input should not crash — should return error JSON
|
|
unsafe {
|
|
let result_ptr = calcpad_eval_line(ptr::null());
|
|
assert!(!result_ptr.is_null());
|
|
|
|
let result_str = CStr::from_ptr(result_ptr).to_str().unwrap();
|
|
assert!(result_str.contains("error") || result_str.contains("Error"));
|
|
|
|
calcpad_free_result(result_ptr);
|
|
}
|
|
}
|
|
|
|
// ===== Memory management tests =====
|
|
|
|
#[test]
|
|
fn ffi_free_result_null() {
|
|
// Freeing null should be a safe no-op
|
|
unsafe {
|
|
calcpad_free_result(ptr::null_mut());
|
|
}
|
|
// If we get here without crashing, the test passes
|
|
}
|
|
|
|
#[test]
|
|
fn ffi_free_result_valid() {
|
|
let input = CString::new("42").unwrap();
|
|
unsafe {
|
|
let result_ptr = calcpad_eval_line(input.as_ptr());
|
|
assert!(!result_ptr.is_null());
|
|
// Free the result — should not crash
|
|
calcpad_free_result(result_ptr);
|
|
}
|
|
// If we get here without crashing, the test passes
|
|
}
|
|
|
|
#[test]
|
|
fn ffi_multiple_allocations() {
|
|
// Allocate and free multiple results to check for memory leaks
|
|
for _ in 0..100 {
|
|
let input = CString::new("1 + 1").unwrap();
|
|
unsafe {
|
|
let result_ptr = calcpad_eval_line(input.as_ptr());
|
|
assert!(!result_ptr.is_null());
|
|
calcpad_free_result(result_ptr);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===== JSON schema versioning tests =====
|
|
|
|
#[test]
|
|
fn json_schema_version_present() {
|
|
let input = CString::new("42").unwrap();
|
|
unsafe {
|
|
let result_ptr = calcpad_eval_line(input.as_ptr());
|
|
let result_str = CStr::from_ptr(result_ptr).to_str().unwrap();
|
|
|
|
let json: serde_json::Value = serde_json::from_str(result_str).unwrap();
|
|
assert_eq!(json["schema_version"], "1.0");
|
|
|
|
calcpad_free_result(result_ptr);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn json_schema_contains_required_fields() {
|
|
let input = CString::new("42").unwrap();
|
|
unsafe {
|
|
let result_ptr = calcpad_eval_line(input.as_ptr());
|
|
let result_str = CStr::from_ptr(result_ptr).to_str().unwrap();
|
|
|
|
let json: serde_json::Value = serde_json::from_str(result_str).unwrap();
|
|
|
|
// Check schema_version
|
|
assert!(json["schema_version"].is_string());
|
|
|
|
// Check result structure
|
|
assert!(json["result"]["value"].is_object());
|
|
assert!(json["result"]["metadata"].is_object());
|
|
|
|
// Check metadata fields
|
|
let metadata = &json["result"]["metadata"];
|
|
assert!(metadata["result_type"].is_string());
|
|
assert!(metadata["display"].is_string());
|
|
assert!(metadata["span"].is_object());
|
|
assert!(metadata["span"]["start"].is_number());
|
|
assert!(metadata["span"]["end"].is_number());
|
|
|
|
// Check raw_value is present (can be number or null)
|
|
assert!(
|
|
metadata["raw_value"].is_number() || metadata["raw_value"].is_null(),
|
|
"raw_value should be a number or null"
|
|
);
|
|
|
|
calcpad_free_result(result_ptr);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn json_schema_result_type_field() {
|
|
let input = CString::new("42").unwrap();
|
|
unsafe {
|
|
let result_ptr = calcpad_eval_line(input.as_ptr());
|
|
let result_str = CStr::from_ptr(result_ptr).to_str().unwrap();
|
|
|
|
let json: serde_json::Value = serde_json::from_str(result_str).unwrap();
|
|
assert_eq!(json["result"]["metadata"]["result_type"], "Number");
|
|
assert_eq!(json["result"]["value"]["kind"], "Number");
|
|
|
|
calcpad_free_result(result_ptr);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn json_schema_error_result() {
|
|
let input = CString::new("10 / 0").unwrap();
|
|
unsafe {
|
|
let result_ptr = calcpad_eval_line(input.as_ptr());
|
|
let result_str = CStr::from_ptr(result_ptr).to_str().unwrap();
|
|
|
|
let json: serde_json::Value = serde_json::from_str(result_str).unwrap();
|
|
assert_eq!(json["result"]["metadata"]["result_type"], "Error");
|
|
assert_eq!(json["result"]["value"]["kind"], "Error");
|
|
assert!(json["result"]["value"]["message"].is_string());
|
|
|
|
calcpad_free_result(result_ptr);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn json_schema_sheet_version() {
|
|
let line1 = CString::new("1 + 1").unwrap();
|
|
let lines: Vec<*const c_char> = vec![line1.as_ptr()];
|
|
|
|
unsafe {
|
|
let result_ptr = calcpad_eval_sheet(lines.as_ptr(), 1);
|
|
let result_str = CStr::from_ptr(result_ptr).to_str().unwrap();
|
|
|
|
let json: serde_json::Value = serde_json::from_str(result_str).unwrap();
|
|
assert_eq!(json["schema_version"], "1.0");
|
|
assert!(json["results"].is_array());
|
|
assert_eq!(json["results"].as_array().unwrap().len(), 1);
|
|
|
|
calcpad_free_result(result_ptr);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn json_display_value_and_raw_value() {
|
|
let input = CString::new("2.5 * 4").unwrap();
|
|
unsafe {
|
|
let result_ptr = calcpad_eval_line(input.as_ptr());
|
|
let result_str = CStr::from_ptr(result_ptr).to_str().unwrap();
|
|
|
|
let json: serde_json::Value = serde_json::from_str(result_str).unwrap();
|
|
assert_eq!(json["result"]["metadata"]["display"], "10");
|
|
assert_eq!(json["result"]["metadata"]["raw_value"], 10.0);
|
|
|
|
calcpad_free_result(result_ptr);
|
|
}
|
|
}
|