From 4b4cd1963f992986a0ce881bb8dbbdc199a9931d Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Sun, 13 Jul 2025 01:04:30 -0700 Subject: [PATCH 1/6] Add SLOPE and INTERCEPT functions --- .../src/expressions/parser/static_analysis.rs | 2 + base/src/functions/mod.rs | 12 +- base/src/functions/statistical.rs | 197 ++++++++++++++++++ base/src/test/mod.rs | 1 + base/src/test/test_fn_slope_intercept.rs | 29 +++ docs/src/functions/statistical.md | 4 +- docs/src/functions/statistical/intercept.md | 3 +- docs/src/functions/statistical/slope.md | 3 +- 8 files changed, 244 insertions(+), 7 deletions(-) create mode 100644 base/src/test/test_fn_slope_intercept.rs diff --git a/base/src/expressions/parser/static_analysis.rs b/base/src/expressions/parser/static_analysis.rs index 280ac2484..80c3f1211 100644 --- a/base/src/expressions/parser/static_analysis.rs +++ b/base/src/expressions/parser/static_analysis.rs @@ -778,6 +778,7 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec args_signature_scalars(arg_count, 1, 0), Function::Unicode => args_signature_scalars(arg_count, 1, 0), Function::Geomean => vec![Signature::Vector; arg_count], + Function::Intercept | Function::Slope => vec![Signature::Vector; arg_count], } } @@ -980,5 +981,6 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::Eomonth => scalar_arguments(args), Function::Formulatext => not_implemented(args), Function::Geomean => not_implemented(args), + Function::Intercept | Function::Slope => scalar_arguments(args), } } diff --git a/base/src/functions/mod.rs b/base/src/functions/mod.rs index 45da02525..28807c144 100644 --- a/base/src/functions/mod.rs +++ b/base/src/functions/mod.rs @@ -142,6 +142,8 @@ pub enum Function { Maxifs, Minifs, Geomean, + Intercept, + Slope, // Date and time Date, @@ -250,7 +252,7 @@ pub enum Function { } impl Function { - pub fn into_iter() -> IntoIter { + pub fn into_iter() -> IntoIter { [ Function::And, Function::False, @@ -351,6 +353,8 @@ impl Function { Function::Maxifs, Function::Minifs, Function::Geomean, + Function::Intercept, + Function::Slope, Function::Year, Function::Day, Function::Month, @@ -615,6 +619,8 @@ impl Function { "MAXIFS" | "_XLFN.MAXIFS" => Some(Function::Maxifs), "MINIFS" | "_XLFN.MINIFS" => Some(Function::Minifs), "GEOMEAN" => Some(Function::Geomean), + "INTERCEPT" => Some(Function::Intercept), + "SLOPE" => Some(Function::Slope), // Date and Time "YEAR" => Some(Function::Year), "DAY" => Some(Function::Day), @@ -823,6 +829,8 @@ impl fmt::Display for Function { Function::Maxifs => write!(f, "MAXIFS"), Function::Minifs => write!(f, "MINIFS"), Function::Geomean => write!(f, "GEOMEAN"), + Function::Intercept => write!(f, "INTERCEPT"), + Function::Slope => write!(f, "SLOPE"), Function::Year => write!(f, "YEAR"), Function::Day => write!(f, "DAY"), Function::Month => write!(f, "MONTH"), @@ -1060,6 +1068,8 @@ impl Model { Function::Maxifs => self.fn_maxifs(args, cell), Function::Minifs => self.fn_minifs(args, cell), Function::Geomean => self.fn_geomean(args, cell), + Function::Intercept => self.fn_intercept(args, cell), + Function::Slope => self.fn_slope(args, cell), // Date and Time Function::Year => self.fn_year(args, cell), Function::Day => self.fn_day(args, cell), diff --git a/base/src/functions/statistical.rs b/base/src/functions/statistical.rs index cdb936406..d93d3d15d 100644 --- a/base/src/functions/statistical.rs +++ b/base/src/functions/statistical.rs @@ -1,4 +1,5 @@ use crate::constants::{LAST_COLUMN, LAST_ROW}; +use crate::expressions::parser::ArrayNode; use crate::expressions::types::CellReferenceIndex; use crate::{ calc_result::{CalcResult, Range}, @@ -730,4 +731,200 @@ impl Model { } CalcResult::Number(product.powf(1.0 / count)) } + + fn collect_series( + &mut self, + node: &Node, + cell: CellReferenceIndex, + ) -> Result>, CalcResult> { + let is_reference = matches!( + node, + Node::ReferenceKind { .. } | Node::RangeKind { .. } | Node::OpRangeKind { .. } + ); + match self.evaluate_node_in_context(node, cell) { + CalcResult::Number(v) => Ok(vec![Some(v)]), + CalcResult::Boolean(b) => { + if is_reference { + Ok(vec![None]) + } else { + Ok(vec![Some(if b { 1.0 } else { 0.0 })]) + } + } + CalcResult::String(s) => { + if is_reference { + Ok(vec![None]) + } else if let Ok(v) = s.parse::() { + Ok(vec![Some(v)]) + } else { + Err(CalcResult::new_error( + Error::VALUE, + cell, + "Argument cannot be cast into number".to_string(), + )) + } + } + CalcResult::Range { left, right } => { + if left.sheet != right.sheet { + return Err(CalcResult::new_error( + Error::VALUE, + cell, + "Ranges are in different sheets".to_string(), + )); + } + let mut values = Vec::new(); + 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(n) => values.push(Some(n)), + CalcResult::Error { .. } => { + return Err(self.evaluate_cell(CellReferenceIndex { + sheet: left.sheet, + row, + column, + })); + } + _ => values.push(None), + } + } + } + Ok(values) + } + CalcResult::Array(arr) => { + let mut values = Vec::new(); + for row in arr { + for val in row { + match val { + ArrayNode::Number(n) => values.push(Some(n)), + ArrayNode::Boolean(b) => values.push(Some(if b { 1.0 } else { 0.0 })), + ArrayNode::String(s) => match s.parse::() { + Ok(v) => values.push(Some(v)), + Err(_) => { + return Err(CalcResult::new_error( + Error::VALUE, + cell, + "Argument cannot be cast into number".to_string(), + )) + } + }, + ArrayNode::Error(e) => { + return Err(CalcResult::Error { + error: e, + origin: cell, + message: "Error in array".to_string(), + }) + } + } + } + } + Ok(values) + } + CalcResult::EmptyCell | CalcResult::EmptyArg => Ok(vec![None]), + error @ CalcResult::Error { .. } => Err(error), + } + } + + pub(crate) fn fn_slope(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() != 2 { + return CalcResult::new_args_number_error(cell); + } + let ys = match self.collect_series(&args[0], cell) { + Ok(v) => v, + Err(e) => return e, + }; + let xs = match self.collect_series(&args[1], cell) { + Ok(v) => v, + Err(e) => return e, + }; + if ys.len() != xs.len() { + return CalcResult::new_error( + Error::NA, + cell, + "Ranges have different lengths".to_string(), + ); + } + let mut pairs = Vec::new(); + let mut sum_x = 0.0; + let mut sum_y = 0.0; + let mut n = 0.0; + for (y, x) in ys.iter().zip(xs.iter()) { + if let (Some(yy), Some(xx)) = (y, x) { + pairs.push((*yy, *xx)); + sum_x += xx; + sum_y += yy; + n += 1.0; + } + } + if n == 0.0 { + return CalcResult::new_error(Error::DIV, cell, "Division by Zero".to_string()); + } + let mean_x = sum_x / n; + let mean_y = sum_y / n; + let mut numerator = 0.0; + let mut denominator = 0.0; + for (yy, xx) in pairs { + let dx = xx - mean_x; + let dy = yy - mean_y; + numerator += dx * dy; + denominator += dx * dx; + } + if denominator == 0.0 { + return CalcResult::new_error(Error::DIV, cell, "Division by Zero".to_string()); + } + CalcResult::Number(numerator / denominator) + } + + pub(crate) fn fn_intercept(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() != 2 { + return CalcResult::new_args_number_error(cell); + } + let ys = match self.collect_series(&args[0], cell) { + Ok(v) => v, + Err(e) => return e, + }; + let xs = match self.collect_series(&args[1], cell) { + Ok(v) => v, + Err(e) => return e, + }; + if ys.len() != xs.len() { + return CalcResult::new_error( + Error::NA, + cell, + "Ranges have different lengths".to_string(), + ); + } + let mut pairs = Vec::new(); + let mut sum_x = 0.0; + let mut sum_y = 0.0; + let mut n = 0.0; + for (y, x) in ys.iter().zip(xs.iter()) { + if let (Some(yy), Some(xx)) = (y, x) { + pairs.push((*yy, *xx)); + sum_x += xx; + sum_y += yy; + n += 1.0; + } + } + if n == 0.0 { + return CalcResult::new_error(Error::DIV, cell, "Division by Zero".to_string()); + } + let mean_x = sum_x / n; + let mean_y = sum_y / n; + let mut numerator = 0.0; + let mut denominator = 0.0; + for (yy, xx) in pairs { + let dx = xx - mean_x; + let dy = yy - mean_y; + numerator += dx * dy; + denominator += dx * dx; + } + if denominator == 0.0 { + return CalcResult::new_error(Error::DIV, cell, "Division by Zero".to_string()); + } + let slope = numerator / denominator; + CalcResult::Number(mean_y - slope * mean_x) + } } diff --git a/base/src/test/mod.rs b/base/src/test/mod.rs index 8e1b4ebe1..99d30c0ad 100644 --- a/base/src/test/mod.rs +++ b/base/src/test/mod.rs @@ -55,6 +55,7 @@ mod test_arrays; mod test_escape_quotes; mod test_extend; mod test_fn_fv; +mod test_fn_slope_intercept; mod test_fn_type; mod test_frozen_rows_and_columns; mod test_geomean; diff --git a/base/src/test/test_fn_slope_intercept.rs b/base/src/test/test_fn_slope_intercept.rs new file mode 100644 index 000000000..928ac03ee --- /dev/null +++ b/base/src/test/test_fn_slope_intercept.rs @@ -0,0 +1,29 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +#[test] +fn test_fn_slope_intercept_arguments() { + let mut model = new_empty_model(); + model._set("A1", "=SLOPE()"); + model._set("A2", "=INTERCEPT(B1:B3)"); + model.evaluate(); + assert_eq!(model._get_text("A1"), *"#ERROR!"); + assert_eq!(model._get_text("A2"), *"#ERROR!"); +} + +#[test] +fn test_fn_slope_intercept_minimal() { + let mut model = new_empty_model(); + model._set("B1", "1"); + model._set("B2", "2"); + model._set("B3", "3"); + model._set("C1", "1"); + model._set("C2", "2"); + model._set("C3", "5"); + model._set("A1", "=SLOPE(B1:B3,C1:C3)"); + model._set("A2", "=INTERCEPT(B1:B3,C1:C3)"); + model.evaluate(); + assert_eq!(model._get_text("A1"), *"0.461538462"); + assert_eq!(model._get_text("A2"), *"0.769230769"); +} diff --git a/docs/src/functions/statistical.md b/docs/src/functions/statistical.md index 6842212c3..df463266f 100644 --- a/docs/src/functions/statistical.md +++ b/docs/src/functions/statistical.md @@ -62,7 +62,7 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | GROWTH | | – | | HARMEAN | | – | | HYPGEOM.DIST | | – | -| INTERCEPT | | – | +| INTERCEPT | | – | | KURT | | – | | LARGE | | – | | LINEST | | – | @@ -97,7 +97,7 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | RSQ | | – | | SKEW | | – | | SKEW.P | | – | -| SLOPE | | – | +| SLOPE | | – | | SMALL | | – | | STANDARDIZE | | – | | STDEV.P | | – | diff --git a/docs/src/functions/statistical/intercept.md b/docs/src/functions/statistical/intercept.md index 4d8208842..280583b2d 100644 --- a/docs/src/functions/statistical/intercept.md +++ b/docs/src/functions/statistical/intercept.md @@ -7,6 +7,5 @@ lang: en-US # INTERCEPT ::: 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/statistical/slope.md b/docs/src/functions/statistical/slope.md index fc6d59db3..185314ef9 100644 --- a/docs/src/functions/statistical/slope.md +++ b/docs/src/functions/statistical/slope.md @@ -7,6 +7,5 @@ lang: en-US # SLOPE ::: 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 From b8a5a146be7232cdba5f964e49f677acedef9ff8 Mon Sep 17 00:00:00 2001 From: BrianHung Date: Sun, 13 Jul 2025 23:44:28 -0700 Subject: [PATCH 2/6] extract collect_series into util --- base/src/functions/statistical.rs | 105 ++------------------------- base/src/functions/util.rs | 113 ++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 99 deletions(-) diff --git a/base/src/functions/statistical.rs b/base/src/functions/statistical.rs index d93d3d15d..7e04c8a5e 100644 --- a/base/src/functions/statistical.rs +++ b/base/src/functions/statistical.rs @@ -8,7 +8,7 @@ use crate::{ model::Model, }; -use super::util::build_criteria; +use super::util::{build_criteria, collect_series}; impl Model { pub(crate) fn fn_average(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { @@ -732,110 +732,17 @@ impl Model { CalcResult::Number(product.powf(1.0 / count)) } - fn collect_series( - &mut self, - node: &Node, - cell: CellReferenceIndex, - ) -> Result>, CalcResult> { - let is_reference = matches!( - node, - Node::ReferenceKind { .. } | Node::RangeKind { .. } | Node::OpRangeKind { .. } - ); - match self.evaluate_node_in_context(node, cell) { - CalcResult::Number(v) => Ok(vec![Some(v)]), - CalcResult::Boolean(b) => { - if is_reference { - Ok(vec![None]) - } else { - Ok(vec![Some(if b { 1.0 } else { 0.0 })]) - } - } - CalcResult::String(s) => { - if is_reference { - Ok(vec![None]) - } else if let Ok(v) = s.parse::() { - Ok(vec![Some(v)]) - } else { - Err(CalcResult::new_error( - Error::VALUE, - cell, - "Argument cannot be cast into number".to_string(), - )) - } - } - CalcResult::Range { left, right } => { - if left.sheet != right.sheet { - return Err(CalcResult::new_error( - Error::VALUE, - cell, - "Ranges are in different sheets".to_string(), - )); - } - let mut values = Vec::new(); - 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(n) => values.push(Some(n)), - CalcResult::Error { .. } => { - return Err(self.evaluate_cell(CellReferenceIndex { - sheet: left.sheet, - row, - column, - })); - } - _ => values.push(None), - } - } - } - Ok(values) - } - CalcResult::Array(arr) => { - let mut values = Vec::new(); - for row in arr { - for val in row { - match val { - ArrayNode::Number(n) => values.push(Some(n)), - ArrayNode::Boolean(b) => values.push(Some(if b { 1.0 } else { 0.0 })), - ArrayNode::String(s) => match s.parse::() { - Ok(v) => values.push(Some(v)), - Err(_) => { - return Err(CalcResult::new_error( - Error::VALUE, - cell, - "Argument cannot be cast into number".to_string(), - )) - } - }, - ArrayNode::Error(e) => { - return Err(CalcResult::Error { - error: e, - origin: cell, - message: "Error in array".to_string(), - }) - } - } - } - } - Ok(values) - } - CalcResult::EmptyCell | CalcResult::EmptyArg => Ok(vec![None]), - error @ CalcResult::Error { .. } => Err(error), - } - } + // collect_series method moved to functions::util::collect_series pub(crate) fn fn_slope(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { if args.len() != 2 { return CalcResult::new_args_number_error(cell); } - let ys = match self.collect_series(&args[0], cell) { + let ys = match collect_series(self, &args[0], cell) { Ok(v) => v, Err(e) => return e, }; - let xs = match self.collect_series(&args[1], cell) { + let xs = match collect_series(self, &args[1], cell) { Ok(v) => v, Err(e) => return e, }; @@ -881,11 +788,11 @@ impl Model { if args.len() != 2 { return CalcResult::new_args_number_error(cell); } - let ys = match self.collect_series(&args[0], cell) { + let ys = match collect_series(self, &args[0], cell) { Ok(v) => v, Err(e) => return e, }; - let xs = match self.collect_series(&args[1], cell) { + let xs = match collect_series(self, &args[1], cell) { Ok(v) => v, Err(e) => return e, }; diff --git a/base/src/functions/util.rs b/base/src/functions/util.rs index dea96e843..6e13a7429 100644 --- a/base/src/functions/util.rs +++ b/base/src/functions/util.rs @@ -1,6 +1,10 @@ #[cfg(feature = "use_regex_lite")] use regex_lite as regex; +use crate::expressions::parser::{ArrayNode, Node}; +use crate::expressions::token::Error; +use crate::expressions::types::CellReferenceIndex; +use crate::model::Model; use crate::{calc_result::CalcResult, expressions::token::is_english_error_string}; /// This test for exact match (modulo case). @@ -398,3 +402,112 @@ pub(crate) fn build_criteria<'a>(value: &'a CalcResult) -> Box Box::new(result_is_equal_to_empty), } } + +/// Collect a numeric series preserving positional information. +/// +/// Given a single argument (range, reference, literal, or array), returns a +/// vector with the same length as the flattened input. Each position contains +/// `Some(f64)` when the corresponding element is numeric and `None` when it is +/// non-numeric or empty. Errors are propagated immediately. +/// +/// Behaviour mirrors Excel's rules used by paired-data statistical functions +/// (SLOPE, INTERCEPT, CORREL, etc.): +/// - Booleans/string literals are coerced to numbers, literals coming from +/// references are ignored. +/// - Non-numeric cells become `None`, keeping the alignment between two series. +/// - Ranges crossing sheets cause a `#VALUE!` error. +pub(crate) fn collect_series( + model: &mut Model, + node: &Node, + cell: CellReferenceIndex, +) -> Result>, CalcResult> { + let is_reference = matches!( + node, + Node::ReferenceKind { .. } | Node::RangeKind { .. } | Node::OpRangeKind { .. } + ); + + match model.evaluate_node_in_context(node, cell) { + CalcResult::Number(v) => Ok(vec![Some(v)]), + CalcResult::Boolean(b) => { + if is_reference { + Ok(vec![None]) + } else { + Ok(vec![Some(if b { 1.0 } else { 0.0 })]) + } + } + CalcResult::String(s) => { + if is_reference { + Ok(vec![None]) + } else if let Ok(v) = s.parse::() { + Ok(vec![Some(v)]) + } else { + Err(CalcResult::new_error( + Error::VALUE, + cell, + "Argument cannot be cast into number".to_string(), + )) + } + } + CalcResult::Range { left, right } => { + if left.sheet != right.sheet { + return Err(CalcResult::new_error( + Error::VALUE, + cell, + "Ranges are in different sheets".to_string(), + )); + } + let mut values = Vec::new(); + for row in left.row..=right.row { + for column in left.column..=right.column { + match model.evaluate_cell(CellReferenceIndex { + sheet: left.sheet, + row, + column, + }) { + CalcResult::Number(n) => values.push(Some(n)), + CalcResult::Error { .. } => { + return Err(model.evaluate_cell(CellReferenceIndex { + sheet: left.sheet, + row, + column, + })); + } + _ => values.push(None), + } + } + } + Ok(values) + } + CalcResult::Array(arr) => { + let mut values = Vec::new(); + for row in arr { + for val in row { + match val { + ArrayNode::Number(n) => values.push(Some(n)), + ArrayNode::Boolean(b) => values.push(Some(if b { 1.0 } else { 0.0 })), + ArrayNode::String(s) => match s.parse::() { + Ok(v) => values.push(Some(v)), + Err(_) => { + return Err(CalcResult::new_error( + Error::VALUE, + cell, + "Argument cannot be cast into number".to_string(), + )) + } + }, + ArrayNode::Error(e) => { + return Err(CalcResult::Error { + error: e, + origin: cell, + message: "Error in array".to_string(), + }) + } + } + } + } + Ok(values) + } + CalcResult::EmptyCell | CalcResult::EmptyArg => Ok(vec![None]), + error @ CalcResult::Error { .. } => Err(error), + } +} From e2bf5c5df49617935f95d79f064447bfa4237406 Mon Sep 17 00:00:00 2001 From: BrianHung Date: Sun, 13 Jul 2025 23:55:58 -0700 Subject: [PATCH 3/6] fix lint --- base/src/functions/statistical.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/base/src/functions/statistical.rs b/base/src/functions/statistical.rs index 7e04c8a5e..311cf3d3d 100644 --- a/base/src/functions/statistical.rs +++ b/base/src/functions/statistical.rs @@ -1,5 +1,4 @@ use crate::constants::{LAST_COLUMN, LAST_ROW}; -use crate::expressions::parser::ArrayNode; use crate::expressions::types::CellReferenceIndex; use crate::{ calc_result::{CalcResult, Range}, From cec3aed263736b037f26b4dc355e74f50b733542 Mon Sep 17 00:00:00 2001 From: BrianHung Date: Sun, 20 Jul 2025 20:45:03 -0700 Subject: [PATCH 4/6] increase test coverage --- base/src/test/test_fn_slope_intercept.rs | 357 ++++++++++++++++++++++- 1 file changed, 348 insertions(+), 9 deletions(-) diff --git a/base/src/test/test_fn_slope_intercept.rs b/base/src/test/test_fn_slope_intercept.rs index 928ac03ee..b6c70cb60 100644 --- a/base/src/test/test_fn_slope_intercept.rs +++ b/base/src/test/test_fn_slope_intercept.rs @@ -2,28 +2,367 @@ use crate::test::util::new_empty_model; +// ============================================================================= +// TEST CONSTANTS +// ============================================================================= + +const EXACT_TOLERANCE: f64 = 1e-10; +const STANDARD_TOLERANCE: f64 = 1e-9; +const HIGH_PRECISION_TOLERANCE: f64 = 1e-15; +const STABILITY_TOLERANCE: f64 = 1e-6; + +// ============================================================================= +// TEST HELPER FUNCTIONS +// ============================================================================= + +fn assert_approx_eq(actual: &str, expected: f64, tolerance: f64) { + let actual_val: f64 = actual + .parse() + .unwrap_or_else(|_| panic!("Failed to parse result as number: {actual}")); + assert!( + (actual_val - expected).abs() < tolerance, + "Expected ~{expected}, got {actual}" + ); +} + +fn assert_slope_intercept_eq( + model: &crate::Model, + slope_cell: &str, + intercept_cell: &str, + expected_slope: f64, + expected_intercept: f64, + tolerance: f64, +) { + assert_approx_eq(&model._get_text(slope_cell), expected_slope, tolerance); + assert_approx_eq( + &model._get_text(intercept_cell), + expected_intercept, + tolerance, + ); +} + +fn assert_slope_intercept_error( + model: &crate::Model, + slope_cell: &str, + intercept_cell: &str, + expected_error: &str, +) { + assert_eq!(model._get_text(slope_cell), *expected_error); + assert_eq!(model._get_text(intercept_cell), *expected_error); +} + +fn set_linear_data(model: &mut crate::Model, slope: f64, intercept: f64, x_values: &[f64]) { + for (i, &x) in x_values.iter().enumerate() { + let y = slope * x + intercept; + model._set(&format!("B{}", i + 1), &y.to_string()); + model._set(&format!("C{}", i + 1), &x.to_string()); + } +} + +// ============================================================================= +// ARGUMENT VALIDATION TESTS +// ============================================================================= + #[test] -fn test_fn_slope_intercept_arguments() { +fn test_slope_intercept_invalid_args() { let mut model = new_empty_model(); + + // Wrong argument counts model._set("A1", "=SLOPE()"); - model._set("A2", "=INTERCEPT(B1:B3)"); + model._set("A2", "=SLOPE(B1:B3)"); + model._set("A3", "=INTERCEPT()"); + model._set("A4", "=INTERCEPT(B1:B3)"); + model._set("A5", "=SLOPE(B1:B3, C1:C3, D1:D3)"); + model._set("A6", "=INTERCEPT(B1:B3, C1:C3, D1:D3)"); + + // Mismatched range sizes + model._set("B1", "1"); + model._set("B2", "2"); + model._set("B3", "3"); + model._set("C1", "10"); + model._set("C2", "20"); + model._set("A7", "=SLOPE(B1:B3, C1:C2)"); + model._set("A8", "=INTERCEPT(B1:B3, C1:C2)"); + + // Direct invalid types + model._set("A9", "=SLOPE(1;TRUE;3, 2;FALSE;6)"); + model._set("A10", "=INTERCEPT(\"1\";\"2\";\"3\", \"2\";\"4\";\"6\")"); + + model.evaluate(); + + // All should error appropriately + for cell in ["A1", "A2", "A3", "A4", "A5", "A6", "A9", "A10"] { + assert_eq!(model._get_text(cell), "#ERROR!"); + } + assert_slope_intercept_error(&model, "A7", "A8", "#N/A"); +} + +// ============================================================================= +// CORE MATHEMATICAL FUNCTIONALITY TESTS +// ============================================================================= + +#[test] +fn test_slope_intercept_perfect_lines() { + let mut model = new_empty_model(); + + // Test 1: Positive slope through origin (y = 3x) + set_linear_data(&mut model, 3.0, 0.0, &[1.0, 2.0, 3.0, 4.0]); + model._set("A1", "=SLOPE(B1:B4, C1:C4)"); + model._set("A2", "=INTERCEPT(B1:B4, C1:C4)"); + + // Test 2: Negative slope with intercept (y = -2x + 10) + model._set("B5", "8"); + model._set("B6", "6"); + model._set("B7", "4"); + model._set("B8", "2"); + model._set("C5", "1"); + model._set("C6", "2"); + model._set("C7", "3"); + model._set("C8", "4"); + model._set("A3", "=SLOPE(B5:B8, C5:C8)"); + model._set("A4", "=INTERCEPT(B5:B8, C5:C8)"); + + // Test 3: Zero slope (y = 7) + model._set("B9", "7"); + model._set("B10", "7"); + model._set("B11", "7"); + model._set("C9", "10"); + model._set("C10", "20"); + model._set("C11", "30"); + model._set("A5", "=SLOPE(B9:B11, C9:C11)"); + model._set("A6", "=INTERCEPT(B9:B11, C9:C11)"); + model.evaluate(); - assert_eq!(model._get_text("A1"), *"#ERROR!"); - assert_eq!(model._get_text("A2"), *"#ERROR!"); + + assert_slope_intercept_eq(&model, "A1", "A2", 3.0, 0.0, EXACT_TOLERANCE); + assert_slope_intercept_eq(&model, "A3", "A4", -2.0, 10.0, EXACT_TOLERANCE); + assert_slope_intercept_eq(&model, "A5", "A6", 0.0, 7.0, EXACT_TOLERANCE); } #[test] -fn test_fn_slope_intercept_minimal() { +fn test_slope_intercept_regression() { let mut model = new_empty_model(); + + // Non-perfect data: (1,1), (2,2), (5,3) + // Manual calculation: slope = 6/13, intercept = 10/13 model._set("B1", "1"); model._set("B2", "2"); model._set("B3", "3"); model._set("C1", "1"); model._set("C2", "2"); model._set("C3", "5"); - model._set("A1", "=SLOPE(B1:B3,C1:C3)"); - model._set("A2", "=INTERCEPT(B1:B3,C1:C3)"); + model._set("A1", "=SLOPE(B1:B3, C1:C3)"); + model._set("A2", "=INTERCEPT(B1:B3, C1:C3)"); + + model.evaluate(); + + assert_slope_intercept_eq( + &model, + "A1", + "A2", + 0.461538462, + 0.769230769, + STANDARD_TOLERANCE, + ); +} + +// ============================================================================= +// DEGENERATE CASES +// ============================================================================= + +#[test] +fn test_slope_intercept_insufficient_data() { + let mut model = new_empty_model(); + + // Single data point + model._set("B1", "5"); + model._set("C1", "10"); + model._set("A1", "=SLOPE(B1, C1)"); + model._set("A2", "=INTERCEPT(B1, C1)"); + + // Empty ranges + model._set("A3", "=SLOPE(B5:B7, C5:C7)"); + model._set("A4", "=INTERCEPT(B5:B7, C5:C7)"); + + // Identical x values (vertical line) + model._set("B8", "1"); + model._set("B9", "2"); + model._set("B10", "3"); + model._set("C8", "5"); + model._set("C9", "5"); + model._set("C10", "5"); + model._set("A5", "=SLOPE(B8:B10, C8:C10)"); + model._set("A6", "=INTERCEPT(B8:B10, C8:C10)"); + + model.evaluate(); + + assert_slope_intercept_error(&model, "A1", "A2", "#DIV/0!"); + assert_slope_intercept_error(&model, "A3", "A4", "#DIV/0!"); + assert_slope_intercept_error(&model, "A5", "A6", "#DIV/0!"); +} + +// ============================================================================= +// DATA FILTERING AND ERROR PROPAGATION +// ============================================================================= + +#[test] +fn test_slope_intercept_data_filtering() { + let mut model = new_empty_model(); + + // Mixed data types - only numeric pairs used: (1,1), (5,2), (9,3) -> y = 4x - 3 + model._set("B1", "1"); // Valid + model._set("B2", ""); // Empty - ignored + model._set("B3", "text"); // Text - ignored + model._set("B4", "5"); // Valid + model._set("B5", "TRUE"); // Boolean - ignored + model._set("B6", "9"); // Valid + model._set("C1", "1"); // Valid + model._set("C2", ""); // Empty - ignored + model._set("C3", "text"); // Text - ignored + model._set("C4", "2"); // Valid + model._set("C5", "FALSE"); // Boolean - ignored + model._set("C6", "3"); // Valid + + model._set("A1", "=SLOPE(B1:B6, C1:C6)"); + model._set("A2", "=INTERCEPT(B1:B6, C1:C6)"); + model.evaluate(); - assert_eq!(model._get_text("A1"), *"0.461538462"); - assert_eq!(model._get_text("A2"), *"0.769230769"); + + assert_slope_intercept_eq(&model, "A1", "A2", 4.0, -3.0, EXACT_TOLERANCE); +} + +#[test] +fn test_slope_intercept_error_propagation() { + let mut model = new_empty_model(); + + // Error in y values + model._set("B1", "1"); + model._set("B2", "=1/0"); // Division by zero + model._set("B3", "3"); + model._set("C1", "1"); + model._set("C2", "2"); + model._set("C3", "3"); + model._set("A1", "=SLOPE(B1:B3, C1:C3)"); + model._set("A2", "=INTERCEPT(B1:B3, C1:C3)"); + + // Error in x values + model._set("B4", "1"); + model._set("B5", "2"); + model._set("B6", "3"); + model._set("C4", "1"); + model._set("C5", "=SQRT(-1)"); // NaN error + model._set("C6", "3"); + model._set("A3", "=SLOPE(B4:B6, C4:C6)"); + model._set("A4", "=INTERCEPT(B4:B6, C4:C6)"); + + model.evaluate(); + + assert_slope_intercept_error(&model, "A1", "A2", "#DIV/0!"); + assert_slope_intercept_error(&model, "A3", "A4", "#NUM!"); +} + +// ============================================================================= +// NUMERICAL PRECISION AND EXTREMES +// ============================================================================= + +#[test] +fn test_slope_intercept_numeric_precision() { + let mut model = new_empty_model(); + + // Very small slope near machine epsilon + model._set("B1", "5.0001"); + model._set("B2", "5.0002"); + model._set("B3", "5.0003"); + model._set("C1", "1"); + model._set("C2", "2"); + model._set("C3", "3"); + model._set("A1", "=SLOPE(B1:B3, C1:C3)"); + model._set("A2", "=INTERCEPT(B1:B3, C1:C3)"); + + // Large numbers with stability concerns + model._set("B4", "1000000"); + model._set("B5", "3000000"); + model._set("B6", "5000000"); + model._set("C4", "1000"); + model._set("C5", "2000"); + model._set("C6", "3000"); + model._set("A3", "=SLOPE(B4:B6, C4:C6)"); + model._set("A4", "=INTERCEPT(B4:B6, C4:C6)"); + + model.evaluate(); + + assert_slope_intercept_eq(&model, "A1", "A2", 0.0001, 5.0, HIGH_PRECISION_TOLERANCE); + assert_slope_intercept_eq(&model, "A3", "A4", 2000.0, -1000000.0, STABILITY_TOLERANCE); +} + +// ============================================================================= +// RANGE ORIENTATIONS AND PERFORMANCE +// ============================================================================= + +#[test] +fn test_slope_intercept_range_orientations() { + let mut model = new_empty_model(); + + // Row-wise ranges: y = 3x - 1 + model._set("B1", "2"); // (1,2) + model._set("C1", "5"); // (2,5) + model._set("D1", "8"); // (3,8) + model._set("B2", "1"); + model._set("C2", "2"); + model._set("D2", "3"); + model._set("A1", "=SLOPE(B1:D1, B2:D2)"); + model._set("A2", "=INTERCEPT(B1:D1, B2:D2)"); + + model.evaluate(); + + assert_slope_intercept_eq(&model, "A1", "A2", 3.0, -1.0, EXACT_TOLERANCE); +} + +#[test] +fn test_slope_intercept_large_dataset() { + let mut model = new_empty_model(); + + // Test with 20 points: y = 0.1x + 100 + for i in 1..=20 { + let y = 0.1 * i as f64 + 100.0; + model._set(&format!("B{}", i), &y.to_string()); + model._set(&format!("C{}", i), &i.to_string()); + } + + model._set("A1", "=SLOPE(B1:B20, C1:C20)"); + model._set("A2", "=INTERCEPT(B1:B20, C1:C20)"); + + model.evaluate(); + + assert_slope_intercept_eq(&model, "A1", "A2", 0.1, 100.0, EXACT_TOLERANCE); +} + +// ============================================================================= +// REAL-WORLD EDGE CASES +// ============================================================================= + +#[test] +fn test_slope_intercept_statistical_outliers() { + let mut model = new_empty_model(); + + // Most points follow y = 2x + 1, with one outlier: (1,3), (2,5), (3,7), (4,9), (5,100) + model._set("B1", "3"); + model._set("B2", "5"); + model._set("B3", "7"); + model._set("B4", "9"); + model._set("B5", "100"); // Statistical outlier + model._set("C1", "1"); + model._set("C2", "2"); + model._set("C3", "3"); + model._set("C4", "4"); + model._set("C5", "5"); + + model._set("A1", "=SLOPE(B1:B5, C1:C5)"); + model._set("A2", "=INTERCEPT(B1:B5, C1:C5)"); + + model.evaluate(); + + // With outlier: mathematically correct results + assert_approx_eq(&model._get_text("A1"), 19.8, STANDARD_TOLERANCE); + assert_approx_eq(&model._get_text("A2"), -34.6, STANDARD_TOLERANCE); } From d1604216a872a43934f3465bad90db1bc3f33540 Mon Sep 17 00:00:00 2001 From: BrianHung Date: Sun, 20 Jul 2025 20:55:34 -0700 Subject: [PATCH 5/6] fix build --- base/src/test/test_fn_slope_intercept.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/base/src/test/test_fn_slope_intercept.rs b/base/src/test/test_fn_slope_intercept.rs index b6c70cb60..1f106b9b9 100644 --- a/base/src/test/test_fn_slope_intercept.rs +++ b/base/src/test/test_fn_slope_intercept.rs @@ -1,4 +1,5 @@ #![allow(clippy::unwrap_used)] +#![allow(clippy::panic)] use crate::test::util::new_empty_model; @@ -325,8 +326,8 @@ fn test_slope_intercept_large_dataset() { // Test with 20 points: y = 0.1x + 100 for i in 1..=20 { let y = 0.1 * i as f64 + 100.0; - model._set(&format!("B{}", i), &y.to_string()); - model._set(&format!("C{}", i), &i.to_string()); + model._set(&format!("B{i}"), &y.to_string()); + model._set(&format!("C{i}"), &i.to_string()); } model._set("A1", "=SLOPE(B1:B20, C1:C20)"); From a30904e068eb1103e41277d543450467d866982e Mon Sep 17 00:00:00 2001 From: BrianHung Date: Tue, 22 Jul 2025 00:21:29 -0700 Subject: [PATCH 6/6] fix redundant cell evaluation on error --- base/src/functions/util.rs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/base/src/functions/util.rs b/base/src/functions/util.rs index 6e13a7429..161f7a437 100644 --- a/base/src/functions/util.rs +++ b/base/src/functions/util.rs @@ -459,18 +459,15 @@ pub(crate) fn collect_series( let mut values = Vec::new(); for row in left.row..=right.row { for column in left.column..=right.column { - match model.evaluate_cell(CellReferenceIndex { + let cell_result = model.evaluate_cell(CellReferenceIndex { sheet: left.sheet, row, column, - }) { + }); + match cell_result { CalcResult::Number(n) => values.push(Some(n)), - CalcResult::Error { .. } => { - return Err(model.evaluate_cell(CellReferenceIndex { - sheet: left.sheet, - row, - column, - })); + error @ CalcResult::Error { .. } => { + return Err(error); } _ => values.push(None), }