Files
simplexcalc/internal/calc/calc_test.go
T
clawbot ac721390de
check / check (push) Successful in 1m7s
Exponentiation and modulo in the calculator (closes #3)
The calculator now takes `^` (or `**`) for powers and `%` for modulo. Powers bind tighter than a sign on their left and group to the right, so `-2^2` is `-4` and `2^3^2` is `512`; `%` sits with `*` and `/` and takes the sign of the divisor. A small parser of our own replaces `go/parser`, which cannot express `^`; `go/constant` still computes.

A whole-number exponent is exact; a fractional one is computed in float64. Every number is held as a fraction under 4096 bits, and a float64 result must be a normal double, so one message cannot stall the bot; anything else gets "That needs a number too large or too small for me."

Disclosure: tiny values below about 2.2e-308, which `next` answered, are now refused.

Model: opus-5-5
2026-09-29 04:28:15 +02:00

364 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package calc_test
import (
"errors"
"strings"
"testing"
"time"
"sneak.berlin/go/simplexcalc/internal/calc"
)
// TestEvaluate covers the two examples the bot was specified with, and
// the arithmetic around them.
func TestEvaluate(t *testing.T) {
t.Parallel()
expectResults(t, map[string]string{
// The specification's own examples.
"2 + 2": "4",
"5 * 5/2": "12.5",
"2+2": "4",
" 7 - 10 \n": "-3",
"-3 * 2": "-6",
"+4": "4",
"-(-4)": "4",
"(1 + 2) * 3": "9",
"1 + 2 * 3": "7",
"((2))": "2",
"8 / 2 / 2": "2",
"10 - 2 - 3": "5",
"7 / 2": "3.5",
"25/2": "12.5",
"1 / 3": "0.3333333333333333",
"2 / 3": "0.6666666666666666",
"0.1 + 0.2": "0.3",
"1.5 * 2": "3",
"2.50 * 2": "5",
".5 + .5": "1",
"3. * 2": "6",
"1e3 + 1": "1001",
"2.5e-1": "0.25",
"010 + 1": "11",
"-0": "0",
"0 / 5": "0",
// Exact: a float64 would print 99999999980000000000.
"9999999999 * 9999999999": "99999999980000000001",
"1e21": "1e+21",
"1e20": "100000000000000000000",
"1 / 1e7": "1e-07",
"1 / 1e6": "0.000001",
"1234567.5": "1234567.5",
"-1 / 4": "-0.25",
"1e300 * 1e8": "1e+308",
})
}
// TestEvaluatePowers: ^ and ** are one operator, binding tighter than
// * / % and a sign on its left, and grouping to the right.
func TestEvaluatePowers(t *testing.T) {
t.Parallel()
expectResults(t, map[string]string{
"2^3": "8",
"2**3": "8",
"2 ** 3 ^ 2": "512",
"2^3^2": "512",
"(2^3)^2": "64",
"-2^2": "-4",
"(-2)^2": "4",
"(-2)^3": "-8",
"(-2)^-3": "-0.125",
"2^-1": "0.5",
"2**-1": "0.5",
"-2^-2": "-0.25",
"2^-3^2": "0.001953125",
"2 * 3^2": "18",
"3^2 * 2": "18",
"2^3 / 2^2": "2",
"1 + 2^3 - 3^2": "0",
"010^2": "100",
"0.1^2": "0.01",
"2^100 - 2^100 + 1": "1",
"2^64": "18446744073709551616",
"0^0": "1",
"0^3": "0",
"1.5^2": "2.25",
"2^0.5": "1.4142135623730951",
"-2^0.5": "-1.4142135623730951",
"4^0.5": "2",
"0^0.5": "0",
"2^1023": "8.98846567431158e+307",
"2^-1022": "2.2250738585072014e-308",
// Past 2^53 a float64 cannot tell odd from even.
"(-1)^(2^53 + 1)": "-1",
"(-1)^(10^30)": "1",
"(-1)^-9223372036854775808": "1",
// Whole powers beyond the range of a double, held exactly.
"2^-1400 * 2^1365 * 2^35": "1",
"0.3^900 * 10^470": "0.25652473503365386",
"2^1500 / 2^1000": "3.273390607896142e+150",
})
}
// TestEvaluateModulo: % sits with * and /, left to right, and its result
// takes the sign of the divisor.
func TestEvaluateModulo(t *testing.T) {
t.Parallel()
expectResults(t, map[string]string{
"7 % 3": "1",
"-7 % 3": "2",
"7 % -3": "-2",
"-7 % -3": "-1",
"6 % 3": "0",
"-6 % 3": "0",
"7.5 % 2": "1.5",
"0.3 % 0.1": "0",
"-0.3 % 0.2": "0.1",
"10 % 4 * 3": "6",
"2 * 7 % 4": "2",
"1 + 7 % 3": "2",
"2^10 % 7": "2",
"10^400 % 7": "4",
"1e-30 % 1": "1e-30",
"-1e-30 % 1": "1",
"10 / 8 % 1": "0.25",
"(7 % 3)^2": "1",
"7 % (3 ^ 2)": "7",
// Both operands and their quotient are held exactly, but y times
// the whole part of x/y is too large to be.
"(5^860*3^630/7) % (5^860/2^998/2^998)": "0.5179219763783696",
// A whole number made from x^0, just below the 4096-bit limit.
"(3^0 + 3^0 + 3^0)^2583 % 10": "7",
})
}
// expectResults checks that each expression evaluates to its result.
func expectResults(t *testing.T, cases map[string]string) {
t.Helper()
for in, want := range cases {
t.Run(in, func(t *testing.T) {
t.Parallel()
got, err := calc.Evaluate(in)
if err != nil {
t.Fatalf("Evaluate(%q) failed: %v", in, err)
}
if got != want {
t.Errorf("Evaluate(%q) = %q, want %q", in, got, want)
}
})
}
}
// TestEvaluateRefuses covers what must be answered with an error rather
// than a number.
func TestEvaluateRefuses(t *testing.T) {
t.Parallel()
expectErrors(t, map[string]error{
"": calc.ErrNotArithmetic,
" ": calc.ErrNotArithmetic,
"hello": calc.ErrNotArithmetic,
"/help": calc.ErrNotArithmetic,
"2 +": calc.ErrNotArithmetic,
"2 2": calc.ErrNotArithmetic,
"2 + 2 =": calc.ErrNotArithmetic,
"x + 1": calc.ErrNotArithmetic,
"len(\"abc\")": calc.ErrNotArithmetic,
"\"a\" + \"b\"": calc.ErrNotArithmetic,
"'a' + 1": calc.ErrNotArithmetic,
"2i * 2i": calc.ErrNotArithmetic,
"0x10 + 1": calc.ErrNotArithmetic,
"1_000 + 1": calc.ErrNotArithmetic,
"1 << 10": calc.ErrNotArithmetic,
"1 == 1": calc.ErrNotArithmetic,
"!1": calc.ErrNotArithmetic,
"func() int { return 1 }()": calc.ErrNotArithmetic,
"(1 + 2": calc.ErrNotArithmetic,
"1 + 2)": calc.ErrNotArithmetic,
"()": calc.ErrNotArithmetic,
"(2)(3)": calc.ErrNotArithmetic,
"2 ^": calc.ErrNotArithmetic,
"^ 2": calc.ErrNotArithmetic,
"2 ^^ 3": calc.ErrNotArithmetic,
"2 *** 3": calc.ErrNotArithmetic,
"2 * * 3": calc.ErrNotArithmetic,
"% 3": calc.ErrNotArithmetic,
"50%": calc.ErrNotArithmetic,
"2 × 3": calc.ErrNotArithmetic,
"1 / 0": calc.ErrDivisionByZero,
"1 / (2 - 2)": calc.ErrDivisionByZero,
"5 / 0.0": calc.ErrDivisionByZero,
"7 % 0": calc.ErrDivisionByZero,
"7.5 % (1 - 1)": calc.ErrDivisionByZero,
"0^-1": calc.ErrDivisionByZero,
"0^-0.5": calc.ErrDivisionByZero,
"(-2)^0.5": calc.ErrNoRealResult,
"(-8)^(1/3)": calc.ErrNoRealResult,
"(-1)^-0.5": calc.ErrNoRealResult,
})
}
// TestEvaluateOutOfRange: a number is held exactly, or computed in
// float64 as a normal double, and a result is written as a normal
// double. Anything else is refused.
func TestEvaluateOutOfRange(t *testing.T) {
t.Parallel()
expectErrors(t, map[string]error{
// Results that are not normal doubles: 2^-1074 would be written
// 5e-324.
"1e400": calc.ErrOutOfRange,
"1e300 * 1e300": calc.ErrOutOfRange,
"1e999999999 * 1e999999999": calc.ErrOutOfRange,
"1 / 1e-400": calc.ErrOutOfRange,
"2^1024": calc.ErrOutOfRange,
"2^5000": calc.ErrOutOfRange,
"(-2)^5001": calc.ErrOutOfRange,
"0.5^-5000": calc.ErrOutOfRange,
"2^-1074": calc.ErrOutOfRange,
"2^-1400": calc.ErrOutOfRange,
"-1e-310": calc.ErrOutOfRange,
// Powers computed in float64 whose base or result is not a
// normal double, and so has lost digits, or all of them.
"2^-1073.5 * 2^1073": calc.ErrOutOfRange,
"1e400^-0.001": calc.ErrOutOfRange,
"1e-400^0.001": calc.ErrOutOfRange,
"1e-310^0.5": calc.ErrOutOfRange,
"(0.5^1100)^4 / (0.5^1100)^4": calc.ErrOutOfRange,
"(1/3)^1e400": calc.ErrOutOfRange,
// go/constant holds numbers of this size rounded. A sum of them
// can lose the answer (this one would be 0), and so can a
// remainder or the sign of -1 to such a power.
"7^1000 * 7^1000 + 5 - 7^1000 * 7^1000": calc.ErrOutOfRange,
"7^1000 * 7^1000 / 7^1000 % 10": calc.ErrOutOfRange,
"(-1)^(3^1365 * 3^1365 / 3^1365)": calc.ErrOutOfRange,
"(-1)^1e1300": calc.ErrOutOfRange,
"1e-1300": calc.ErrOutOfRange,
"1e-1300 + 1": calc.ErrOutOfRange,
"1e-700 * 1e-700": calc.ErrOutOfRange,
"0.1^800 * 0.1^800": calc.ErrOutOfRange,
// Both operands are held exactly, but their quotient is not.
"3^1365 % 7^-1000": calc.ErrOutOfRange,
// The same limit for a whole number made from x^0, which
// go/constant would hold as an integer and never round, and for
// a literal it reads exactly as a fraction past the limit.
"(2^0 + 2^0)^4095 % 10": calc.ErrOutOfRange,
"1e-1233 * 0": calc.ErrOutOfRange,
// go/constant reads this literal as 0.
"1e-999999999": calc.ErrOutOfRange,
"1 / 1e-999999999": calc.ErrOutOfRange,
})
}
// expectErrors checks that each expression is refused with its error,
// and never with a panic.
func expectErrors(t *testing.T, cases map[string]error) {
t.Helper()
for in, want := range cases {
t.Run(in, func(t *testing.T) {
t.Parallel()
got, err := calc.Evaluate(in)
if !errors.Is(err, want) {
t.Errorf("Evaluate(%q) = %q, %v; want error %v", in, got, err, want)
}
})
}
}
// TestEvaluateBoundsWork: computed exactly, each of these powers would
// need more time and memory than any machine has. They must be answered
// at once.
func TestEvaluateBoundsWork(t *testing.T) {
t.Parallel()
cases := []struct {
in string
want string
err error
}{
{in: "9^9^9^9^9", err: calc.ErrOutOfRange},
{in: "((9^999)^999)^999", err: calc.ErrOutOfRange},
{in: "(3^2583)^4096", err: calc.ErrOutOfRange},
{in: "1.0000001^99999", want: "1.01005006557947"},
{in: "0.5^99999999999999999999", err: calc.ErrOutOfRange},
{in: "2^-9223372036854775808", err: calc.ErrOutOfRange},
{in: "(-1)^99999999999999999999", want: "-1"},
// The longest tower that fits.
{in: strings.Repeat("9^", 127) + "9", err: calc.ErrOutOfRange},
// The largest power of 3 computed exactly, as often as fits.
{in: "0" + strings.Repeat("*3^2583", 36), want: "0"},
// Whole numbers made from x^0, through each operation. Held as
// integers, which go/constant never rounds, they would escape
// the 4096-bit limit: the first needs about 69 billion bits.
{in: "(((2^0+2^0)^4096)^4096)^4096", err: calc.ErrOutOfRange},
{in: "(((0^0+0^0)^4096)^4096)^4096", err: calc.ErrOutOfRange},
{in: "(((-2^0-2^0)^4096)^4096)^4096", err: calc.ErrOutOfRange},
{in: "((2^0+2^0)^4000*(2^0+2^0)^4000)^4096", err: calc.ErrOutOfRange},
{in: "((((2^0+2^0)/2^0)^4096)^4096)^4096", err: calc.ErrOutOfRange},
{in: "((((2^0+2^0) % 3)^4096)^4096)^4096", err: calc.ErrOutOfRange},
{in: "(((2^0+2^0)^4096)^4096)^4096 * 0", err: calc.ErrOutOfRange},
// A fraction whose numerator and denominator are both just below
// the limit, and a literal whose exponent is too large to read.
{in: "(3^2583/5^1760)^4096", err: calc.ErrOutOfRange},
{in: "1e99999999999999999999", err: calc.ErrOutOfRange},
}
for _, c := range cases {
t.Run(c.in, func(t *testing.T) {
t.Parallel()
start := time.Now()
got, err := calc.Evaluate(c.in)
if elapsed := time.Since(start); elapsed > time.Second {
t.Errorf("Evaluate(%q) took %v", c.in, elapsed)
}
if !errors.Is(err, c.err) || got != c.want {
t.Errorf("Evaluate(%q) = %q, %v; want %q, %v", c.in, got, err, c.want, c.err)
}
})
}
}
// TestEvaluateCapsInput: the length cap is what bounds the work a
// message can cause, so it must hold exactly at the boundary.
func TestEvaluateCapsInput(t *testing.T) {
t.Parallel()
// "1+1+...+1" with the last term padded to land exactly on the cap.
longest := strings.Repeat("1+", calc.MaxInputLength/2-1) + "10"
if len(longest) != calc.MaxInputLength {
t.Fatalf("test setup: expression is %d bytes, want %d",
len(longest), calc.MaxInputLength)
}
got, err := calc.Evaluate(longest)
if err != nil {
t.Fatalf("an expression of exactly MaxInputLength bytes was refused: %v", err)
}
if want := "137"; got != want {
t.Errorf("Evaluate(longest) = %q, want %q", got, want)
}
_, err = calc.Evaluate(longest + "0")
if !errors.Is(err, calc.ErrTooLong) {
t.Errorf("an expression over MaxInputLength gave %v, want ErrTooLong", err)
}
// Surrounding whitespace is not part of the expression.
_, err = calc.Evaluate(" " + longest + "\n")
if err != nil {
t.Errorf("whitespace around a maximal expression counted against the cap: %v", err)
}
}