"""Tests for the formula engine — DAG, cycle detection, evaluation. Covers: topological sort, circular reference detection, aggregate vs row context, formula-to-formula dependencies, and the built-in function library. """ from __future__ import annotations import pytest from agentkit.bitable.formula.engine import ( CircularReferenceError, FormulaEngine, ) # --------------------------------------------------------------------------- # Basic evaluation # --------------------------------------------------------------------------- def test_engine_evaluate_simple_arithmetic() -> None: """=1+2*3 → 7""" engine = FormulaEngine() engine.add_formula("calc", "=1+2*3") result = engine.evaluate("calc", row_values={}) assert result == 7 def test_engine_evaluate_row_reference() -> None: """={f1} + {f2} → row-level sum""" engine = FormulaEngine() engine.add_formula("sum", "={f1} + {f2}") result = engine.evaluate("sum", row_values={"f1": 10, "f2": 20}) assert result == 30 def test_engine_evaluate_aggregate_sum() -> None: """=SUM({f1}) → aggregate sum of column""" engine = FormulaEngine() engine.add_formula("total", "=SUM({f1})") result = engine.evaluate("total", row_values={}, column_values={"f1": [1, 2, 3]}) assert result == 6 def test_engine_evaluate_aggregate_avg() -> None: """=AVG({f1}) → average of column""" engine = FormulaEngine() engine.add_formula("avg", "=AVG({f1})") result = engine.evaluate("avg", row_values={}, column_values={"f1": [10, 20, 30]}) assert result == 20.0 def test_engine_evaluate_aggregate_count() -> None: """=COUNT({f1}) → count of non-empty values""" engine = FormulaEngine() engine.add_formula("cnt", "=COUNT({f1})") result = engine.evaluate("cnt", row_values={}, column_values={"f1": [1, None, 3, "", 5]}) assert result == 3 # None and "" are ignored def test_engine_evaluate_mixed_aggregate_and_row() -> None: """={f1} + SUM({f2}) → row f1 + column f2 sum""" engine = FormulaEngine() engine.add_formula("mixed", "={f1} + SUM({f2})") result = engine.evaluate("mixed", row_values={"f1": 10}, column_values={"f2": [1, 2, 3]}) assert result == 16 # 10 + 6 def test_engine_evaluate_concat() -> None: """=CONCAT({f1}, "-", {f2}) → string concat""" engine = FormulaEngine() engine.add_formula("label", '=CONCAT({f1}, "-", {f2})') result = engine.evaluate("label", row_values={"f1": "a", "f2": "b"}) assert result == "a-b" def test_engine_evaluate_if_function() -> None: """=IF({f1} > 5, "big", "small")""" engine = FormulaEngine() engine.add_formula("size", '=IF({f1} > 5, "big", "small")') assert engine.evaluate("size", row_values={"f1": 10}) == "big" assert engine.evaluate("size", row_values={"f1": 3}) == "small" def test_engine_evaluate_min_max() -> None: engine = FormulaEngine() engine.add_formula("mn", "=MIN({f1})") engine.add_formula("mx", "=MAX({f1})") cols = {"f1": [3, 1, 4, 1, 5, 9, 2, 6]} assert engine.evaluate("mn", {}, cols) == 1 assert engine.evaluate("mx", {}, cols) == 9 # --------------------------------------------------------------------------- # DAG: dependencies and dependents # --------------------------------------------------------------------------- def test_engine_get_dependencies() -> None: engine = FormulaEngine() engine.add_formula("c", "={a} + {b}") assert engine.get_dependencies("c") == {"a", "b"} assert engine.get_dependents("a") == {"c"} assert engine.get_dependents("b") == {"c"} def test_engine_topological_order() -> None: """c depends on b, b depends on a → order: a, b, c""" engine = FormulaEngine() engine.add_formula("c", "={b} + 1") engine.add_formula("b", "={a} + 1") engine.add_formula("a", "=1") order = engine.topological_order() assert order.index("a") < order.index("b") assert order.index("b") < order.index("c") def test_engine_evaluate_all_for_record() -> None: """Formula-to-formula dependency: c = b + 1, b = a + 1, a = 5 → c = 7""" engine = FormulaEngine() engine.add_formula("a", "=5") engine.add_formula("b", "={a} + 1") engine.add_formula("c", "={b} + 1") results = engine.evaluate_all_for_record(row_values={}) assert results["a"] == 5 assert results["b"] == 6 assert results["c"] == 7 # --------------------------------------------------------------------------- # Circular reference detection # --------------------------------------------------------------------------- def test_circular_reference_detected() -> None: """f1 = f2 + 1, f2 = f1 + 1 → CircularReferenceError""" engine = FormulaEngine() engine.add_formula("f1", "={f2} + 1") with pytest.raises(CircularReferenceError): engine.add_formula("f2", "={f1} + 1") def test_circular_reference_rollback() -> None: """When cycle is detected, the formula is not added (rollback).""" engine = FormulaEngine() engine.add_formula("f1", "={f2} + 1") with pytest.raises(CircularReferenceError): engine.add_formula("f2", "={f1} + 1") # f2 should not be in the engine assert "f2" not in engine._formulas assert "f2" not in engine._dag def test_self_reference_detected() -> None: """f1 = f1 + 1 → CircularReferenceError""" engine = FormulaEngine() with pytest.raises(CircularReferenceError): engine.add_formula("f1", "={f1} + 1") def test_remove_formula_breaks_cycle() -> None: """Remove a formula, then the cycle can be broken.""" engine = FormulaEngine() engine.add_formula("f1", "={f2} + 1") # Can't add f2 = f1 + 1 (cycle) with pytest.raises(CircularReferenceError): engine.add_formula("f2", "={f1} + 1") # Remove f1, now f2 can be added standalone engine.remove_formula("f1") engine.add_formula("f2", "=42") assert engine.evaluate("f2", {}) == 42 # --------------------------------------------------------------------------- # Edge cases # --------------------------------------------------------------------------- def test_evaluate_missing_field_value_is_none() -> None: """Missing field values are None — arithmetic on None raises TypeError.""" engine = FormulaEngine() engine.add_formula("calc", "={missing_field} + 1") # The engine passes None for missing fields (row_values.get returns None) with pytest.raises(TypeError): engine.evaluate("calc", row_values={}) def test_aggregate_ignores_none_and_empty() -> None: """SUM ignores None and empty string values.""" engine = FormulaEngine() engine.add_formula("total", "=SUM({f1})") result = engine.evaluate("total", row_values={}, column_values={"f1": [1, None, 2, "", 3]}) assert result == 6 def test_division_by_zero_returns_error_in_evaluate_all() -> None: """Division by zero is caught in evaluate_all_for_record, returns error dict.""" engine = FormulaEngine() engine.add_formula("calc", "={f1} / 0") results = engine.evaluate_all_for_record(row_values={"f1": 10}) assert "__error" in results["calc"] def test_engine_with_uuid_field_ids() -> None: """Field IDs with hyphens (UUIDs) work correctly.""" fid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" engine = FormulaEngine() engine.add_formula("calc", f"={{{fid}}} * 2") result = engine.evaluate("calc", row_values={fid: 21}) assert result == 42