oficina / sheets Public

calc: math family -- SUM, AVERAGE, COUNT, COUNTA, MIN, MAX, ROUND family, ABS, SQRT, POWER, MOD

13 functions, all ordinary Funcs (fn_math.go), plus 26 corpus rows
(testdata/corpus/formulas.json, hand-authored -- see the file's own
note and corpus_test.go's loadCorpus doc comment) and corpusSHA256
updated to match in this same commit.

COUNT counts only numbers; COUNTA counts every non-blank value
(numbers, text, booleans, even errors); neither counts a blank cell --
pinned by TestCountVsCounta, the distinction task 8's brief calls out
by name. SUM/AVERAGE/MIN/MAX propagate an error argument left-most-wins
like every other operator in this package, but COUNT/COUNTA do not --
they are a tally, and a broken cell elsewhere (a VLOOKUP helper column
full of #N/A, say) is common enough that Excel lets them scan past it.

ROUND uses half-away-from-zero (Excel's own rule, not banker's
rounding); MOD takes the sign of the divisor, matching Excel and NOT
Go's %. Both are pinned by tests using cases picked specifically
because a naive port of Go's own math primitives gets them wrong
(ROUND(-2.5,0)=-3, MOD(-10,3)=2).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APtNStXDZ5H2fAc1ZuGqtS
Paul Campbell f1a20a51702d32102ec8e5c5b04067cab162f414 parent 716da75
4 files changed, +442 −1
  • internal/sheets/calc/corpus_test.go +1 −1
  • internal/sheets/calc/fn_math.go +274 −0
  • internal/sheets/calc/fn_math_test.go +141 −0
  • internal/sheets/calc/testdata/corpus/formulas.json +26 −0
diff --git a/internal/sheets/calc/corpus_test.go b/internal/sheets/calc/corpus_test.go
index 2af3bf5..426e76a 100644
--- a/internal/sheets/calc/corpus_test.go
+++ b/internal/sheets/calc/corpus_test.go
@@ -21,7 +21,7 @@ import (
// without also changing the file (or vice versa) is exactly the silent
// drift this test exists to catch — a corpus change is a deliberate act
// with a regenerated hash, never a side effect of an unrelated edit.
-const corpusSHA256 = "80a965d0556bb9fa0048dcd155cd2e59dda27fd5093d59a3bd082a021c267e8a"
+const corpusSHA256 = "ee3efa16a4cc76c3f389a7932877799113ff3ea13e8db9fa817c2086c2153699"
// corpusValue is formulas.json's wire format for a Value: kind names one
// of "empty", "num", "str", "bool", "err", and only the matching field is
diff --git a/internal/sheets/calc/fn_math.go b/internal/sheets/calc/fn_math.go
new file mode 100644
index 0000000..0f045bf
--- /dev/null
+++ b/internal/sheets/calc/fn_math.go
@@ -0,0 +1,274 @@
+package calc
+
+import "math"
+
+func init() {
+ Register("SUM", fnSum)
+ Register("AVERAGE", fnAverage)
+ Register("COUNT", fnCount)
+ Register("COUNTA", fnCounta)
+ Register("MIN", fnMin)
+ Register("MAX", fnMax)
+ Register("ROUND", fnRound)
+ Register("ROUNDUP", fnRoundUp)
+ Register("ROUNDDOWN", fnRoundDown)
+ Register("ABS", fnAbs)
+ Register("SQRT", fnSqrt)
+ Register("POWER", fnPower)
+ Register("MOD", fnMod)
+}
+
+// numericValues applies to SUM, AVERAGE, MIN and MAX: any KindErr
+// argument propagates immediately (left-most wins, matching every other
+// error path in this package — one bad cell breaks a real spreadsheet's
+// SUM the same way). KindEmpty and KindStr are skipped rather than
+// coerced — this matches how Excel treats a *range's* blank/text cells
+// for these four functions.
+//
+// What it does NOT match is Excel's other rule that a text argument
+// passed directly (not through a range) coerces if it looks numeric —
+// real Excel's SUM(1,"2") is 3. evalCallArg has already flattened a
+// range's cells and a literal argument into the same []Value with no way
+// to tell them apart by the time a Func sees them (see registry.go's
+// nodeFunc doc comment for the general shape of this problem), so this
+// task picks the one rule that is right for a range and wrong for a
+// literal, rather than the reverse — ranges are the overwhelmingly more
+// common shape for these four functions in practice. Flagged here for
+// whoever eventually threads argument-origin through Func's signature.
+func numericValues(args []Value) ([]float64, *Value) {
+ var nums []float64
+ for i := range args {
+ a := args[i]
+ if a.Kind == KindErr {
+ return nil, &a
+ }
+ if a.Kind == KindNum {
+ nums = append(nums, a.Num)
+ }
+ }
+ return nums, nil
+}
+
+func fnSum(args []Value) Value {
+ nums, errv := numericValues(args)
+ if errv != nil {
+ return *errv
+ }
+ var total float64
+ for _, n := range nums {
+ total += n
+ }
+ return Value{Kind: KindNum, Num: total}
+}
+
+func fnAverage(args []Value) Value {
+ nums, errv := numericValues(args)
+ if errv != nil {
+ return *errv
+ }
+ if len(nums) == 0 {
+ return errValue(ErrDiv0)
+ }
+ var total float64
+ for _, n := range nums {
+ total += n
+ }
+ return Value{Kind: KindNum, Num: total / float64(len(nums))}
+}
+
+// fnCount counts only KindNum arguments — not KindEmpty, KindStr,
+// KindBool or KindErr. Unlike SUM/AVERAGE/MIN/MAX, an error argument does
+// not propagate: COUNT is a tally, and a broken cell elsewhere in a range
+// is common enough (a VLOOKUP helper column full of #N/A, say) that
+// COUNT/COUNTA are relied on to scan past it rather than becoming it.
+func fnCount(args []Value) Value {
+ n := 0
+ for _, a := range args {
+ if a.Kind == KindNum {
+ n++
+ }
+ }
+ return Value{Kind: KindNum, Num: float64(n)}
+}
+
+// fnCounta counts every argument that is not KindEmpty — numbers,
+// strings, booleans and (per fnCount's doc comment) errors all count as
+// "present." This is the property COUNT vs. COUNTA hinges on (task 8's
+// brief names it directly): neither counts a blank cell, but COUNTA
+// counts everything else that COUNT would skip.
+func fnCounta(args []Value) Value {
+ n := 0
+ for _, a := range args {
+ if a.Kind != KindEmpty {
+ n++
+ }
+ }
+ return Value{Kind: KindNum, Num: float64(n)}
+}
+
+func fnMin(args []Value) Value {
+ nums, errv := numericValues(args)
+ if errv != nil {
+ return *errv
+ }
+ if len(nums) == 0 {
+ return Value{Kind: KindNum, Num: 0}
+ }
+ m := nums[0]
+ for _, n := range nums[1:] {
+ if n < m {
+ m = n
+ }
+ }
+ return Value{Kind: KindNum, Num: m}
+}
+
+func fnMax(args []Value) Value {
+ nums, errv := numericValues(args)
+ if errv != nil {
+ return *errv
+ }
+ if len(nums) == 0 {
+ return Value{Kind: KindNum, Num: 0}
+ }
+ m := nums[0]
+ for _, n := range nums[1:] {
+ if n > m {
+ m = n
+ }
+ }
+ return Value{Kind: KindNum, Num: m}
+}
+
+// twoNumArgs evaluates the common "exactly two numeric arguments" shape
+// shared by ROUND/ROUNDUP/ROUNDDOWN/POWER/MOD: an error argument
+// propagates left-most-wins, a wrong argument count or a non-coercible
+// argument is #VALUE!.
+func twoNumArgs(args []Value) (a, b float64, errv Value, ok bool) {
+ if len(args) != 2 {
+ return 0, 0, errValue(ErrValue), false
+ }
+ if args[0].Kind == KindErr {
+ return 0, 0, args[0], false
+ }
+ if args[1].Kind == KindErr {
+ return 0, 0, args[1], false
+ }
+ x, ek := toNumber(args[0])
+ if ek != ErrNone {
+ return 0, 0, errValue(ek), false
+ }
+ y, ek := toNumber(args[1])
+ if ek != ErrNone {
+ return 0, 0, errValue(ek), false
+ }
+ return x, y, Value{}, true
+}
+
+// roundAt implements the shared shape behind ROUND (mode 0, half away
+// from zero — Excel's own rounding rule, not banker's rounding),
+// ROUNDUP (mode 1, always away from zero regardless of the dropped
+// digits) and ROUNDDOWN (mode 2, always toward zero — truncation).
+// digits may be negative (round to tens, hundreds, ...), matching Excel.
+func roundAt(x float64, digits int, mode int) float64 {
+ mult := math.Pow(10, float64(digits))
+ v := x * mult
+ switch mode {
+ case 0:
+ if v >= 0 {
+ v = math.Floor(v + 0.5)
+ } else {
+ v = math.Ceil(v - 0.5)
+ }
+ case 1:
+ if v >= 0 {
+ v = math.Ceil(v)
+ } else {
+ v = math.Floor(v)
+ }
+ default:
+ if v >= 0 {
+ v = math.Floor(v)
+ } else {
+ v = math.Ceil(v)
+ }
+ }
+ return v / mult
+}
+
+func fnRound(args []Value) Value {
+ x, d, errv, ok := twoNumArgs(args)
+ if !ok {
+ return errv
+ }
+ return Value{Kind: KindNum, Num: roundAt(x, int(d), 0)}
+}
+
+func fnRoundUp(args []Value) Value {
+ x, d, errv, ok := twoNumArgs(args)
+ if !ok {
+ return errv
+ }
+ return Value{Kind: KindNum, Num: roundAt(x, int(d), 1)}
+}
+
+func fnRoundDown(args []Value) Value {
+ x, d, errv, ok := twoNumArgs(args)
+ if !ok {
+ return errv
+ }
+ return Value{Kind: KindNum, Num: roundAt(x, int(d), 2)}
+}
+
+func fnAbs(args []Value) Value {
+ if len(args) != 1 {
+ return errValue(ErrValue)
+ }
+ if args[0].Kind == KindErr {
+ return args[0]
+ }
+ x, ek := toNumber(args[0])
+ if ek != ErrNone {
+ return errValue(ek)
+ }
+ return Value{Kind: KindNum, Num: math.Abs(x)}
+}
+
+func fnSqrt(args []Value) Value {
+ if len(args) != 1 {
+ return errValue(ErrValue)
+ }
+ if args[0].Kind == KindErr {
+ return args[0]
+ }
+ x, ek := toNumber(args[0])
+ if ek != ErrNone {
+ return errValue(ek)
+ }
+ if x < 0 {
+ return errValue(ErrNum)
+ }
+ return Value{Kind: KindNum, Num: math.Sqrt(x)}
+}
+
+func fnPower(args []Value) Value {
+ x, y, errv, ok := twoNumArgs(args)
+ if !ok {
+ return errv
+ }
+ return Value{Kind: KindNum, Num: math.Pow(x, y)}
+}
+
+// fnMod implements MOD the way Excel does: the result takes the sign of
+// the divisor (n - d*floor(n/d)), not the sign of the dividend the way
+// Go's own % operator would give — MOD(-10,3) is 2, not -1.
+func fnMod(args []Value) Value {
+ x, y, errv, ok := twoNumArgs(args)
+ if !ok {
+ return errv
+ }
+ if y == 0 {
+ return errValue(ErrDiv0)
+ }
+ return Value{Kind: KindNum, Num: x - y*math.Floor(x/y)}
+}
diff --git a/internal/sheets/calc/fn_math_test.go b/internal/sheets/calc/fn_math_test.go
new file mode 100644
index 0000000..cc1499b
--- /dev/null
+++ b/internal/sheets/calc/fn_math_test.go
@@ -0,0 +1,141 @@
+package calc
+
+import "testing"
+
+// evalNum evaluates src and requires the result to be KindNum. Like
+// mustParse (eval_test.go), it never puts src into a failure message —
+// formula text is cell content, and this codebase's discipline is that
+// cell content never appears in a string a person might read off a log
+// or a test report.
+func evalNum(t *testing.T, src string, env Env) float64 {
+ t.Helper()
+ v := Eval(mustParse(t, src), env)
+ if v.Kind != KindNum {
+ t.Fatalf("result Kind = %v, want KindNum (value %+v)", v.Kind, v)
+ }
+ return v.Num
+}
+
+func TestSum(t *testing.T) {
+ if got := evalNum(t, "SUM(1,2,3)", emptyEnv{}); got != 6 {
+ t.Errorf("SUM(1,2,3) = %v, want 6", got)
+ }
+}
+
+func TestSumIgnoresBlankAndText(t *testing.T) {
+ env := mapEnv{{1, 1}: {Kind: KindNum, Num: 5}, {1, 2}: {Kind: KindStr, Str: "x"}}
+ // A1=5 (num), B1="x" (str, ignored), C1 blank (never set, ignored).
+ if got := evalNum(t, "SUM(A1,B1,C1)", env); got != 5 {
+ t.Errorf("SUM(A1,B1,C1) = %v, want 5", got)
+ }
+}
+
+func TestSumPropagatesError(t *testing.T) {
+ env := mapEnv{{1, 1}: {Kind: KindErr, Err: ErrRef}}
+ v := Eval(mustParse(t, "SUM(A1,1)"), env)
+ if v.Kind != KindErr || v.Err != ErrRef {
+ t.Fatalf("SUM(A1,1) = %v, want #REF!", v)
+ }
+}
+
+func TestAverage(t *testing.T) {
+ if got := evalNum(t, "AVERAGE(2,4,6)", emptyEnv{}); got != 4 {
+ t.Errorf("AVERAGE(2,4,6) = %v, want 4", got)
+ }
+}
+
+func TestAverageOfNoNumbersIsDiv0(t *testing.T) {
+ v := Eval(mustParse(t, `AVERAGE("a","b")`), emptyEnv{})
+ if v.Kind != KindErr || v.Err != ErrDiv0 {
+ t.Fatalf(`AVERAGE("a","b") = %v, want #DIV/0!`, v)
+ }
+}
+
+// COUNT counts only numbers; COUNTA counts everything non-blank; neither
+// counts a blank cell — the exact distinction the brief calls out.
+func TestCountVsCounta(t *testing.T) {
+ env := mapEnv{
+ {1, 1}: {Kind: KindNum, Num: 1},
+ {1, 2}: {Kind: KindStr, Str: "x"},
+ {1, 3}: {Kind: KindBool, Bool: true},
+ // D1 (col 4) left unset: blank.
+ }
+ if got := evalNum(t, "COUNT(A1,B1,C1,D1)", env); got != 1 {
+ t.Errorf("COUNT = %v, want 1", got)
+ }
+ if got := evalNum(t, "COUNTA(A1,B1,C1,D1)", env); got != 3 {
+ t.Errorf("COUNTA = %v, want 3", got)
+ }
+}
+
+func TestMinMax(t *testing.T) {
+ if got := evalNum(t, "MIN(4,2,9,1)", emptyEnv{}); got != 1 {
+ t.Errorf("MIN = %v, want 1", got)
+ }
+ if got := evalNum(t, "MAX(4,2,9,1)", emptyEnv{}); got != 9 {
+ t.Errorf("MAX = %v, want 9", got)
+ }
+}
+
+func TestRoundHalfAwayFromZero(t *testing.T) {
+ if got := evalNum(t, "ROUND(2.5,0)", emptyEnv{}); got != 3 {
+ t.Errorf("ROUND(2.5,0) = %v, want 3", got)
+ }
+ if got := evalNum(t, "ROUND(-2.5,0)", emptyEnv{}); got != -3 {
+ t.Errorf("ROUND(-2.5,0) = %v, want -3", got)
+ }
+}
+
+func TestRoundUpDown(t *testing.T) {
+ if got := evalNum(t, "ROUNDUP(3.001,0)", emptyEnv{}); got != 4 {
+ t.Errorf("ROUNDUP(3.001,0) = %v, want 4", got)
+ }
+ if got := evalNum(t, "ROUNDDOWN(3.99,0)", emptyEnv{}); got != 3 {
+ t.Errorf("ROUNDDOWN(3.99,0) = %v, want 3", got)
+ }
+}
+
+func TestAbsSqrtPower(t *testing.T) {
+ if got := evalNum(t, "ABS(-7)", emptyEnv{}); got != 7 {
+ t.Errorf("ABS(-7) = %v, want 7", got)
+ }
+ if got := evalNum(t, "SQRT(16)", emptyEnv{}); got != 4 {
+ t.Errorf("SQRT(16) = %v, want 4", got)
+ }
+ if got := evalNum(t, "POWER(2,10)", emptyEnv{}); got != 1024 {
+ t.Errorf("POWER(2,10) = %v, want 1024", got)
+ }
+}
+
+func TestSqrtOfNegativeIsNum(t *testing.T) {
+ v := Eval(mustParse(t, "SQRT(-1)"), emptyEnv{})
+ if v.Kind != KindErr || v.Err != ErrNum {
+ t.Fatalf("SQRT(-1) = %v, want #NUM!", v)
+ }
+}
+
+// MOD takes the sign of the divisor, matching Excel — not Go's %.
+func TestModSignFollowsDivisor(t *testing.T) {
+ if got := evalNum(t, "MOD(-10,3)", emptyEnv{}); got != 2 {
+ t.Errorf("MOD(-10,3) = %v, want 2", got)
+ }
+ if got := evalNum(t, "MOD(10,-3)", emptyEnv{}); got != -2 {
+ t.Errorf("MOD(10,-3) = %v, want -2", got)
+ }
+}
+
+func TestModByZeroIsDiv0(t *testing.T) {
+ v := Eval(mustParse(t, "MOD(10,0)"), emptyEnv{})
+ if v.Kind != KindErr || v.Err != ErrDiv0 {
+ t.Fatalf("MOD(10,0) = %v, want #DIV/0!", v)
+ }
+}
+
+func TestMathWrongArgCountIsValueError(t *testing.T) {
+ for _, formula := range []string{"ROUND(1)", "ABS()", "ABS(1,2)", "POWER(1)", "MOD(1)"} {
+ v := Eval(mustParse(t, formula), emptyEnv{})
+ if v.Kind != KindErr || v.Err != ErrValue {
+ t.Errorf("%s = %v, want #VALUE!", formula, v)
+ }
+ }
+}
diff --git a/internal/sheets/calc/testdata/corpus/formulas.json b/internal/sheets/calc/testdata/corpus/formulas.json
index fe9815b..ec13d64 100644
--- a/internal/sheets/calc/testdata/corpus/formulas.json
+++ b/internal/sheets/calc/testdata/corpus/formulas.json
@@ -1,5 +1,31 @@
{
"note": "HAND-AUTHORED, NOT YET LIBREOFFICE-VERIFIED. tools/gencorpus (repo root) exists to regenerate this file from a real LibreOffice headless run, but no machine in the environment that wrote this file has soffice on PATH. Every \"want\" value below was computed by a person hand-tracing this package's arithmetic/string/lookup rules against well-established, widely-documented spreadsheet semantics -- not against an independent implementation. TestAgainstGoldenCorpus (corpus_test.go) currently proves self-consistency with that reasoning, not third-party agreement with a real spreadsheet. Regenerate with `go run ./tools/gencorpus -out internal/sheets/calc/testdata/corpus/formulas.json` on a machine with LibreOffice installed, review the diff, then update corpusSHA256 in corpus_test.go and this note.",
"entries": [
+ {"function": "SUM", "expr": "SUM(1,2,3)", "want": {"kind": "num", "num": 6}},
+ {"function": "SUM", "expr": "SUM(1,2,3,4,5)", "want": {"kind": "num", "num": 15}},
+ {"function": "SUM", "expr": "SUM(-5,5)", "want": {"kind": "num", "num": 0}},
+ {"function": "SUM", "expr": "SUM(1.5,2.5)", "want": {"kind": "num", "num": 4}},
+ {"function": "AVERAGE", "expr": "AVERAGE(2,4,6)", "want": {"kind": "num", "num": 4}},
+ {"function": "AVERAGE", "expr": "AVERAGE(1,2,3,4)", "want": {"kind": "num", "num": 2.5}},
+ {"function": "COUNT", "expr": "COUNT(1,2,\"a\",TRUE)", "want": {"kind": "num", "num": 2}},
+ {"function": "COUNTA", "expr": "COUNTA(1,2,\"a\",TRUE)", "want": {"kind": "num", "num": 4}},
+ {"function": "MIN", "expr": "MIN(4,2,9,1)", "want": {"kind": "num", "num": 1}},
+ {"function": "MAX", "expr": "MAX(4,2,9,1)", "want": {"kind": "num", "num": 9}},
+ {"function": "ROUND", "expr": "ROUND(3.14159,2)", "want": {"kind": "num", "num": 3.14}},
+ {"function": "ROUND", "expr": "ROUND(2.5,0)", "want": {"kind": "num", "num": 3}},
+ {"function": "ROUND", "expr": "ROUND(-2.5,0)", "want": {"kind": "num", "num": -3}},
+ {"function": "ROUNDUP", "expr": "ROUNDUP(3.14159,2)", "want": {"kind": "num", "num": 3.15}},
+ {"function": "ROUNDUP", "expr": "ROUNDUP(3.001,0)", "want": {"kind": "num", "num": 4}},
+ {"function": "ROUNDDOWN", "expr": "ROUNDDOWN(3.99,0)", "want": {"kind": "num", "num": 3}},
+ {"function": "ROUNDDOWN", "expr": "ROUNDDOWN(-3.99,0)", "want": {"kind": "num", "num": -3}},
+ {"function": "ABS", "expr": "ABS(-7)", "want": {"kind": "num", "num": 7}},
+ {"function": "ABS", "expr": "ABS(7)", "want": {"kind": "num", "num": 7}},
+ {"function": "SQRT", "expr": "SQRT(16)", "want": {"kind": "num", "num": 4}},
+ {"function": "SQRT", "expr": "SQRT(2)", "want": {"kind": "num", "num": 1.4142135623730951}},
+ {"function": "POWER", "expr": "POWER(2,10)", "want": {"kind": "num", "num": 1024}},
+ {"function": "POWER", "expr": "POWER(9,0.5)", "want": {"kind": "num", "num": 3}},
+ {"function": "MOD", "expr": "MOD(10,3)", "want": {"kind": "num", "num": 1}},
+ {"function": "MOD", "expr": "MOD(-10,3)", "want": {"kind": "num", "num": 2}},
+ {"function": "MOD", "expr": "MOD(10,-3)", "want": {"kind": "num", "num": -2}}
]
}