From 5524e2520a2f9d303834c56455add045c247c58b Mon Sep 17 00:00:00 2001 From: neilcawse <8085959+neilcawse@users.noreply.github.com> Date: Mon, 12 Oct 2026 07:36:37 -0400 Subject: [PATCH] Functions: COUNTIF family returns #VALUE! for criteria over 255 characters Excel rejects a text criterion longer than 255 characters in COUNTIF(S), SUMIF(S), AVERAGEIF(S), MAXIFS and MINIFS with #VALUE!; GridCraft matched it anyway. Found by the calc oracle on an Excel-saved report where a 268-character concatenated criterion caches #VALUE!. Co-Authored-By: Claude Opus 5.5 --- crates/functions/src/criteria.rs | 13 +++++++++++++ crates/functions/src/math.rs | 18 +++++++++++++++--- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/crates/functions/src/criteria.rs b/crates/functions/src/criteria.rs index 20410c8..db010fc 100644 --- a/crates/functions/src/criteria.rs +++ b/crates/functions/src/criteria.rs @@ -5,6 +5,9 @@ use std::cmp::Ordering; use gridcraft_core::parse::parse_number_text; use gridcraft_core::{CellError, Value, compare_numbers, compare_text}; +/// Longest text criterion COUNTIF and its relatives accept; a longer one is `#VALUE!`. +const MAX_CRITERION_CHARS: usize = 255; + #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Op { Eq, @@ -48,6 +51,16 @@ impl Criterion { } } + /// Parses the criteria argument of COUNTIF(S), SUMIF(S), AVERAGEIF(S), MAXIFS and MINIFS: + /// like `parse`, but text longer than 255 characters is `#VALUE!`, as in Excel. + pub(crate) fn parse_arg(v: &Value) -> Result { + match v { + Value::Array(a) => Criterion::parse_arg(a.data.first().unwrap_or(&Value::Empty)), + Value::Text(t) if t.chars().nth(MAX_CRITERION_CHARS).is_some() => Err(CellError::Value), + other => Ok(Criterion::parse(other)), + } + } + fn parse_text(t: &str) -> Criterion { let (op, rest) = if let Some(r) = t.strip_prefix(">=") { (Op::Ge, r) diff --git a/crates/functions/src/math.rs b/crates/functions/src/math.rs index bab219e..1233a40 100644 --- a/crates/functions/src/math.rs +++ b/crates/functions/src/math.rs @@ -957,7 +957,7 @@ fn criteria_mask(a: &[Arg], first_pair: usize, rows: usize, cols: usize) -> R R { fn countif(a: &[Arg], _c: &mut dyn Ctx) -> R { let range = as_array(&crate::util::arg(a, 0)?.value); - let c = Criterion::parse(&crate::util::arg(a, 1)?.value.scalar()); + let c = Criterion::parse_arg(&crate::util::arg(a, 1)?.value.scalar())?; num_val(range.iter().filter(|v| c.matches(v)).count() as f64) } @@ -1026,7 +1026,7 @@ fn countif(a: &[Arg], _c: &mut dyn Ctx) -> R { /// the evaluator does that resizing (it has the references), so cells line up one to one here. fn if_numbers(a: &[Arg]) -> R> { let range = as_array(&crate::util::arg(a, 0)?.value); - let c = Criterion::parse(&crate::util::arg(a, 1)?.value.scalar()); + let c = Criterion::parse_arg(&crate::util::arg(a, 1)?.value.scalar())?; let sum_range = if has(a, 2) { Some(as_array(&crate::util::arg(a, 2)?.value)) } else { None }; let mut out = Vec::new(); for r in 0..range.rows { @@ -1643,4 +1643,16 @@ mod tests { is_err(ev("COUNTIFS", vec![rf(qty.clone()), t(">1"), rf(qty)]), CellError::Value); is_err(ev("SUMIF", vec![rf(col(&[1.0, 2.0])), t(">0"), rf(arr(vec![vec![Value::Error(NA)], vec![nv(1.0)]]))]), NA); } + + #[test] + fn criteria_longer_than_255_chars_are_value_errors() { + let ok = "x".repeat(255); + let long = "x".repeat(256); + let cells = arr(vec![vec![tv(&ok)], vec![tv(&long)]]); + let qty = col(&[1.0, 2.0]); + close(ev("COUNTIF", vec![rf(cells.clone()), t(&ok)]), 1.0); + is_err(ev("COUNTIF", vec![rf(cells.clone()), t(&long)]), CellError::Value); + close(ev("SUMIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&ok)]), 1.0); + is_err(ev("SUMIFS", vec![rf(qty), rf(cells), t(&long)]), CellError::Value); + } }