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
4 files changed,
+442
−1
internal/sheets/calc/corpus_test.go+1 −1internal/sheets/calc/fn_math.go+274 −0internal/sheets/calc/fn_math_test.go+141 −0internal/sheets/calc/testdata/corpus/formulas.json+26 −0
Intent
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}} |
| ] |
| } |