formualizer-eval 0.7.0

High-performance Arrow-backed Excel formula engine with dependency graph and incremental recalculation
Documentation
//! Iterate edge corpus — structural edits × iterating SCCs (RFC #112/#113,
//! spec §7.15).
//!
//! Insert/delete rows/columns between recalcs must keep the per-recalc
//! redirty set and the graph consistent: shifted members keep iterating
//! (VertexIds are stable across shifts), deleted members tombstone cleanly
//! (`redirty_iterative_members` guards on `vertex_exists`), and ranges a
//! member reads grow/shrink with the edit.

use crate::engine::{CycleConfig, Engine, EvalConfig};
use crate::test_workbook::TestWorkbook;
use formualizer_common::{ExcelErrorKind, LiteralValue};
use formualizer_parse::parser::parse;

fn iterate_engine(max_iterations: u32, max_change: f64) -> Engine<TestWorkbook> {
    Engine::new(
        TestWorkbook::new(),
        EvalConfig::default().with_cycle(CycleConfig::iterate(max_iterations, max_change)),
    )
}

fn set_formula(engine: &mut Engine<TestWorkbook>, sheet: &str, row: u32, col: u32, f: &str) {
    engine
        .set_cell_formula(sheet, row, col, parse(f).expect("parse"))
        .expect("set formula");
}

fn set_value(engine: &mut Engine<TestWorkbook>, sheet: &str, row: u32, col: u32, v: LiteralValue) {
    engine
        .set_cell_value(sheet, row, col, v)
        .expect("set value");
}

fn num(engine: &Engine<TestWorkbook>, sheet: &str, row: u32, col: u32) -> f64 {
    match engine.get_cell_value(sheet, row, col) {
        Some(LiteralValue::Number(n)) => n,
        Some(LiteralValue::Int(i)) => i as f64,
        other => panic!("expected number at {sheet} r{row}c{col}, got {other:?}"),
    }
}

#[test]
fn insert_row_above_shifts_accumulator_and_iteration_continues() {
    // Accumulator B1 = B1 + A1 (A1 = 5, cap 1). After one recalc B1 holds 5.
    // Inserting a row above shifts the SCC member to B2 (= B2 + A2); the next
    // recalc must keep accumulating from the persisted value.
    let mut engine = iterate_engine(1, 0.001);
    set_value(&mut engine, "Sheet1", 1, 1, LiteralValue::Number(5.0));
    set_formula(&mut engine, "Sheet1", 1, 2, "=B1+A1");
    engine.evaluate_all().unwrap();
    assert_eq!(num(&engine, "Sheet1", 1, 2), 5.0);

    engine.insert_rows("Sheet1", 1, 1).unwrap();
    engine.evaluate_all().unwrap();
    assert_eq!(
        num(&engine, "Sheet1", 2, 2),
        10.0,
        "shifted accumulator keeps its value and adds once more"
    );
    let t = engine.last_cycle_telemetry();
    assert_eq!(t.static_sccs, 1, "the shifted member still forms its SCC");
    assert_eq!(t.iterated_sccs, 1);

    // And again — the redirty chain follows the shifted vertex.
    engine.evaluate_all().unwrap();
    assert_eq!(num(&engine, "Sheet1", 2, 2), 15.0);
}

#[test]
fn delete_row_of_scc_member_dissolves_cycle_without_panicking() {
    // Divergent pair A1 = A2+1 / A2 = A1+1 iterates once (cap 4 → 7/8), then
    // row 2 (member A2) is deleted: A1's reference dangles → #REF!, the SCC
    // is gone, and the per-recalc redirty (which registered both VertexIds)
    // must tolerate the tombstoned member.
    let mut engine = iterate_engine(4, 0.001);
    set_formula(&mut engine, "Sheet1", 1, 1, "=A2+1");
    set_formula(&mut engine, "Sheet1", 2, 1, "=A1+1");
    engine.evaluate_all().unwrap();
    assert_eq!(num(&engine, "Sheet1", 1, 1), 7.0);
    assert_eq!(num(&engine, "Sheet1", 2, 1), 8.0);

    engine.delete_rows("Sheet1", 2, 1).unwrap();
    engine.evaluate_all().unwrap();
    match engine.get_cell_value("Sheet1", 1, 1) {
        Some(LiteralValue::Error(e)) => assert_eq!(e.kind, ExcelErrorKind::Ref),
        other => panic!("expected #REF! after member deletion, got {other:?}"),
    }
    assert_eq!(engine.last_cycle_telemetry().static_sccs, 0);

    // The recalc after that schedules no cycle work at all (no redirty leak
    // from the tombstoned member).
    let res = engine.evaluate_all().unwrap();
    assert_eq!(engine.last_cycle_telemetry().static_sccs, 0);
    assert_eq!(res.computed_vertices, 0, "no perpetual redirty leak");
}

#[test]
fn insert_row_inside_member_read_range_extends_the_range() {
    // §7.8 self-inclusion B2 = SUM(B1:B3) (B1 = 1, B3 = 2; cap 3 → 9). Insert
    // a row before row 3: the formula stays at B2 but its range stretches to
    // B1:B4 (B3 value moves to B4, new B3 empty). Growth stays +3 per pass,
    // seeded from the persisted 9.
    let mut engine = iterate_engine(3, 0.001);
    set_value(&mut engine, "Sheet1", 1, 2, LiteralValue::Number(1.0));
    set_value(&mut engine, "Sheet1", 3, 2, LiteralValue::Number(2.0));
    set_formula(&mut engine, "Sheet1", 2, 2, "=SUM(B1:B3)");
    engine.evaluate_all().unwrap();
    assert_eq!(num(&engine, "Sheet1", 2, 2), 9.0); // 3, 6, 9

    engine.insert_rows("Sheet1", 3, 1).unwrap();
    engine.evaluate_all().unwrap();
    // Each pass: B2 = 1 + B2_prev + 0 + 2 → +3 per pass from 9.
    assert_eq!(
        num(&engine, "Sheet1", 2, 2),
        18.0,
        "range must include the inserted row's shifted contents"
    );
    let t = engine.last_cycle_telemetry();
    assert_eq!(t.iterated_sccs, 1);
    assert_eq!(t.capped_sccs, 1);
}

