diff --git a/base/src/expressions/parser/static_analysis.rs b/base/src/expressions/parser/static_analysis.rs index 80f19436..fd6af3e2 100644 --- a/base/src/expressions/parser/static_analysis.rs +++ b/base/src/expressions/parser/static_analysis.rs @@ -610,18 +610,26 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec args_signature_row(arg_count), Function::Columns => args_signature_one_vector(arg_count), Function::Ln => args_signature_scalars(arg_count, 1, 0), + Function::Gcd => vec![Signature::Vector; arg_count], + Function::Lcm => vec![Signature::Vector; arg_count], Function::Log => args_signature_scalars(arg_count, 1, 1), Function::Log10 => args_signature_scalars(arg_count, 1, 0), Function::Cos => args_signature_scalars(arg_count, 1, 0), Function::Cosh => args_signature_scalars(arg_count, 1, 0), + Function::Degrees => args_signature_scalars(arg_count, 1, 0), Function::Max => vec![Signature::Vector; arg_count], Function::Min => vec![Signature::Vector; arg_count], Function::Pi => args_signature_no_args(arg_count), Function::Power => args_signature_scalars(arg_count, 2, 0), Function::Product => vec![Signature::Vector; arg_count], + Function::Ceiling => args_signature_scalars(arg_count, 2, 0), + Function::Floor => args_signature_scalars(arg_count, 2, 0), + Function::Radians => args_signature_scalars(arg_count, 1, 0), Function::Round => args_signature_scalars(arg_count, 2, 0), Function::Rounddown => args_signature_scalars(arg_count, 2, 0), Function::Roundup => args_signature_scalars(arg_count, 2, 0), + Function::Mod => args_signature_scalars(arg_count, 2, 0), + Function::Quotient => args_signature_scalars(arg_count, 2, 0), Function::Sin => args_signature_scalars(arg_count, 1, 0), Function::Sinh => args_signature_scalars(arg_count, 1, 0), Function::Sqrt => args_signature_scalars(arg_count, 1, 0), @@ -782,6 +790,8 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec args_signature_npv(arg_count), Function::Rand => args_signature_no_args(arg_count), Function::Randbetween => args_signature_scalars(arg_count, 2, 0), + Function::Int => args_signature_scalars(arg_count, 1, 0), + Function::Mround => args_signature_scalars(arg_count, 2, 0), Function::Formulatext => args_signature_scalars(arg_count, 1, 0), Function::Unicode => args_signature_scalars(arg_count, 1, 0), Function::Geomean => vec![Signature::Vector; arg_count], @@ -815,15 +825,23 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::Columns => not_implemented(args), Function::Cos => scalar_arguments(args), Function::Cosh => scalar_arguments(args), + Function::Degrees => scalar_arguments(args), Function::Max => StaticResult::Scalar, Function::Min => StaticResult::Scalar, Function::Pi => StaticResult::Scalar, Function::Power => scalar_arguments(args), Function::Product => not_implemented(args), + Function::Ceiling => scalar_arguments(args), + Function::Floor => scalar_arguments(args), + Function::Radians => scalar_arguments(args), Function::Round => scalar_arguments(args), Function::Rounddown => scalar_arguments(args), Function::Roundup => scalar_arguments(args), + Function::Mod => scalar_arguments(args), + Function::Quotient => scalar_arguments(args), Function::Ln => scalar_arguments(args), + Function::Gcd => StaticResult::Scalar, + Function::Lcm => StaticResult::Scalar, Function::Log => scalar_arguments(args), Function::Log10 => scalar_arguments(args), Function::Sin => scalar_arguments(args), @@ -987,6 +1005,8 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::Subtotal => not_implemented(args), Function::Rand => not_implemented(args), Function::Randbetween => scalar_arguments(args), + Function::Int => scalar_arguments(args), + Function::Mround => scalar_arguments(args), Function::Eomonth => scalar_arguments(args), Function::Formulatext => not_implemented(args), Function::Geomean => not_implemented(args), diff --git a/base/src/functions/mathematical.rs b/base/src/functions/mathematical.rs index 8931b58d..29a221a0 100644 --- a/base/src/functions/mathematical.rs +++ b/base/src/functions/mathematical.rs @@ -8,6 +8,32 @@ use crate::{ }; use std::f64::consts::PI; +/// Shared GCD (Greatest Common Divisor) implementation +/// Used by both GCD and LCM functions +fn gcd(mut a: u128, mut b: u128) -> u128 { + while b != 0 { + let r = a % b; + a = b; + b = r; + } + a +} + +/// Specifies which rounding behaviour to apply when calling `round_to_multiple`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RoundKind { + Ceiling, + Floor, +} + +/// Rounding mode used by the classic ROUND family (ROUND, ROUNDUP, ROUNDDOWN). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RoundDecimalKind { + Nearest, // ROUND + Up, // ROUNDUP + Down, // ROUNDDOWN +} + #[cfg(not(target_arch = "wasm32"))] pub fn random() -> f64 { rand::random() @@ -19,152 +45,179 @@ pub fn random() -> f64 { Math::random() } +/// Utility struct to hold optimized range bounds +struct RangeBounds { + row_start: i32, + row_end: i32, + col_start: i32, + col_end: i32, +} + impl Model { - pub(crate) fn fn_min(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { - let mut result = f64::NAN; - for arg in args { - match self.evaluate_node_in_context(arg, cell) { - CalcResult::Number(value) => result = value.min(result), - CalcResult::Range { left, right } => { - if left.sheet != right.sheet { - return CalcResult::new_error( - Error::VALUE, - cell, - "Ranges are in different sheets".to_string(), - ); - } - for row in left.row..(right.row + 1) { - for column in left.column..(right.column + 1) { - match self.evaluate_cell(CellReferenceIndex { - sheet: left.sheet, - row, - column, - }) { - CalcResult::Number(value) => { - result = value.min(result); - } - error @ CalcResult::Error { .. } => return error, - _ => { - // We ignore booleans and strings - } - } - } - } + /// Resolves worksheet bounds by replacing LAST_ROW/LAST_COLUMN with actual sheet dimensions + /// Provides significant performance improvement for large range operations + fn resolve_worksheet_bounds( + &mut self, + left: CellReferenceIndex, + right: CellReferenceIndex, + cell: CellReferenceIndex, + ) -> Result { + let row_start = left.row; + let mut row_end = right.row; + let col_start = left.column; + let mut col_end = right.column; + + if row_start == 1 && row_end == LAST_ROW { + row_end = match self.workbook.worksheet(left.sheet) { + Ok(s) => s.dimension().max_row, + Err(_) => { + return Err(CalcResult::new_error( + Error::ERROR, + cell, + format!("Invalid worksheet index: '{}'", left.sheet), + )); } - error @ CalcResult::Error { .. } => return error, - _ => { - // We ignore booleans and strings + }; + } + if col_start == 1 && col_end == LAST_COLUMN { + col_end = match self.workbook.worksheet(left.sheet) { + Ok(s) => s.dimension().max_column, + Err(_) => { + return Err(CalcResult::new_error( + Error::ERROR, + cell, + format!("Invalid worksheet index: '{}'", left.sheet), + )); } }; } - if result.is_nan() || result.is_infinite() { - return CalcResult::Number(0.0); + + Ok(RangeBounds { + row_start, + row_end, + col_start, + col_end, + }) + } + + /// Extracts exactly two numbers from function arguments with validation + /// Used by ATAN2, MOD, QUOTIENT, POWER, etc. + fn extract_two_numbers( + &mut self, + args: &[Node], + cell: CellReferenceIndex, + ) -> Result<(f64, f64), CalcResult> { + if args.len() != 2 { + return Err(CalcResult::new_args_number_error(cell)); } - CalcResult::Number(result) + let first = self.get_number(&args[0], cell)?; + let second = self.get_number(&args[1], cell)?; + Ok((first, second)) } - pub(crate) fn fn_max(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { - let mut result = f64::NAN; + /// Applies a closure to all numeric values in function arguments (ranges, arrays, numbers) + /// Returns early on errors. Used by aggregate functions like GCD, LCM. + fn process_numeric_args( + &mut self, + args: &[Node], + cell: CellReferenceIndex, + processor: &mut F, + ) -> Result<(), CalcResult> + where + F: FnMut(f64) -> Result<(), CalcResult>, + { for arg in args { match self.evaluate_node_in_context(arg, cell) { - CalcResult::Number(value) => result = value.max(result), + CalcResult::Number(v) => { + processor(v)?; + } CalcResult::Range { left, right } => { if left.sheet != right.sheet { - return CalcResult::new_error( + return Err(CalcResult::new_error( Error::VALUE, cell, "Ranges are in different sheets".to_string(), - ); + )); } - for row in left.row..(right.row + 1) { - for column in left.column..(right.column + 1) { + for row in left.row..=right.row { + for column in left.column..=right.column { match self.evaluate_cell(CellReferenceIndex { sheet: left.sheet, row, column, }) { - CalcResult::Number(value) => { - result = value.max(result); - } - error @ CalcResult::Error { .. } => return error, - _ => { - // We ignore booleans and strings + CalcResult::Number(v) => { + processor(v)?; } + error @ CalcResult::Error { .. } => return Err(error), + _ => {} } } } } - error @ CalcResult::Error { .. } => return error, - _ => { - // We ignore booleans and strings + CalcResult::Array(arr) => { + for row in arr { + for value in row { + match value { + ArrayNode::Number(v) => { + processor(v)?; + } + ArrayNode::Error(err) => { + return Err(CalcResult::Error { + error: err, + origin: cell, + message: "Error in array".to_string(), + }); + } + _ => {} + } + } + } } - }; - } - if result.is_nan() || result.is_infinite() { - return CalcResult::Number(0.0); + error @ CalcResult::Error { .. } => return Err(error), + _ => {} + } } - CalcResult::Number(result) + Ok(()) } - pub(crate) fn fn_sum(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { - if args.is_empty() { - return CalcResult::new_args_number_error(cell); - } - - let mut result = 0.0; + /// Applies a closure to all numeric values in ranges with bounds optimization + /// Used by functions like SUM, PRODUCT that benefit from the optimization + fn process_numeric_args_with_range_bounds( + &mut self, + args: &[Node], + cell: CellReferenceIndex, + processor: &mut F, + ) -> Result<(), CalcResult> + where + F: FnMut(f64) -> Result<(), CalcResult>, + { for arg in args { match self.evaluate_node_in_context(arg, cell) { - CalcResult::Number(value) => result += value, + CalcResult::Number(value) => { + processor(value)?; + } CalcResult::Range { left, right } => { if left.sheet != right.sheet { - return CalcResult::new_error( + return Err(CalcResult::new_error( Error::VALUE, cell, "Ranges are in different sheets".to_string(), - ); + )); } - // TODO: We should do this for all functions that run through ranges - // Running cargo test for the ironcalc takes around .8 seconds with this speedup - // and ~ 3.5 seconds without it. Note that once properly in place sheet.dimension should be almost a noop - let row1 = left.row; - let mut row2 = right.row; - let column1 = left.column; - let mut column2 = right.column; - if row1 == 1 && row2 == LAST_ROW { - row2 = match self.workbook.worksheet(left.sheet) { - Ok(s) => s.dimension().max_row, - Err(_) => { - return CalcResult::new_error( - Error::ERROR, - cell, - format!("Invalid worksheet index: '{}'", left.sheet), - ); - } - }; - } - if column1 == 1 && column2 == LAST_COLUMN { - column2 = match self.workbook.worksheet(left.sheet) { - Ok(s) => s.dimension().max_column, - Err(_) => { - return CalcResult::new_error( - Error::ERROR, - cell, - format!("Invalid worksheet index: '{}'", left.sheet), - ); - } - }; - } - for row in row1..row2 + 1 { - for column in column1..(column2 + 1) { + let bounds = self.resolve_worksheet_bounds(left, right, cell)?; + + for row in bounds.row_start..=bounds.row_end { + for column in bounds.col_start..=bounds.col_end { match self.evaluate_cell(CellReferenceIndex { sheet: left.sheet, row, column, }) { CalcResult::Number(value) => { - result += value; + processor(value)?; } - error @ CalcResult::Error { .. } => return error, + error @ CalcResult::Error { .. } => return Err(error), _ => { // We ignore booleans and strings } @@ -177,14 +230,14 @@ impl Model { for value in row { match value { ArrayNode::Number(value) => { - result += value; + processor(value)?; } ArrayNode::Error(error) => { - return CalcResult::Error { + return Err(CalcResult::Error { error, origin: cell, message: "Error in array".to_string(), - } + }); } _ => { // We ignore booleans and strings @@ -193,12 +246,70 @@ impl Model { } } } - error @ CalcResult::Error { .. } => return error, + error @ CalcResult::Error { .. } => return Err(error), _ => { // We ignore booleans and strings } - }; + } + } + Ok(()) + } + + pub(crate) fn fn_min(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + let mut result = f64::NAN; + let mut min_processor = |value: f64| -> Result<(), CalcResult> { + result = value.min(result); + Ok(()) + }; + + // Use the optimized utility function for range processing + if let Err(e) = self.process_numeric_args_with_range_bounds(args, cell, &mut min_processor) + { + return e; + } + + if result.is_nan() || result.is_infinite() { + return CalcResult::Number(0.0); + } + CalcResult::Number(result) + } + + pub(crate) fn fn_max(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + let mut result = f64::NAN; + let mut max_processor = |value: f64| -> Result<(), CalcResult> { + result = value.max(result); + Ok(()) + }; + + // Use the optimized utility function for range processing + if let Err(e) = self.process_numeric_args_with_range_bounds(args, cell, &mut max_processor) + { + return e; + } + + if result.is_nan() || result.is_infinite() { + return CalcResult::Number(0.0); + } + CalcResult::Number(result) + } + + pub(crate) fn fn_sum(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.is_empty() { + return CalcResult::new_args_number_error(cell); } + + let mut result = 0.0; + let mut sum_processor = |value: f64| -> Result<(), CalcResult> { + result += value; + Ok(()) + }; + + // Use the new utility function with optimization for range bounds + if let Err(e) = self.process_numeric_args_with_range_bounds(args, cell, &mut sum_processor) + { + return e; + } + CalcResult::Number(result) } @@ -206,75 +317,22 @@ impl Model { if args.is_empty() { return CalcResult::new_args_number_error(cell); } + let mut result = 1.0; let mut seen_value = false; - for arg in args { - match self.evaluate_node_in_context(arg, cell) { - CalcResult::Number(value) => { - seen_value = true; - result *= value; - } - CalcResult::Range { left, right } => { - if left.sheet != right.sheet { - return CalcResult::new_error( - Error::VALUE, - cell, - "Ranges are in different sheets".to_string(), - ); - } - let row1 = left.row; - let mut row2 = right.row; - let column1 = left.column; - let mut column2 = right.column; - if row1 == 1 && row2 == LAST_ROW { - row2 = match self.workbook.worksheet(left.sheet) { - Ok(s) => s.dimension().max_row, - Err(_) => { - return CalcResult::new_error( - Error::ERROR, - cell, - format!("Invalid worksheet index: '{}'", left.sheet), - ); - } - }; - } - if column1 == 1 && column2 == LAST_COLUMN { - column2 = match self.workbook.worksheet(left.sheet) { - Ok(s) => s.dimension().max_column, - Err(_) => { - return CalcResult::new_error( - Error::ERROR, - cell, - format!("Invalid worksheet index: '{}'", left.sheet), - ); - } - }; - } - for row in row1..row2 + 1 { - for column in column1..(column2 + 1) { - match self.evaluate_cell(CellReferenceIndex { - sheet: left.sheet, - row, - column, - }) { - CalcResult::Number(value) => { - seen_value = true; - result *= value; - } - error @ CalcResult::Error { .. } => return error, - _ => { - // We ignore booleans and strings - } - } - } - } - } - error @ CalcResult::Error { .. } => return error, - _ => { - // We ignore booleans and strings - } - }; + let mut product_processor = |value: f64| -> Result<(), CalcResult> { + seen_value = true; + result *= value; + Ok(()) + }; + + // Use the new utility function with optimization for range bounds + if let Err(e) = + self.process_numeric_args_with_range_bounds(args, cell, &mut product_processor) + { + return e; } + if !seen_value { return CalcResult::Number(0.0); } @@ -305,77 +363,123 @@ impl Model { CalcResult::Number(total) } - pub(crate) fn fn_round(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + /// Shared implementation for Excel's ROUND / ROUNDUP / ROUNDDOWN functions + /// that round a scalar to a specified number of decimal digits. + fn round_decimal_fn( + &mut self, + args: &[Node], + cell: CellReferenceIndex, + mode: RoundDecimalKind, + ) -> CalcResult { if args.len() != 2 { - // Incorrect number of arguments return CalcResult::new_args_number_error(cell); } + + // Extract value and number_of_digits, propagating errors. let value = match self.get_number(&args[0], cell) { - Ok(f) => f, - Err(s) => return s, + Ok(v) => v, + Err(e) => return e, }; - let number_of_digits = match self.get_number(&args[1], cell) { - Ok(f) => { - if f > 0.0 { - f.floor() + let digits_raw = match self.get_number(&args[1], cell) { + Ok(v) => v, + Err(e) => return e, + }; + + // Excel truncates non-integer digit counts toward zero. + let digits = if digits_raw > 0.0 { + digits_raw.floor() + } else { + digits_raw.ceil() + }; + + let scale = 10.0_f64.powf(digits); + + let rounded = match mode { + RoundDecimalKind::Nearest => (value * scale).round() / scale, + RoundDecimalKind::Up => { + if value > 0.0 { + (value * scale).ceil() / scale } else { - f.ceil() + (value * scale).floor() / scale } } - Err(s) => return s, - }; - let scale = 10.0_f64.powf(number_of_digits); - CalcResult::Number((value * scale).round() / scale) - } - pub(crate) fn fn_roundup(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { - if args.len() != 2 { - return CalcResult::new_args_number_error(cell); - } - let value = match self.get_number(&args[0], cell) { - Ok(f) => f, - Err(s) => return s, - }; - let number_of_digits = match self.get_number(&args[1], cell) { - Ok(f) => { - if f > 0.0 { - f.floor() + RoundDecimalKind::Down => { + if value > 0.0 { + (value * scale).floor() / scale } else { - f.ceil() + (value * scale).ceil() / scale } } - Err(s) => return s, }; - let scale = 10.0_f64.powf(number_of_digits); - if value > 0.0 { - CalcResult::Number((value * scale).ceil() / scale) - } else { - CalcResult::Number((value * scale).floor() / scale) - } + + CalcResult::Number(rounded) } - pub(crate) fn fn_rounddown(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + + /// Helper used by CEILING and FLOOR to round a value to the nearest multiple of + /// `significance`, taking into account the Excel sign rule. + fn round_to_multiple( + &mut self, + args: &[Node], + cell: CellReferenceIndex, + kind: RoundKind, + ) -> CalcResult { if args.len() != 2 { return CalcResult::new_args_number_error(cell); } + let value = match self.get_number(&args[0], cell) { - Ok(f) => f, - Err(s) => return s, + Ok(v) => v, + Err(e) => return e, }; - let number_of_digits = match self.get_number(&args[1], cell) { - Ok(f) => { - if f > 0.0 { - f.floor() - } else { - f.ceil() - } - } - Err(s) => return s, + let significance = match self.get_number(&args[1], cell) { + Ok(v) => v, + Err(e) => return e, }; - let scale = 10.0_f64.powf(number_of_digits); - if value > 0.0 { - CalcResult::Number((value * scale).floor() / scale) - } else { - CalcResult::Number((value * scale).ceil() / scale) + + if significance == 0.0 { + return CalcResult::Error { + error: Error::DIV, + origin: cell, + message: "Divide by 0".to_string(), + }; } + if value.signum() * significance.signum() < 0.0 { + return CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "Invalid sign".to_string(), + }; + } + + let quotient = value / significance; + let use_ceil = (significance > 0.0) == matches!(kind, RoundKind::Ceiling); + let rounded_multiple = if use_ceil { + quotient.ceil() * significance + } else { + quotient.floor() * significance + }; + + CalcResult::Number(rounded_multiple) + } + + pub(crate) fn fn_ceiling(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + self.round_to_multiple(args, cell, RoundKind::Ceiling) + } + + pub(crate) fn fn_floor(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + self.round_to_multiple(args, cell, RoundKind::Floor) + } + + pub(crate) fn fn_round(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + self.round_decimal_fn(args, cell, RoundDecimalKind::Nearest) + } + + pub(crate) fn fn_roundup(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + self.round_decimal_fn(args, cell, RoundDecimalKind::Up) + } + + pub(crate) fn fn_rounddown(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + self.round_decimal_fn(args, cell, RoundDecimalKind::Down) } single_number_fn!(fn_log10, |f| if f <= 0.0 { @@ -400,6 +504,8 @@ impl Model { single_number_fn!(fn_asinh, |f| Ok(f64::asinh(f))); single_number_fn!(fn_acosh, |f| Ok(f64::acosh(f))); single_number_fn!(fn_atanh, |f| Ok(f64::atanh(f))); + single_number_fn!(fn_degrees, |f: f64| Ok(f.to_degrees())); + single_number_fn!(fn_radians, |f: f64| Ok(f.to_radians())); single_number_fn!(fn_abs, |f| Ok(f64::abs(f))); single_number_fn!(fn_sqrt, |f| if f < 0.0 { Err(Error::NUM) @@ -420,16 +526,9 @@ impl Model { } pub(crate) fn fn_atan2(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { - if args.len() != 2 { - return CalcResult::new_args_number_error(cell); - } - let x = match self.get_number(&args[0], cell) { - Ok(f) => f, - Err(s) => return s, - }; - let y = match self.get_number(&args[1], cell) { - Ok(f) => f, - Err(s) => return s, + let (x, y) = match self.extract_two_numbers(args, cell) { + Ok((x, y)) => (x, y), + Err(e) => return e, }; if x == 0.0 && y == 0.0 { return CalcResult::Error { @@ -483,28 +582,21 @@ impl Model { } pub(crate) fn fn_power(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { - if args.len() != 2 { - return CalcResult::new_args_number_error(cell); - } - let x = match self.get_number(&args[0], cell) { - Ok(f) => f, - Err(s) => return s, + let (base, exp) = match self.extract_two_numbers(args, cell) { + Ok((base, exp)) => (base, exp), + Err(e) => return e, }; - let y = match self.get_number(&args[1], cell) { - Ok(f) => f, - Err(s) => return s, - }; - if x == 0.0 && y == 0.0 { + if base == 0.0 && exp == 0.0 { return CalcResult::Error { error: Error::NUM, origin: cell, message: "Arguments can't be both zero".to_string(), }; } - if y == 0.0 { + if exp == 0.0 { return CalcResult::Number(1.0); } - let result = x.powf(y); + let result = base.powf(exp); if result.is_infinite() { return CalcResult::Error { error: Error::DIV, @@ -523,6 +615,124 @@ impl Model { CalcResult::Number(result) } + pub(crate) fn fn_mod(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + let (number, divisor) = match self.extract_two_numbers(args, cell) { + Ok((num, div)) => (num, div), + Err(e) => return e, + }; + if divisor == 0.0 { + return CalcResult::new_error(Error::DIV, cell, "Divide by 0".to_string()); + } + CalcResult::Number(number - divisor * (number / divisor).floor()) + } + + pub(crate) fn fn_quotient(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + let (numerator, denominator) = match self.extract_two_numbers(args, cell) { + Ok((num, den)) => (num, den), + Err(e) => return e, + }; + if denominator == 0.0 { + return CalcResult::new_error(Error::DIV, cell, "Divide by 0".to_string()); + } + CalcResult::Number((numerator / denominator).trunc()) + } + + pub(crate) fn fn_gcd(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.is_empty() { + return CalcResult::new_args_number_error(cell); + } + + let mut result: Option = None; + let mut update = |value: f64| -> Result<(), CalcResult> { + if value < 0.0 { + return Err(CalcResult::new_error( + Error::NUM, + cell, + "numbers must be positive".to_string(), + )); + } + if !value.is_finite() { + return Err(CalcResult::new_error( + Error::NUM, + cell, + "value must be finite".to_string(), + )); + } + let truncated = value.trunc(); + if truncated > u128::MAX as f64 { + return Err(CalcResult::new_error( + Error::NUM, + cell, + "value too large".to_string(), + )); + } + let v = truncated as u128; + result = Some(match result { + None => v, + Some(r) => gcd(r, v), + }); + Ok(()) + }; + + // Use the new utility function to process all numeric arguments + if let Err(e) = self.process_numeric_args(args, cell, &mut update) { + return e; + } + + CalcResult::Number(result.unwrap_or(0) as f64) + } + + pub(crate) fn fn_lcm(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.is_empty() { + return CalcResult::new_args_number_error(cell); + } + + let mut result: Option = None; + let mut update = |value: f64| -> Result<(), CalcResult> { + if value < 0.0 { + return Err(CalcResult::new_error( + Error::NUM, + cell, + "numbers must be positive".to_string(), + )); + } + if !value.is_finite() { + return Err(CalcResult::new_error( + Error::NUM, + cell, + "value must be finite".to_string(), + )); + } + let truncated = value.trunc(); + if truncated > u128::MAX as f64 { + return Err(CalcResult::new_error( + Error::NUM, + cell, + "value too large".to_string(), + )); + } + let v = truncated as u128; + result = Some(match result { + None => v, + Some(r) => { + if r == 0 || v == 0 { + 0 + } else { + r / gcd(r, v) * v + } + } + }); + Ok(()) + }; + + // Use the new utility function to process all numeric arguments + if let Err(e) = self.process_numeric_args(args, cell, &mut update) { + return e; + } + + CalcResult::Number(result.unwrap_or(0) as f64) + } + pub(crate) fn fn_rand(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { if !args.is_empty() { return CalcResult::new_args_number_error(cell); @@ -552,4 +762,48 @@ impl Model { } CalcResult::Number((x + random() * (y - x)).floor()) } + + single_number_fn!(fn_int, |f: f64| Ok(f.floor())); + + pub(crate) fn fn_mround(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() != 2 { + return CalcResult::new_args_number_error(cell); + } + let number = match self.get_number(&args[0], cell) { + Ok(f) => f, + Err(s) => return s, + }; + let significance = match self.get_number(&args[1], cell) { + Ok(f) => f, + Err(s) => return s, + }; + if significance == 0.0 { + return CalcResult::Number(0.0); + } + // Special case: zero number always returns 0 regardless of significance sign + if number == 0.0 { + return CalcResult::Number(0.0); + } + // Excel requires number and significance to have the same sign for non-zero numbers + if number.signum() != significance.signum() { + return CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "number and significance must have the same sign".to_string(), + }; + } + + // MROUND rounds ties away from zero (unlike Rust's round() which uses banker's rounding) + let quotient = number / significance; + + // Add f64::EPSILON to handle floating-point precision at exact 0.5 boundaries + // e.g., when 0.15/0.1 gives 1.4999999999999998 instead of exactly 1.5 + let rounded_quotient = if quotient >= 0.0 { + (quotient + 0.5 + f64::EPSILON).floor() + } else { + (quotient - 0.5 - f64::EPSILON).ceil() + }; + + CalcResult::Number(rounded_quotient * significance) + } } diff --git a/base/src/functions/mod.rs b/base/src/functions/mod.rs index 21c8f72d..39f215d6 100644 --- a/base/src/functions/mod.rs +++ b/base/src/functions/mod.rs @@ -54,16 +54,26 @@ pub enum Function { Columns, Cos, Cosh, + Degrees, Log, Log10, Ln, + Int, + Gcd, + Lcm, Max, Min, + Mod, Pi, Power, Product, + Quotient, + Radians, Rand, Randbetween, + Ceiling, + Floor, + Mround, Round, Rounddown, Roundup, @@ -253,7 +263,7 @@ pub enum Function { } impl Function { - pub fn into_iter() -> IntoIter { + pub fn into_iter() -> IntoIter { [ Function::And, Function::False, @@ -274,6 +284,7 @@ impl Function { Function::Atan, Function::Sinh, Function::Cosh, + Function::Degrees, Function::Tanh, Function::Asinh, Function::Acosh, @@ -281,6 +292,9 @@ impl Function { Function::Abs, Function::Pi, Function::Ln, + Function::Int, + Function::Gcd, + Function::Lcm, Function::Log, Function::Log10, Function::Sqrt, @@ -289,9 +303,15 @@ impl Function { Function::Power, Function::Max, Function::Min, + Function::Mod, Function::Product, + Function::Quotient, + Function::Radians, Function::Rand, Function::Randbetween, + Function::Ceiling, + Function::Floor, + Function::Mround, Function::Round, Function::Rounddown, Function::Roundup, @@ -527,6 +547,7 @@ impl Function { "SINH" => Some(Function::Sinh), "COSH" => Some(Function::Cosh), + "DEGREES" => Some(Function::Degrees), "TANH" => Some(Function::Tanh), "ASINH" => Some(Function::Asinh), @@ -541,14 +562,23 @@ impl Function { "ATAN2" => Some(Function::Atan2), "LN" => Some(Function::Ln), + "INT" => Some(Function::Int), + "GCD" => Some(Function::Gcd), + "LCM" => Some(Function::Lcm), "LOG" => Some(Function::Log), "LOG10" => Some(Function::Log10), "MAX" => Some(Function::Max), "MIN" => Some(Function::Min), + "MOD" => Some(Function::Mod), "PRODUCT" => Some(Function::Product), + "QUOTIENT" => Some(Function::Quotient), + "RADIANS" => Some(Function::Radians), "RAND" => Some(Function::Rand), "RANDBETWEEN" => Some(Function::Randbetween), + "CEILING" => Some(Function::Ceiling), + "FLOOR" => Some(Function::Floor), + "MROUND" => Some(Function::Mround), "ROUND" => Some(Function::Round), "ROUNDDOWN" => Some(Function::Rounddown), "ROUNDUP" => Some(Function::Roundup), @@ -747,6 +777,9 @@ impl fmt::Display for Function { Function::Log => write!(f, "LOG"), Function::Log10 => write!(f, "LOG10"), Function::Ln => write!(f, "LN"), + Function::Int => write!(f, "INT"), + Function::Gcd => write!(f, "GCD"), + Function::Lcm => write!(f, "LCM"), Function::Sin => write!(f, "SIN"), Function::Cos => write!(f, "COS"), Function::Tan => write!(f, "TAN"), @@ -755,6 +788,7 @@ impl fmt::Display for Function { Function::Atan => write!(f, "ATAN"), Function::Sinh => write!(f, "SINH"), Function::Cosh => write!(f, "COSH"), + Function::Degrees => write!(f, "DEGREES"), Function::Tanh => write!(f, "TANH"), Function::Asinh => write!(f, "ASINH"), Function::Acosh => write!(f, "ACOSH"), @@ -767,9 +801,15 @@ impl fmt::Display for Function { Function::Power => write!(f, "POWER"), Function::Max => write!(f, "MAX"), Function::Min => write!(f, "MIN"), + Function::Mod => write!(f, "MOD"), Function::Product => write!(f, "PRODUCT"), + Function::Quotient => write!(f, "QUOTIENT"), + Function::Radians => write!(f, "RADIANS"), Function::Rand => write!(f, "RAND"), Function::Randbetween => write!(f, "RANDBETWEEN"), + Function::Ceiling => write!(f, "CEILING"), + Function::Floor => write!(f, "FLOOR"), + Function::Mround => write!(f, "MROUND"), Function::Round => write!(f, "ROUND"), Function::Rounddown => write!(f, "ROUNDDOWN"), Function::Roundup => write!(f, "ROUNDUP"), @@ -977,6 +1017,9 @@ impl Model { Function::Log => self.fn_log(args, cell), Function::Log10 => self.fn_log10(args, cell), Function::Ln => self.fn_ln(args, cell), + Function::Int => self.fn_int(args, cell), + Function::Gcd => self.fn_gcd(args, cell), + Function::Lcm => self.fn_lcm(args, cell), Function::Sin => self.fn_sin(args, cell), Function::Cos => self.fn_cos(args, cell), Function::Tan => self.fn_tan(args, cell), @@ -987,6 +1030,7 @@ impl Model { Function::Sinh => self.fn_sinh(args, cell), Function::Cosh => self.fn_cosh(args, cell), + Function::Degrees => self.fn_degrees(args, cell), Function::Tanh => self.fn_tanh(args, cell), Function::Asinh => self.fn_asinh(args, cell), @@ -1003,9 +1047,15 @@ impl Model { Function::Max => self.fn_max(args, cell), Function::Min => self.fn_min(args, cell), + Function::Mod => self.fn_mod(args, cell), Function::Product => self.fn_product(args, cell), + Function::Quotient => self.fn_quotient(args, cell), + Function::Radians => self.fn_radians(args, cell), Function::Rand => self.fn_rand(args, cell), Function::Randbetween => self.fn_randbetween(args, cell), + Function::Ceiling => self.fn_ceiling(args, cell), + Function::Floor => self.fn_floor(args, cell), + Function::Mround => self.fn_mround(args, cell), Function::Round => self.fn_round(args, cell), Function::Rounddown => self.fn_rounddown(args, cell), Function::Roundup => self.fn_roundup(args, cell), diff --git a/base/src/test/mod.rs b/base/src/test/mod.rs index a0a0d69d..49949e67 100644 --- a/base/src/test/mod.rs +++ b/base/src/test/mod.rs @@ -10,6 +10,7 @@ mod test_date_and_time; mod test_error_propagation; mod test_fn_average; mod test_fn_averageifs; +mod test_fn_ceiling_floor; mod test_fn_choose; mod test_fn_concatenate; mod test_fn_count; @@ -20,6 +21,7 @@ mod test_fn_formulatext; mod test_fn_if; mod test_fn_maxifs; mod test_fn_minifs; +mod test_fn_mod; mod test_fn_or_xor; mod test_fn_product; mod test_fn_rept; @@ -52,18 +54,23 @@ mod test_fn_offset; mod test_number_format; mod test_arrays; +mod test_degrees_radians; mod test_escape_quotes; mod test_extend; mod test_fn_fv; mod test_fn_type; mod test_frozen_rows_and_columns; +mod test_gcd; mod test_geomean; mod test_get_cell_content; mod test_implicit_intersection; +mod test_int; mod test_issue_155; +mod test_lcm; mod test_ln; mod test_log; mod test_log10; +mod test_mround; mod test_percentage; mod test_set_functions_error_handling; mod test_today; diff --git a/base/src/test/test_degrees_radians.rs b/base/src/test/test_degrees_radians.rs new file mode 100644 index 00000000..021d303f --- /dev/null +++ b/base/src/test/test_degrees_radians.rs @@ -0,0 +1,94 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +#[test] +fn degrees_and_radians_arguments() { + let mut model = new_empty_model(); + model._set("A1", "=DEGREES()"); + model._set("A2", "=DEGREES(PI())"); + model._set("A3", "=DEGREES(1,2)"); + + model._set("B1", "=RADIANS()"); + model._set("B2", "=RADIANS(180)"); + model._set("B3", "=RADIANS(1,2)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"#ERROR!"); + assert_eq!(model._get_text("A2"), *"180"); + assert_eq!(model._get_text("A3"), *"#ERROR!"); + + assert_eq!(model._get_text("B1"), *"#ERROR!"); + assert_eq!(model._get_text("B2"), *"3.141592654"); + assert_eq!(model._get_text("B3"), *"#ERROR!"); +} + +#[test] +fn degrees_and_radians_typical_angles() { + let mut model = new_empty_model(); + + // RADIANS for common degree values + model._set("A1", "=RADIANS(90)"); + model._set("A2", "=RADIANS(270)"); + model._set("A3", "=RADIANS(360)"); + + // DEGREES for common radian values + model._set("B1", "=DEGREES(PI()/2)"); + model._set("B2", "=DEGREES(3*PI()/2)"); + model._set("B3", "=DEGREES(2*PI())"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"1.570796327"); + assert_eq!(model._get_text("A2"), *"4.71238898"); + assert_eq!(model._get_text("A3"), *"6.283185307"); + + assert_eq!(model._get_text("B1"), *"90"); + assert_eq!(model._get_text("B2"), *"270"); + assert_eq!(model._get_text("B3"), *"360"); +} + +#[test] +fn degrees_radians_round_trip_precision() { + let mut model = new_empty_model(); + + model._set("C1", "=DEGREES(RADIANS(123.456))"); + model._set("C2", "=RADIANS(DEGREES(PI()/4))"); + + model.evaluate(); + + // Round-trip check within general-format precision (string equality is enough) + assert_eq!(model._get_text("C1"), *"123.456"); + assert_eq!(model._get_text("C2"), *"0.785398163"); +} + +#[test] +fn test_fn_pi_value() { + let mut model = new_empty_model(); + model._set("D1", "=PI()"); + model.evaluate(); + + assert_eq!(model._get_text("D1"), *"3.141592654"); +} + +#[test] +fn degrees_radians_negative_and_zero() { + let mut model = new_empty_model(); + + // Zero angle + model._set("A1", "=RADIANS(0)"); + model._set("B1", "=DEGREES(0)"); + + // Negative angles + model._set("A2", "=RADIANS(-45)"); // -pi/4 + model._set("B2", "=DEGREES(-PI()/2)"); // -90 + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"0"); + assert_eq!(model._get_text("B1"), *"0"); + + assert_eq!(model._get_text("A2"), *"-0.785398163"); + assert_eq!(model._get_text("B2"), *"-90"); +} diff --git a/base/src/test/test_fn_ceiling_floor.rs b/base/src/test/test_fn_ceiling_floor.rs new file mode 100644 index 00000000..ca0d876b --- /dev/null +++ b/base/src/test/test_fn_ceiling_floor.rs @@ -0,0 +1,144 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +#[test] +fn simple_cases() { + let mut model = new_empty_model(); + model._set("A1", "=CEILING(4.3,2)"); + model._set("A2", "=CEILING(-4.3,-2)"); + model._set("A3", "=CEILING(-4.3,2)"); + model._set("B1", "=FLOOR(4.3,2)"); + model._set("B2", "=FLOOR(-4.3,-2)"); + model._set("B3", "=FLOOR(4.3,-2)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"6"); + assert_eq!(model._get_text("A2"), *"-4"); + assert_eq!(model._get_text("A3"), *"#NUM!"); + assert_eq!(model._get_text("B1"), *"4"); + assert_eq!(model._get_text("B2"), *"-6"); + assert_eq!(model._get_text("B3"), *"#NUM!"); +} + +#[test] +fn wrong_number_of_arguments() { + let mut model = new_empty_model(); + model._set("A1", "=CEILING(1)"); + model._set("A2", "=CEILING(1,2,3)"); + model._set("B1", "=FLOOR(1)"); + model._set("B2", "=FLOOR(1,2,3)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"#ERROR!"); + assert_eq!(model._get_text("A2"), *"#ERROR!"); + assert_eq!(model._get_text("B1"), *"#ERROR!"); + assert_eq!(model._get_text("B2"), *"#ERROR!"); +} + +#[test] +fn zero_significance() { + let mut model = new_empty_model(); + model._set("A1", "=CEILING(4.3,0)"); + model._set("B1", "=FLOOR(4.3,0)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"#DIV/0!"); + assert_eq!(model._get_text("B1"), *"#DIV/0!"); +} + +#[test] +fn already_multiple() { + let mut model = new_empty_model(); + model._set("A1", "=CEILING(6,3)"); + model._set("B1", "=FLOOR(6,3)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"6"); + assert_eq!(model._get_text("B1"), *"6"); +} + +#[test] +fn smaller_than_significance() { + let mut model = new_empty_model(); + model._set("A1", "=CEILING(1.3,2)"); + model._set("B1", "=FLOOR(1.3,2)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"2"); + assert_eq!(model._get_text("B1"), *"0"); +} + +#[test] +fn fractional_significance() { + let mut model = new_empty_model(); + model._set("A1", "=CEILING(4.3,2.5)"); + model._set("B1", "=FLOOR(4.3,2.5)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"5"); + assert_eq!(model._get_text("B1"), *"2.5"); +} + +#[test] +fn opposite_sign_error() { + let mut model = new_empty_model(); + model._set("A1", "=CEILING(4.3,-2)"); + model._set("B1", "=FLOOR(-4.3,2)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"#NUM!"); + assert_eq!(model._get_text("B1"), *"#NUM!"); +} + +#[test] +fn zero_value() { + let mut model = new_empty_model(); + model._set("A1", "=CEILING(0,2)"); + model._set("B1", "=FLOOR(0,2)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"0"); + assert_eq!(model._get_text("B1"), *"0"); +} + +#[test] +fn coercion_cases() { + let mut model = new_empty_model(); + model._set("B1", "'4.3"); // text that can be coerced + model._set("B2", "TRUE"); // boolean + // B3 left blank + + model._set("C1", "=CEILING(B1,2)"); + model._set("C2", "=FLOOR(B2,1)"); + model._set("C3", "=CEILING(B3,2)"); + + model.evaluate(); + + assert_eq!(model._get_text("C1"), *"6"); + assert_eq!(model._get_text("C2"), *"1"); + assert_eq!(model._get_text("C3"), *"0"); +} + +#[test] +fn error_propagation() { + let mut model = new_empty_model(); + model._set("A1", "=1/0"); // #DIV/0! in value + model._set("A2", "#REF!"); // #REF! error literal as significance + + model._set("B1", "=CEILING(A1,2)"); + model._set("B2", "=FLOOR(4.3,A2)"); + + model.evaluate(); + + assert_eq!(model._get_text("B1"), *"#DIV/0!"); + assert_eq!(model._get_text("B2"), *"#REF!"); +} diff --git a/base/src/test/test_fn_mod.rs b/base/src/test/test_fn_mod.rs new file mode 100644 index 00000000..9f804850 --- /dev/null +++ b/base/src/test/test_fn_mod.rs @@ -0,0 +1,55 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +#[test] +fn mod_function() { + let mut model = new_empty_model(); + model._set("A1", "=MOD(9,4)"); + model._set("A2", "=MOD(-3,2)"); + model._set("A3", "=MOD(3,-2)"); + model._set("A4", "=MOD(3,0)"); + model._set("A5", "=MOD(1)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"1"); + assert_eq!(model._get_text("A2"), *"1"); + assert_eq!(model._get_text("A3"), *"-1"); + assert_eq!(model._get_text("A4"), *"#DIV/0!"); + assert_eq!(model._get_text("A5"), *"#ERROR!"); +} + +#[test] +fn quotient_function() { + let mut model = new_empty_model(); + model._set("A1", "=QUOTIENT(5,2)"); + model._set("A2", "=QUOTIENT(5,-2)"); + model._set("A3", "=QUOTIENT(5,0)"); + model._set("A4", "=QUOTIENT(5)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"2"); + assert_eq!(model._get_text("A2"), *"-2"); // Fixed: should truncate toward zero, not floor + assert_eq!(model._get_text("A3"), *"#DIV/0!"); + assert_eq!(model._get_text("A4"), *"#ERROR!"); +} + +#[test] +fn quotient_function_truncate_toward_zero() { + let mut model = new_empty_model(); + model._set("A1", "=QUOTIENT(5,-2)"); // positive, negative truncation (original bug case) + model._set("A2", "=QUOTIENT(7,3)"); // positive, positive truncation + model._set("A3", "=QUOTIENT(-7,3)"); // negative, positive truncation + model._set("A4", "=QUOTIENT(-7,-3)"); // negative, negative truncation + model._set("A5", "=QUOTIENT(6,3)"); // exact division + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"-2"); // The key fix + assert_eq!(model._get_text("A2"), *"2"); + assert_eq!(model._get_text("A3"), *"-2"); + assert_eq!(model._get_text("A4"), *"2"); + assert_eq!(model._get_text("A5"), *"2"); +} diff --git a/base/src/test/test_gcd.rs b/base/src/test/test_gcd.rs new file mode 100644 index 00000000..af1367d8 --- /dev/null +++ b/base/src/test/test_gcd.rs @@ -0,0 +1,75 @@ +#![allow(clippy::unwrap_used)] +use crate::test::util::new_empty_model; + +#[test] +fn test_fn_gcd_arguments() { + let mut model = new_empty_model(); + model._set("A1", "=GCD()"); + model.evaluate(); + assert_eq!(model._get_text("A1"), *"#ERROR!"); +} + +#[test] +fn test_fn_gcd_basic() { + let mut model = new_empty_model(); + model._set("A1", "=GCD(12)"); + model._set("A2", "=GCD(60,36)"); + model._set("A3", "=GCD(15,25,35)"); + model._set("A4", "=GCD(12.7,8.3)"); // Decimal truncation + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"12"); + assert_eq!(model._get_text("A2"), *"12"); + assert_eq!(model._get_text("A3"), *"5"); + assert_eq!(model._get_text("A4"), *"4"); +} + +#[test] +fn test_fn_gcd_zeros_and_edge_cases() { + let mut model = new_empty_model(); + model._set("A1", "=GCD(0)"); + model._set("A2", "=GCD(0,12)"); + model._set("A3", "=GCD(12,0)"); + model._set("A4", "=GCD(1,2,3,4,5)"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"0"); + assert_eq!(model._get_text("A2"), *"12"); + assert_eq!(model._get_text("A3"), *"12"); + assert_eq!(model._get_text("A4"), *"1"); +} + +#[test] +fn test_fn_gcd_error_cases() { + let mut model = new_empty_model(); + model._set("A1", "=GCD(-5)"); + model._set("A2", "=GCD(12,-8)"); + model._set("B1", "=1/0"); // Infinity + model._set("A3", "=GCD(B1)"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"#NUM!"); + assert_eq!(model._get_text("A2"), *"#NUM!"); + assert_eq!(model._get_text("A3"), *"#DIV/0!"); +} + +#[test] +fn test_fn_gcd_ranges() { + let mut model = new_empty_model(); + // Range with numbers + model._set("B1", "12"); + model._set("B2", "18"); + model._set("B3", "24"); + model._set("A1", "=GCD(B1:B3)"); + + // Range with mixed data (text ignored) + model._set("C1", "12"); + model._set("C2", "text"); + model._set("C3", "6"); + model._set("A2", "=GCD(C1:C3)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"6"); + assert_eq!(model._get_text("A2"), *"6"); +} diff --git a/base/src/test/test_int.rs b/base/src/test/test_int.rs new file mode 100644 index 00000000..bb334053 --- /dev/null +++ b/base/src/test/test_int.rs @@ -0,0 +1,48 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +#[test] +fn int_wrong_argument_count() { + let mut model = new_empty_model(); + model._set("A1", "=INT()"); + model._set("A2", "=INT(5.7, 2)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"#ERROR!"); + assert_eq!(model._get_text("A2"), *"#ERROR!"); +} + +#[test] +fn int_basic_floor_behavior() { + let mut model = new_empty_model(); + // INT returns the largest integer less than or equal to the number (floor function) + model._set("A1", "=INT(5.7)"); // 5 + model._set("A2", "=INT(3.9)"); // 3 + model._set("A3", "=INT(5)"); // whole numbers unchanged + model._set("A4", "=INT(0)"); // zero + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"5"); + assert_eq!(model._get_text("A2"), *"3"); + assert_eq!(model._get_text("A3"), *"5"); + assert_eq!(model._get_text("A4"), *"0"); +} + +#[test] +fn int_negative_floor_behavior() { + let mut model = new_empty_model(); + // Critical: INT floors towards negative infinity, not towards zero + // This is different from truncation behavior + model._set("A1", "=INT(-5.7)"); // -6 (not -5) + model._set("A2", "=INT(-3.1)"); // -4 (not -3) + model._set("A3", "=INT(-0.9)"); // -1 (not 0) + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"-6"); + assert_eq!(model._get_text("A2"), *"-4"); + assert_eq!(model._get_text("A3"), *"-1"); +} diff --git a/base/src/test/test_lcm.rs b/base/src/test/test_lcm.rs new file mode 100644 index 00000000..ff1bc12f --- /dev/null +++ b/base/src/test/test_lcm.rs @@ -0,0 +1,82 @@ +#![allow(clippy::unwrap_used)] +use crate::test::util::new_empty_model; + +#[test] +fn test_fn_lcm_arguments() { + let mut model = new_empty_model(); + model._set("A1", "=LCM()"); + model.evaluate(); + assert_eq!(model._get_text("A1"), *"#ERROR!"); +} + +#[test] +fn test_fn_lcm_basic() { + let mut model = new_empty_model(); + model._set("A1", "=LCM(12)"); + model._set("A2", "=LCM(25,40)"); + model._set("A3", "=LCM(4,6,8)"); + model._set("A4", "=LCM(4.7,6.3)"); // Decimal truncation + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"12"); + assert_eq!(model._get_text("A2"), *"200"); + assert_eq!(model._get_text("A3"), *"24"); + assert_eq!(model._get_text("A4"), *"12"); +} + +#[test] +fn test_fn_lcm_zeros_and_edge_cases() { + let mut model = new_empty_model(); + model._set("A1", "=LCM(0)"); + model._set("A2", "=LCM(0,12)"); + model._set("A3", "=LCM(12,0)"); + model._set("A4", "=LCM(1,2,3,4,5)"); + model.evaluate(); + + // LCM with any zero = 0 + assert_eq!(model._get_text("A1"), *"0"); + assert_eq!(model._get_text("A2"), *"0"); + assert_eq!(model._get_text("A3"), *"0"); + assert_eq!(model._get_text("A4"), *"60"); +} + +#[test] +fn test_fn_lcm_error_cases() { + let mut model = new_empty_model(); + model._set("A1", "=LCM(-5)"); + model._set("A2", "=LCM(12,-8)"); + model._set("B1", "=1/0"); // Infinity + model._set("A3", "=LCM(B1)"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"#NUM!"); + assert_eq!(model._get_text("A2"), *"#NUM!"); + assert_eq!(model._get_text("A3"), *"#DIV/0!"); +} + +#[test] +fn test_fn_lcm_ranges() { + let mut model = new_empty_model(); + // Range with numbers + model._set("B1", "4"); + model._set("B2", "6"); + model._set("B3", "8"); + model._set("A1", "=LCM(B1:B3)"); + + // Range with mixed data (text ignored) + model._set("C1", "4"); + model._set("C2", "text"); + model._set("C3", "6"); + model._set("A2", "=LCM(C1:C3)"); + + // Zero in range + model._set("D1", "4"); + model._set("D2", "0"); + model._set("A3", "=LCM(D1:D2)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"24"); + assert_eq!(model._get_text("A2"), *"12"); + assert_eq!(model._get_text("A3"), *"0"); +} diff --git a/base/src/test/test_mround.rs b/base/src/test/test_mround.rs new file mode 100644 index 00000000..b4ad7493 --- /dev/null +++ b/base/src/test/test_mround.rs @@ -0,0 +1,86 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +#[test] +fn mround_wrong_argument_count() { + let mut model = new_empty_model(); + model._set("A1", "=MROUND()"); + model._set("A2", "=MROUND(10)"); + model._set("A3", "=MROUND(10, 3, 5)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"#ERROR!"); + assert_eq!(model._get_text("A2"), *"#ERROR!"); + assert_eq!(model._get_text("A3"), *"#ERROR!"); +} + +#[test] +fn mround_basic_rounding() { + let mut model = new_empty_model(); + model._set("A1", "=MROUND(10, 3)"); // 9 + model._set("A2", "=MROUND(11, 3)"); // 12 + model._set("A3", "=MROUND(1.3, 0.2)"); // 1.4 + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"9"); + assert_eq!(model._get_text("A2"), *"12"); + assert_eq!(model._get_text("A3"), *"1.4"); +} + +#[test] +fn mround_sign_validation() { + let mut model = new_empty_model(); + // Number and significance must have same sign + model._set("A1", "=MROUND(10, -3)"); // NUM error + model._set("A2", "=MROUND(-10, 3)"); // NUM error + model._set("A3", "=MROUND(-10, -3)"); // -9 + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"#NUM!"); + assert_eq!(model._get_text("A2"), *"#NUM!"); + assert_eq!(model._get_text("A3"), *"-9"); +} + +#[test] +fn mround_special_cases() { + let mut model = new_empty_model(); + model._set("A1", "=MROUND(10, 0)"); // 0 + model._set("A2", "=MROUND(0, 5)"); // 0 + model._set("A3", "=MROUND(2.5, 5)"); // 5 + // Zero number with any significance sign + model._set("A4", "=MROUND(0, -1)"); // 0 + model._set("A5", "=MROUND(0, -5)"); // 0 + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"0"); + assert_eq!(model._get_text("A2"), *"0"); + assert_eq!(model._get_text("A3"), *"5"); + assert_eq!(model._get_text("A4"), *"0"); + assert_eq!(model._get_text("A5"), *"0"); +} + +#[test] +fn mround_precision_edge_cases() { + let mut model = new_empty_model(); + // Floating-point precision at midpoints + model._set("A1", "=MROUND(1.5, 1)"); // 2 + model._set("A2", "=MROUND(-1.5, -1)"); // -2 + model._set("A3", "=MROUND(2.5, 1)"); // 3 + model._set("A4", "=MROUND(-2.5, -1)"); // -3 + model._set("A5", "=MROUND(0.15, 0.1)"); // 0.2 + model._set("A6", "=MROUND(-0.15, -0.1)"); // -0.2 + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"2"); + assert_eq!(model._get_text("A2"), *"-2"); + assert_eq!(model._get_text("A3"), *"3"); + assert_eq!(model._get_text("A4"), *"-3"); + assert_eq!(model._get_text("A5"), *"0.2"); + assert_eq!(model._get_text("A6"), *"-0.2"); +} diff --git a/docs/src/functions/math-and-trigonometry.md b/docs/src/functions/math-and-trigonometry.md index 03593584..a3deb2b7 100644 --- a/docs/src/functions/math-and-trigonometry.md +++ b/docs/src/functions/math-and-trigonometry.md @@ -24,7 +24,7 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | ATAN2 | | – | | ATANH | | – | | BASE | | – | -| CEILING | | – | +| CEILING | | [CEILING](math_and_trigonometry/ceiling) | | CEILING.MATH | | – | | CEILING.PRECISE | | – | | COMBIN | | – | @@ -36,35 +36,35 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | CSC | | – | | CSCH | | – | | DECIMAL | | – | -| DEGREES | | – | +| DEGREES | | [DEGREES](math_and_trigonometry/degrees) | | EVEN | | – | | EXP | | – | | FACT | | – | | FACTDOUBLE | | – | -| FLOOR | | – | +| FLOOR | | [FLOOR](math_and_trigonometry/floor) | | FLOOR.MATH | | – | | FLOOR.PRECISE | | – | -| GCD | | – | -| INT | | – | +| GCD | | [GCD](math_and_trigonometry/gcd) | +| INT | | [INT](math_and_trigonometry/int) | | ISO.CEILING | | – | -| LCM | | – | +| LCM | | [LCM](math_and_trigonometry/lcm) | | LET | | – | -| LN | | – | -| LOG | | – | -| LOG10 | | – | +| LN | | – | +| LOG | | – | +| LOG10 | | – | | MDETERM | | – | | MINVERSE | | – | | MMULT | | – | -| MOD | | – | -| MROUND | | – | +| MOD | | [MOD](math_and_trigonometry/mod) | +| MROUND | | [MROUND](math_and_trigonometry/mround) | | MULTINOMIAL | | – | | MUNIT | | – | | ODD | | – | -| PI | | – | +| PI | | [PI](math_and_trigonometry/pi) | | POWER | | – | | PRODUCT | | – | -| QUOTIENT | | – | -| RADIANS | | – | +| QUOTIENT | | [QUOTIENT](math_and_trigonometry/quotient) | +| RADIANS | | [RADIANS](math_and_trigonometry/radians) | | RAND | | – | | RANDARRAY | | – | | RANDBETWEEN | | – | @@ -80,11 +80,11 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | SIN | | [SIN](math_and_trigonometry/sin) | | SINH | | – | | SQRT | | – | -| SQRTPI | | – | -| SUBTOTAL | | – | +| SQRTPI | | – | +| SUBTOTAL | | – | | SUM | | – | | SUMIF | | – | -| SUMIFS | | – | +| SUMIFS | | [SUMIFS](math_and_trigonometry/sumifs) | | SUMPRODUCT | | – | | SUMSQ | | – | | SUMX2MY2 | | – | diff --git a/docs/src/functions/math_and_trigonometry/ceiling.md b/docs/src/functions/math_and_trigonometry/ceiling.md index e6640045..5ddc750c 100644 --- a/docs/src/functions/math_and_trigonometry/ceiling.md +++ b/docs/src/functions/math_and_trigonometry/ceiling.md @@ -7,6 +7,5 @@ lang: en-US # CEILING ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). ::: \ No newline at end of file diff --git a/docs/src/functions/math_and_trigonometry/degrees.md b/docs/src/functions/math_and_trigonometry/degrees.md index a82bcad5..d3a8dc22 100644 --- a/docs/src/functions/math_and_trigonometry/degrees.md +++ b/docs/src/functions/math_and_trigonometry/degrees.md @@ -7,6 +7,5 @@ lang: en-US # DEGREES ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). ::: \ No newline at end of file diff --git a/docs/src/functions/math_and_trigonometry/floor.md b/docs/src/functions/math_and_trigonometry/floor.md index 6e49e257..e5c739cd 100644 --- a/docs/src/functions/math_and_trigonometry/floor.md +++ b/docs/src/functions/math_and_trigonometry/floor.md @@ -7,6 +7,5 @@ lang: en-US # FLOOR ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). ::: \ No newline at end of file diff --git a/docs/src/functions/math_and_trigonometry/gcd.md b/docs/src/functions/math_and_trigonometry/gcd.md index f3fee3ad..5bd08b98 100644 --- a/docs/src/functions/math_and_trigonometry/gcd.md +++ b/docs/src/functions/math_and_trigonometry/gcd.md @@ -7,6 +7,5 @@ lang: en-US # GCD ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). ::: \ No newline at end of file diff --git a/docs/src/functions/math_and_trigonometry/int.md b/docs/src/functions/math_and_trigonometry/int.md index a4e46932..916eb019 100644 --- a/docs/src/functions/math_and_trigonometry/int.md +++ b/docs/src/functions/math_and_trigonometry/int.md @@ -7,6 +7,5 @@ lang: en-US # INT ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/int-function-881735da-3799-4681-8618-92b5c4f46c4a). ::: \ No newline at end of file diff --git a/docs/src/functions/math_and_trigonometry/lcm.md b/docs/src/functions/math_and_trigonometry/lcm.md index dace2183..50918cfb 100644 --- a/docs/src/functions/math_and_trigonometry/lcm.md +++ b/docs/src/functions/math_and_trigonometry/lcm.md @@ -7,6 +7,5 @@ lang: en-US # LCM ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). ::: \ No newline at end of file diff --git a/docs/src/functions/math_and_trigonometry/mod.md b/docs/src/functions/math_and_trigonometry/mod.md index 9f4eeccc..1025a2f4 100644 --- a/docs/src/functions/math_and_trigonometry/mod.md +++ b/docs/src/functions/math_and_trigonometry/mod.md @@ -7,6 +7,5 @@ lang: en-US # MOD ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). ::: \ No newline at end of file diff --git a/docs/src/functions/math_and_trigonometry/mround.md b/docs/src/functions/math_and_trigonometry/mround.md index ca02f76c..2c8e09db 100644 --- a/docs/src/functions/math_and_trigonometry/mround.md +++ b/docs/src/functions/math_and_trigonometry/mround.md @@ -7,6 +7,5 @@ lang: en-US # MROUND ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/mround-function-c299c2b1-15ed-4261-8a0f-4b0a023685b8). ::: \ No newline at end of file diff --git a/docs/src/functions/math_and_trigonometry/quotient.md b/docs/src/functions/math_and_trigonometry/quotient.md index d0baef91..fbfe6edf 100644 --- a/docs/src/functions/math_and_trigonometry/quotient.md +++ b/docs/src/functions/math_and_trigonometry/quotient.md @@ -7,6 +7,5 @@ lang: en-US # QUOTIENT ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). ::: \ No newline at end of file diff --git a/docs/src/functions/math_and_trigonometry/radians.md b/docs/src/functions/math_and_trigonometry/radians.md index 17964557..31abf742 100644 --- a/docs/src/functions/math_and_trigonometry/radians.md +++ b/docs/src/functions/math_and_trigonometry/radians.md @@ -7,6 +7,5 @@ lang: en-US # RADIANS ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). ::: \ No newline at end of file diff --git a/docs/src/functions/math_and_trigonometry/sumproduct.md b/docs/src/functions/math_and_trigonometry/sumproduct.md index 21866e9d..61937dd2 100644 --- a/docs/src/functions/math_and_trigonometry/sumproduct.md +++ b/docs/src/functions/math_and_trigonometry/sumproduct.md @@ -7,6 +7,5 @@ lang: en-US # SUMPRODUCT ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). ::: \ No newline at end of file diff --git a/xlsx/tests/calc_tests/INT.xlsx b/xlsx/tests/calc_tests/INT.xlsx new file mode 100644 index 00000000..d6d1946c Binary files /dev/null and b/xlsx/tests/calc_tests/INT.xlsx differ diff --git a/xlsx/tests/calc_tests/MROUND.xlsx b/xlsx/tests/calc_tests/MROUND.xlsx new file mode 100644 index 00000000..47088fbf Binary files /dev/null and b/xlsx/tests/calc_tests/MROUND.xlsx differ