diff --git a/crates/apps/spreadsheet/spreadsheet-engine/src/formula2.rs b/crates/apps/spreadsheet/spreadsheet-engine/src/formula2.rs index 03b54b4..6ded958 100644 --- a/crates/apps/spreadsheet/spreadsheet-engine/src/formula2.rs +++ b/crates/apps/spreadsheet/spreadsheet-engine/src/formula2.rs @@ -98,6 +98,67 @@ impl CellRef { abs_col, }) } + + #[allow(dead_code)] + fn parse_inner(s: &str, abs_col_hint: bool, abs_row_hint: bool) -> Option { + let mut chars = s.chars().peekable(); + let mut abs_col = abs_col_hint; + let mut abs_row = abs_row_hint; + + // Skip leading `$`. + if chars.peek() == Some(&'$') { + abs_col = true; + chars.next(); + } + + // Column letters. + let mut col_str = String::new(); + while let Some(&c) = chars.peek() { + if c.is_ascii_alphabetic() { + col_str.push(c.to_ascii_uppercase()); + chars.next(); + } else { + break; + } + } + if col_str.is_empty() { + return None; + } + + // Check for `$` between col and row. + if chars.peek() == Some(&'$') { + abs_row = true; + chars.next(); + } + + // Row digits. + let mut row_str = String::new(); + while let Some(&c) = chars.peek() { + if c.is_ascii_digit() { + row_str.push(c); + chars.next(); + } else { + return None; // trailing junk + } + } + if row_str.is_empty() { + return None; + } + + let row = row_str.parse::().ok()?; + if row == 0 { + return None; + } + + let col = col_str_to_index(&col_str)?; + + Some(Self { + row: row - 1, + col, + abs_row, + abs_col, + }) + } } /// Format back to A1 notation (e.g. `$A$1`, `B2`). @@ -1894,204 +1955,4 @@ mod tests { ctx.set(0, 0, "=A1"); assert_eq!(eval_mock(&ctx, "A1"), "#CYCLE!"); } - - // ── FormulaError display and round-trip (remaining variants) ─────── - - #[test] - fn formula_error_invalid_ref_and_runtime_display() { - assert_eq!( - FormulaError::InvalidRef("A0".into()).to_display(), - "#REF!A0" - ); - assert_eq!( - FormulaError::RuntimeError("boom".into()).to_display(), - "boom" - ); - // `Display` delegates to `to_display`; `format!` exercises it. - assert_eq!( - format!("{}", FormulaError::InvalidRef("A0".into())), - "#REF!A0" - ); - } - - #[test] - fn formula_error_from_display_invalid_ref() { - assert_eq!( - FormulaError::from_display("#REF!ZZ999999"), - Some(FormulaError::InvalidRef("ZZ999999".into())) - ); - } - - // ── BinOp precedence table ───────────────────────────────────────── - - #[test] - fn binop_precedence_table() { - assert_eq!(BinOp::Concat.precedence(), 1); - assert_eq!(BinOp::Gt.precedence(), 2); - assert_eq!(BinOp::Lt.precedence(), 2); - assert_eq!(BinOp::Ge.precedence(), 2); - assert_eq!(BinOp::Le.precedence(), 2); - assert_eq!(BinOp::Eq.precedence(), 2); - assert_eq!(BinOp::Ne.precedence(), 2); - assert_eq!(BinOp::Add.precedence(), 3); - assert_eq!(BinOp::Sub.precedence(), 3); - assert_eq!(BinOp::Mul.precedence(), 4); - assert_eq!(BinOp::Div.precedence(), 4); - } - - // ── Expr::cell_ref convenience constructor ───────────────────────── - - #[test] - fn expr_cell_ref_constructor_evaluates() { - let mut ctx = MockCtx::new(); - ctx.set(0, 0, "42"); - let expr = Expr::cell_ref(0, 0); - assert!(matches!(evaluate(&expr, &ctx), Ok(Value::Number(n)) if n == 42.0)); - } - - // ── Value conversion error arms ──────────────────────────────────── - - #[test] - fn value_conversions_propagate_errors() { - let err = Value::Error(FormulaError::DivByZero); - assert!(matches!(err.to_f64(), Err(FormulaError::DivByZero))); - assert!(matches!(err.to_bool(), Err(FormulaError::DivByZero))); - assert_eq!(err.to_display_string(), "#DIV/0!"); - assert!(err.is_error()); - assert!(!Value::Number(1.0).is_error()); - } - - // ── Tokenizer edge branches ──────────────────────────────────────── - - #[test] - fn tokenize_lone_bang_is_an_error() { - assert!(matches!(tokenize("!"), Err(FormulaError::ParseError(_)))); - assert!(matches!(tokenize("5!3"), Err(FormulaError::ParseError(_)))); - } - - #[test] - fn tokenize_string_escape_sequences() { - assert_eq!( - tokenize("\"a\\nb\"").unwrap(), - vec![Token::Str("a\nb".to_string())] - ); - assert_eq!( - tokenize("\"a\\tb\"").unwrap(), - vec![Token::Str("a\tb".to_string())] - ); - assert_eq!( - tokenize("\"a\\\\b\"").unwrap(), - vec![Token::Str("a\\b".to_string())] - ); - } - - #[test] - fn tokenize_double_dot_number_is_rejected() { - // `1.2.3`: the tokenizer consumes `1.2`, hits a second dot, breaks, - // then rejects the stray dot. - assert!(matches!( - tokenize("1.2.3"), - Err(FormulaError::ParseError(_)) - )); - } - - // ── evaluate: boolean literals, unary plus, ranges ───────────────── - - #[test] - fn eval_boolean_literals() { - let ctx = MockCtx::new(); - assert_eq!(eval_mock(&ctx, "TRUE"), "TRUE"); - assert_eq!(eval_mock(&ctx, "FALSE"), "FALSE"); - } - - #[test] - fn eval_unary_plus() { - let mut ctx = MockCtx::new(); - ctx.set(0, 0, "7"); - // The parser collapses `+x` to `x`; the Unary(Pos, ..) node only - // reaches the evaluator when the AST is built directly. - let expr = Expr::unary(UnaryOp::Pos, Expr::number(5.0)); - assert!(matches!(evaluate(&expr, &ctx), Ok(Value::Number(n)) if n == 5.0)); - assert_eq!(eval_mock(&ctx, "+A1"), "7"); - } - - #[test] - fn eval_single_cell_named_range() { - let mut ctx = MockCtx::new(); - ctx.set(0, 0, "99"); - // MockCtx is exact-match, and the tokenizer uppercases identifiers. - ctx.named.insert("SOLO".to_string(), (0, 0, 0, 0)); - assert_eq!(eval_mock(&ctx, "Solo"), "99"); - } - - #[test] - fn eval_range_expression_single_and_multi_cell() { - let mut ctx = MockCtx::new(); - ctx.set(0, 0, "5"); - ctx.set(0, 1, "6"); - // Single-cell range returns that cell. - assert_eq!(eval_mock(&ctx, "A1:A1"), "5"); - // Multi-cell range in expression context returns the first cell. - assert_eq!(eval_mock(&ctx, "A1:B1"), "5"); - } - - #[test] - fn eval_sum_over_unknown_named_range_errors() { - let ctx = MockCtx::new(); - assert_eq!(eval_mock(&ctx, "SUM(Undefined)"), "#NAME?UNDEFINED"); - } - - // ── values_equal: text, boolean, error, mixed ────────────────────── - - #[test] - fn eq_operator_covers_text_boolean_error_and_mixed() { - let mut ctx = MockCtx::new(); - ctx.set(0, 0, "abc"); - ctx.set(0, 1, "ABC"); - // Text equality is case-insensitive. - assert_eq!(eval_mock(&ctx, "A1=B1"), "TRUE"); - // Boolean equality. - assert_eq!(eval_mock(&ctx, "TRUE=TRUE"), "TRUE"); - assert_eq!(eval_mock(&ctx, "TRUE=FALSE"), "FALSE"); - // Mixed types are not equal. - assert_eq!(eval_mock(&ctx, "1=\"1\""), "FALSE"); - // An error operand propagates instead of comparing. - assert_eq!(eval_mock(&ctx, "1/0=1"), "#DIV/0!"); - } - - #[test] - fn values_equal_error_arm_returns_none() { - let num = Value::Number(1.0); - let err = Value::Error(FormulaError::DivByZero); - assert_eq!(values_equal(&err, &num), None); - assert_eq!(values_equal(&num, &err), None); - } - - // ── evaluate_call: SQRT negative, LOG10, SIN/COS/TAN ─────────────── - - #[test] - fn eval_sqrt_negative_is_div_by_zero() { - let ctx = MockCtx::new(); - assert_eq!(eval_mock(&ctx, "SQRT(-4)"), "#DIV/0!"); - } - - #[test] - fn eval_log10_and_trig_functions() { - let ctx = MockCtx::new(); - assert_eq!(eval_mock(&ctx, "LOG10(100)"), "2"); - assert_eq!(eval_mock(&ctx, "SIN(0)"), "0"); - assert_eq!(eval_mock(&ctx, "COS(0)"), "1"); - assert_eq!(eval_mock(&ctx, "TAN(0)"), "0"); - } - - // ── evaluate_if: wrong argument count ────────────────────────────── - - #[test] - fn eval_if_wrong_argument_count_errors() { - let ctx = MockCtx::new(); - assert_eq!( - eval_mock(&ctx, "IF(1)"), - "#VALUE:IF requires 2 or 3 arguments" - ); - } }