#[test]
fn insert_column_shifts_divergent_pair_and_iteration_continues() {
    // B1 = C1+1, C1 = B1+1 (cap 2 → B1 = 3, C1 = 4). Insert a column before
    // column A: members shift to C1/D1; iteration resumes from persisted
    // values on the next recalc.
    let mut engine = iterate_engine(2, 0.001);
    set_formula(&mut engine, "Sheet1", 1, 2, "=C1+1");
    set_formula(&mut engine, "Sheet1", 1, 3, "=B1+1");
    engine.evaluate_all().unwrap();
    assert_eq!(num(&engine, "Sheet1", 1, 2), 3.0);
    assert_eq!(num(&engine, "Sheet1", 1, 3), 4.0);

    engine.insert_columns("Sheet1", 1, 1).unwrap();
    engine.evaluate_all().unwrap();
    // Member order is still (row, col) ascending: C1 then D1.
    // Pass 1: C1 = 4+1 = 5, D1 = 6; pass 2: C1 = 7, D1 = 8.
    assert_eq!(num(&engine, "Sheet1", 1, 3), 7.0);
    assert_eq!(num(&engine, "Sheet1", 1, 4), 8.0);
    let t = engine.last_cycle_telemetry();
    assert_eq!(t.iterated_sccs, 1);
    assert_eq!(t.capped_sccs, 1);
}

#[test]
fn delete_unrelated_row_between_recalcs_keeps_scc_iterating() {
    // The SCC sits at rows 10/11; deleting row 1 shifts both members up one
    // row. The redirty set was registered against VertexIds pre-shift — the
    // shifted vertices must still iterate (id stability across shifts).
    let mut engine = iterate_engine(2, 0.001);
    set_value(&mut engine, "Sheet1", 1, 1, LiteralValue::Number(99.0)); // filler row
    set_formula(&mut engine, "Sheet1", 10, 1, "=A11+1");
    set_formula(&mut engine, "Sheet1", 11, 1, "=A10+1");
    engine.evaluate_all().unwrap();
    assert_eq!(num(&engine, "Sheet1", 10, 1), 3.0);
    assert_eq!(num(&engine, "Sheet1", 11, 1), 4.0);

    engine.delete_rows("Sheet1", 1, 1).unwrap();
    engine.evaluate_all().unwrap();
    assert_eq!(num(&engine, "Sheet1", 9, 1), 7.0);
    assert_eq!(num(&engine, "Sheet1", 10, 1), 8.0);
    assert_eq!(engine.last_cycle_telemetry().iterated_sccs, 1);

    engine.evaluate_all().unwrap();
    assert_eq!(num(&engine, "Sheet1", 9, 1), 11.0);
    assert_eq!(num(&engine, "Sheet1", 10, 1), 12.0);
}

#[test]
fn delete_one_member_of_a_three_cycle_leaves_a_smaller_live_cycle() {
    // Ring A1 → A2 → A3 → A1 (each = next + 1), cap 3. Delete row 2: the
    // ring breaks into A1 = #REF!-chain... pinned empirically: A1 = A2+1 now
    // points at the shifted A3 (which became A2 = A1+1), forming a 2-ring
    // that KEEPS iterating. Reference adjustment, not dangling: deleting a
    // middle row rewires `A3`→`A2` in the survivors.
    let mut engine = iterate_engine(3, 0.001);
    set_formula(&mut engine, "Sheet1", 1, 1, "=A2+1");
    set_formula(&mut engine, "Sheet1", 2, 1, "=A3+1");
    set_formula(&mut engine, "Sheet1", 3, 1, "=A1+1");
    engine.evaluate_all().unwrap();
    let t = engine.last_cycle_telemetry();
    assert_eq!(t.iterated_sccs, 1);
    assert_eq!(t.capped_sccs, 1);

    engine.delete_rows("Sheet1", 2, 1).unwrap();
    // Survivors: A1 = #REF! or = A2+1 (rewired), A2 (old A3) = A1+1.
    engine.evaluate_all().unwrap();
    // Empirical pin below — adjusted after first run if the engine chooses
    // #REF! semantics for references INTO the deleted row.
    match engine.get_cell_value("Sheet1", 1, 1) {
        Some(LiteralValue::Error(e)) => {
            // A1 referenced the deleted A2 directly → #REF! propagates to the
            // survivor pair; no SCC remains live through the error? The
            // static edge A2(new)=A1+1 + A1=#REF! is acyclic — assert no
            // panic and a consistent error state.
            assert_eq!(e.kind, ExcelErrorKind::Ref);
            match engine.get_cell_value("Sheet1", 2, 1) {
                Some(LiteralValue::Error(e2)) => assert_eq!(e2.kind, ExcelErrorKind::Ref),
                other => panic!("survivor must propagate #REF!, got {other:?}"),
            }
        }
        Some(LiteralValue::Number(_)) | Some(LiteralValue::Int(_)) => {
            // Rewired semantics: the 2-ring keeps iterating.
            assert_eq!(engine.last_cycle_telemetry().iterated_sccs, 1);
        }
        other => panic!("unexpected post-delete state: {other:?}"),
    }
    // Either way the NEXT recalc must be stable and panic-free.
    engine.evaluate_all().unwrap();
}