diff --git a/base/src/expressions/parser/static_analysis.rs b/base/src/expressions/parser/static_analysis.rs index 280ac2484..192606b32 100644 --- a/base/src/expressions/parser/static_analysis.rs +++ b/base/src/expressions/parser/static_analysis.rs @@ -612,6 +612,8 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec 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::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), @@ -813,6 +815,8 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { 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::Round => scalar_arguments(args), Function::Rounddown => scalar_arguments(args), Function::Roundup => scalar_arguments(args), diff --git a/base/src/functions/mathematical.rs b/base/src/functions/mathematical.rs index 82f4b8b45..4b8366be7 100644 --- a/base/src/functions/mathematical.rs +++ b/base/src/functions/mathematical.rs @@ -8,6 +8,21 @@ use crate::{ }; use std::f64::consts::PI; +/// 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() @@ -305,77 +320,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 digits_raw = match self.get_number(&args[1], cell) { + 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() + + // 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_sin, |f| Ok(f64::sin(f))); diff --git a/base/src/functions/mod.rs b/base/src/functions/mod.rs index 45da02525..74b2af6bb 100644 --- a/base/src/functions/mod.rs +++ b/base/src/functions/mod.rs @@ -61,6 +61,8 @@ pub enum Function { Product, Rand, Randbetween, + Ceiling, + Floor, Round, Rounddown, Roundup, @@ -250,7 +252,7 @@ pub enum Function { } impl Function { - pub fn into_iter() -> IntoIter { + pub fn into_iter() -> IntoIter { [ Function::And, Function::False, @@ -286,6 +288,8 @@ impl Function { Function::Product, Function::Rand, Function::Randbetween, + Function::Ceiling, + Function::Floor, Function::Round, Function::Rounddown, Function::Roundup, @@ -539,6 +543,8 @@ impl Function { "PRODUCT" => Some(Function::Product), "RAND" => Some(Function::Rand), "RANDBETWEEN" => Some(Function::Randbetween), + "CEILING" => Some(Function::Ceiling), + "FLOOR" => Some(Function::Floor), "ROUND" => Some(Function::Round), "ROUNDDOWN" => Some(Function::Rounddown), "ROUNDUP" => Some(Function::Roundup), @@ -757,6 +763,8 @@ impl fmt::Display for Function { Function::Product => write!(f, "PRODUCT"), Function::Rand => write!(f, "RAND"), Function::Randbetween => write!(f, "RANDBETWEEN"), + Function::Ceiling => write!(f, "CEILING"), + Function::Floor => write!(f, "FLOOR"), Function::Round => write!(f, "ROUND"), Function::Rounddown => write!(f, "ROUNDDOWN"), Function::Roundup => write!(f, "ROUNDUP"), @@ -990,6 +998,8 @@ impl Model { Function::Product => self.fn_product(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::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 8e1b4ebe1..b0ba354aa 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; 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 000000000..ca0d876b6 --- /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/docs/src/functions/math-and-trigonometry.md b/docs/src/functions/math-and-trigonometry.md index 03593584f..33d6d2f63 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 | | – | @@ -41,7 +41,7 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | EXP | | – | | FACT | | – | | FACTDOUBLE | | – | -| FLOOR | | – | +| FLOOR | | [FLOOR](math_and_trigonometry/floor) | | FLOOR.MATH | | – | | FLOOR.PRECISE | | – | | GCD | | – | diff --git a/docs/src/functions/math_and_trigonometry/ceiling.md b/docs/src/functions/math_and_trigonometry/ceiling.md index e6640045b..5ddc750cb 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/floor.md b/docs/src/functions/math_and_trigonometry/floor.md index 6e49e2571..e5c739cdb 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