196 lines
6.5 KiB
TypeScript
196 lines
6.5 KiB
TypeScript
|
|
import { describe, expect, test } from 'bun:test'
|
||
|
|
|
||
|
|
import {
|
||
|
|
CALC_FUNCTIONS,
|
||
|
|
CalcSyntaxError,
|
||
|
|
evaluateExpression
|
||
|
|
} from '@open-pencil/core/tools/calc/expression'
|
||
|
|
|
||
|
|
describe('calc expression evaluator', () => {
|
||
|
|
test.each([
|
||
|
|
['844 - 56 - 96 - 82', 610],
|
||
|
|
['1440 * 8 / 12', 960],
|
||
|
|
['(952 - 16) / 2', 468],
|
||
|
|
[' 8 * 4 ', 32],
|
||
|
|
['2 + 2 * 2', 6],
|
||
|
|
['(2 + 2) * 2', 8],
|
||
|
|
['7 / 2', 3.5],
|
||
|
|
['10 % 3', 1],
|
||
|
|
['-10 % 3', -1],
|
||
|
|
['1e3', 1000],
|
||
|
|
['1E3 + 1', 1001],
|
||
|
|
['.5 + 1', 1.5],
|
||
|
|
['1.', 1],
|
||
|
|
['2.5e-2', 0.025]
|
||
|
|
])('evaluates %s', (expression, expected) => {
|
||
|
|
expect(evaluateExpression(expression)).toBe(expected)
|
||
|
|
})
|
||
|
|
|
||
|
|
test.each([
|
||
|
|
['-5', -5],
|
||
|
|
['+5', 5],
|
||
|
|
['3 - -2', 5],
|
||
|
|
['3 * -2', -6],
|
||
|
|
['-(2 + 3)', -5]
|
||
|
|
])('applies the sign in %s', (expression, expected) => {
|
||
|
|
expect(evaluateExpression(expression)).toBe(expected)
|
||
|
|
})
|
||
|
|
|
||
|
|
describe('exponentiation', () => {
|
||
|
|
test('is right-associative', () => {
|
||
|
|
expect(evaluateExpression('2 ** 3 ** 2')).toBe(512)
|
||
|
|
})
|
||
|
|
|
||
|
|
// jsep binds a leading sign tighter than `**`, so `-2 ** 2` is `(-2) ** 2`.
|
||
|
|
// Pinned because Python and ordinary notation instead read it as `-(2 ** 2)`.
|
||
|
|
test('applies a leading sign before the exponent', () => {
|
||
|
|
expect(evaluateExpression('-2 ** 2')).toBe(4)
|
||
|
|
expect(evaluateExpression('(-2) ** 2')).toBe(4)
|
||
|
|
expect(evaluateExpression('-(2 ** 2)')).toBe(-4)
|
||
|
|
})
|
||
|
|
|
||
|
|
test('binds tighter than multiplication and accepts a signed exponent', () => {
|
||
|
|
expect(evaluateExpression('3 * 2 ** 3')).toBe(24)
|
||
|
|
expect(evaluateExpression('2 ** -2')).toBe(0.25)
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
describe('functions', () => {
|
||
|
|
test.each([
|
||
|
|
['floor(390 * 0.6)', 234],
|
||
|
|
['ceil(1.2)', 2],
|
||
|
|
['round(2.5)', 3],
|
||
|
|
['round(-2.5)', -2],
|
||
|
|
['abs(-4)', 4],
|
||
|
|
['sqrt(16)', 4],
|
||
|
|
['pow(2, 10)', 1024],
|
||
|
|
['min(3, 1, 2)', 1],
|
||
|
|
['max(3, 1, 2)', 3],
|
||
|
|
['min(3)', 3],
|
||
|
|
['max(1 + 1, 3 - 2)', 2],
|
||
|
|
['floor(min(4.7, 9))', 4]
|
||
|
|
])('evaluates %s', (expression, expected) => {
|
||
|
|
expect(evaluateExpression(expression)).toBe(expected)
|
||
|
|
})
|
||
|
|
|
||
|
|
test('rejects a wrong argument count', () => {
|
||
|
|
expect(() => evaluateExpression('round(2.345, 2)')).toThrow(/takes 1 argument, received 2/)
|
||
|
|
expect(() => evaluateExpression('pow(2)')).toThrow(/takes 2 arguments, received 1/)
|
||
|
|
})
|
||
|
|
|
||
|
|
test.each(['min', 'max'])('folds a long %s argument list without spreading it', (name) => {
|
||
|
|
// Spreading overflows the call stack on V8 at roughly 125k arguments.
|
||
|
|
const args = Array.from({ length: 200_000 }, (_, index) => index)
|
||
|
|
expect(evaluateExpression(`${name}(${args.join(',')})`)).toBe(name === 'min' ? 0 : 199_999)
|
||
|
|
})
|
||
|
|
|
||
|
|
test('names the supported functions when one is unknown', () => {
|
||
|
|
expect(() => evaluateExpression('sin(0)')).toThrow(/Unknown function 'sin'/)
|
||
|
|
for (const name of CALC_FUNCTIONS) {
|
||
|
|
expect(() => evaluateExpression('sin(0)')).toThrow(new RegExp(`\\b${name}\\b`))
|
||
|
|
}
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
describe('results outside the real numbers', () => {
|
||
|
|
test.each([
|
||
|
|
['1 / 0', Number.POSITIVE_INFINITY],
|
||
|
|
['-1 / 0', Number.NEGATIVE_INFINITY]
|
||
|
|
])('returns %s as a non-finite number for the caller to report', (expression, expected) => {
|
||
|
|
expect(evaluateExpression(expression)).toBe(expected)
|
||
|
|
})
|
||
|
|
|
||
|
|
test.each(['0 / 0', 'sqrt(-1)', '0 % 0'])('returns NaN for %s', (expression) => {
|
||
|
|
expect(evaluateExpression(expression)).toBeNaN()
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
describe('input the evaluator refuses', () => {
|
||
|
|
test.each([
|
||
|
|
['', 'Expression is empty'],
|
||
|
|
[' ', 'Expression is empty'],
|
||
|
|
['1 +', 'Expected expression after +'],
|
||
|
|
['(1 + 2', 'Unclosed ('],
|
||
|
|
['1 + 2)', 'Unexpected ")"'],
|
||
|
|
['pow(2, 10', 'Expected )'],
|
||
|
|
['5!', 'missing unaryOp argument'],
|
||
|
|
['1_000 + 1', 'Variable names cannot start with a number'],
|
||
|
|
['0x10', 'Variable names cannot start with a number'],
|
||
|
|
['{"a": 1}', 'Unexpected "{"']
|
||
|
|
])("reports the parser's own message for %s", (expression, message) => {
|
||
|
|
expect(() => evaluateExpression(expression)).toThrow(CalcSyntaxError)
|
||
|
|
expect(() => evaluateExpression(expression)).toThrow(message)
|
||
|
|
})
|
||
|
|
|
||
|
|
test.each([
|
||
|
|
['1 2', 'more than one expression'],
|
||
|
|
['1 ; 2', 'more than one expression'],
|
||
|
|
['3 and 4', 'more than one expression'],
|
||
|
|
['floor 1', 'more than one expression'],
|
||
|
|
['x + 1', 'a name'],
|
||
|
|
['PI', 'a name'],
|
||
|
|
['12 + $', 'a name'],
|
||
|
|
['a.b', 'property access'],
|
||
|
|
['[1,2][0]', 'property access'],
|
||
|
|
['1 ? 2 : 3', 'a conditional']
|
||
|
|
])('names the construct it refuses in %s', (expression, description) => {
|
||
|
|
expect(() => evaluateExpression(expression)).toThrow(CalcSyntaxError)
|
||
|
|
expect(() => evaluateExpression(expression)).toThrow(description)
|
||
|
|
})
|
||
|
|
|
||
|
|
test.each([
|
||
|
|
['2 < 3', "Unsupported operator '<'"],
|
||
|
|
['1 == 1', "Unsupported operator '=='"],
|
||
|
|
['1 || 2', "Unsupported operator '||'"],
|
||
|
|
['3 & 4', "Unsupported operator '&'"]
|
||
|
|
])('refuses the non-arithmetic operator in %s', (expression, message) => {
|
||
|
|
expect(() => evaluateExpression(expression)).toThrow(message)
|
||
|
|
})
|
||
|
|
|
||
|
|
test.each([
|
||
|
|
['"a" + "b"', 'is not a number'],
|
||
|
|
['true', "'true' is not a number"],
|
||
|
|
['null', "'null' is not a number"]
|
||
|
|
])('refuses the non-numeric literal in %s', (expression, message) => {
|
||
|
|
expect(() => evaluateExpression(expression)).toThrow(message)
|
||
|
|
})
|
||
|
|
|
||
|
|
test.each([
|
||
|
|
['random()', "Unknown function 'random'"],
|
||
|
|
['if(1, 2, 3)', "Unknown function 'if'"],
|
||
|
|
['sin(0)', "Unknown function 'sin'"]
|
||
|
|
])('refuses the undocumented function in %s', (expression, message) => {
|
||
|
|
expect(() => evaluateExpression(expression)).toThrow(message)
|
||
|
|
})
|
||
|
|
|
||
|
|
test('keeps the character position the parser reports', () => {
|
||
|
|
expect(() => evaluateExpression('(1 + 2')).toThrow('character 6')
|
||
|
|
})
|
||
|
|
|
||
|
|
test.each([
|
||
|
|
'constructor(1)',
|
||
|
|
'toString(1)',
|
||
|
|
'valueOf(1)',
|
||
|
|
'hasOwnProperty(1)',
|
||
|
|
'__defineGetter__(1)'
|
||
|
|
])('refuses %s, an inherited name rather than a function', (expression) => {
|
||
|
|
expect(() => evaluateExpression(expression)).toThrow(CalcSyntaxError)
|
||
|
|
expect(() => evaluateExpression(expression)).toThrow('Unknown function')
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
test.each([
|
||
|
|
'constructor',
|
||
|
|
'globalThis',
|
||
|
|
'this',
|
||
|
|
'process',
|
||
|
|
'Math',
|
||
|
|
'Math.random()',
|
||
|
|
'Function("return 1")()',
|
||
|
|
'__proto__',
|
||
|
|
'constructor.constructor("return 1")()'
|
||
|
|
])('evaluates %s without reaching a host object', (expression) => {
|
||
|
|
expect(() => evaluateExpression(expression)).toThrow(CalcSyntaxError)
|
||
|
|
})
|
||
|
|
})
|