Skip to content

Instantly share code, notes, and snippets.

@tkrotoff
Last active April 9, 2024 14:45
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save tkrotoff/b0b1d39da340f5fc6c5e2a79a8b6cec0 to your computer and use it in GitHub Desktop.
Save tkrotoff/b0b1d39da340f5fc6c5e2a79a8b6cec0 to your computer and use it in GitHub Desktop.
Calculator function using shunting yard algorithm and reverse Polish notation (RPN)
// https://gist.github.com/tkrotoff/b0b1d39da340f5fc6c5e2a79a8b6cec0
// WTF!
// parseFloat('-0') => -0 vs parseFloat(-0) => 0
// -0 === 0 => true vs Object.is(-0, 0) => false
const minus0Hack = (value: number) => (Object.is(value, -0) ? '-0' : value);
export const operators: {
[operator: string]:
| {
func: (...args: string[]) => string;
precedence: number;
associativity: 'left' | 'right';
arity: number; // Needed by evalReversePolishNotation()
}
| undefined;
} = {
'+': {
func: (x, y) => `${minus0Hack(Number(x) + Number(y))}`,
precedence: 1,
associativity: 'left',
arity: 2
},
'-': {
func: (x, y) => `${minus0Hack(Number(x) - Number(y))}`,
precedence: 1,
associativity: 'left',
arity: 2
},
'*': {
func: (x, y) => `${minus0Hack(Number(x) * Number(y))}`,
precedence: 2,
associativity: 'left',
arity: 2
},
'/': {
func: (x, y) => `${minus0Hack(Number(x) / Number(y))}`,
precedence: 2,
associativity: 'left',
arity: 2
},
'%': {
func: (x, y) => `${minus0Hack(Number(x) % Number(y))}`,
precedence: 2,
associativity: 'left',
arity: 2
},
'^': {
// Why Math.pow() instead of **?
// -2 ** 2 => "SyntaxError: Unary operator used immediately before exponentiation expression..."
// Math.pow(-2, 2) => -4
// eslint-disable-next-line prefer-exponentiation-operator, no-restricted-properties
func: (x, y) => `${minus0Hack(Math.pow(Number(x), Number(y)))}`,
precedence: 3,
associativity: 'right',
arity: 2
}
};
export const operatorsKeys = Object.keys(operators);
export const functions: {
[operator: string]:
| {
func: (...args: string[]) => string;
// Needed by evalReversePolishNotation()
arity: number;
}
| undefined;
} = {
min: { func: (x, y) => `${minus0Hack(Math.min(Number(x), Number(y)))}`, arity: 2 },
max: { func: (x, y) => `${minus0Hack(Math.max(Number(x), Number(y)))}`, arity: 2 },
sin: { func: x => `${minus0Hack(Math.sin(Number(x)))}`, arity: 1 },
cos: { func: x => `${minus0Hack(Math.cos(Number(x)))}`, arity: 1 },
tan: { func: x => `${minus0Hack(Math.tan(Number(x)))}`, arity: 1 },
log: { func: x => `${Math.log(Number(x))}`, arity: 1 } // No need for -0 hack
};
export const functionsKeys = Object.keys(functions);
const top = (stack: string[]): string | undefined => stack[stack.length - 1];
/**
* Shunting yard algorithm: converts infix expression to postfix expression (reverse Polish notation)
*
* Example: ['1', '+', '2'] => ['1', '2', '+']
*
* https://en.wikipedia.org/wiki/Shunting_yard_algorithm
* https://github.com/poteat/shunting-yard-typescript
* https://blog.kallisti.net.nz/2008/02/extension-to-the-shunting-yard-algorithm-to-allow-variable-numbers-of-arguments-to-functions/
*/
export function shuntingYard(tokens: string[]) {
const output = new Array<string>();
const operatorStack = new Array<string>();
for (const token of tokens) {
if (functions[token] !== undefined) {
operatorStack.push(token);
} else if (token === ',') {
while (operatorStack.length > 0 && top(operatorStack) !== '(') {
output.push(operatorStack.pop()!);
}
if (operatorStack.length === 0) {
throw new Error("Misplaced ','");
}
} else if (operators[token] !== undefined) {
const o1 = token;
while (
operatorStack.length > 0 &&
top(operatorStack) !== undefined &&
top(operatorStack) !== '(' &&
(operators[top(operatorStack)!]!.precedence > operators[o1]!.precedence ||
(operators[o1]!.precedence === operators[top(operatorStack)!]!.precedence &&
operators[o1]!.associativity === 'left'))
) {
output.push(operatorStack.pop()!); // o2
}
operatorStack.push(o1);
} else if (token === '(') {
operatorStack.push(token);
} else if (token === ')') {
while (operatorStack.length > 0 && top(operatorStack) !== '(') {
output.push(operatorStack.pop()!);
}
if (operatorStack.length > 0 && top(operatorStack) === '(') {
operatorStack.pop();
} else {
throw new Error('Parentheses mismatch');
}
if (functions[top(operatorStack)!] !== undefined) {
output.push(operatorStack.pop()!);
}
} else {
output.push(token);
}
}
// Remaining items
while (operatorStack.length > 0) {
const operator = top(operatorStack);
if (operator === '(') {
throw new Error('Parentheses mismatch');
} else {
output.push(operatorStack.pop()!);
}
}
return output;
}
/**
* Evaluates reverse Polish notation (RPN) (postfix expression).
*
* Example: ['1', '2', '+'] => 3
*
* https://en.wikipedia.org/wiki/Reverse_Polish_notation
* https://github.com/poteat/shunting-yard-typescript
*/
export function evalReversePolishNotation(tokens: string[]) {
const stack = new Array<string>();
const ops = { ...operators, ...functions };
for (const token of tokens) {
const op = ops[token];
// eslint-disable-next-line unicorn/no-negated-condition
if (op !== undefined) {
const parameters = [];
for (let i = 0; i < op.arity; i++) {
parameters.push(stack.pop()!);
}
stack.push(op.func(...parameters.reverse()));
} else {
stack.push(token);
}
}
if (stack.length > 1) {
throw new Error('Insufficient operators');
}
return Number(stack[0]);
}
/**
* Breaks a mathematical expression into tokens.
*
* Example: "1 + 2" => [1, '+', 2]
*
* https://gist.github.com/tchayen/44c28e8d4230b3b05e9f
*/
export function tokenize(expression: string) {
// "1 +" => "1 +"
const expr = expression.replace(/\s+/g, ' ');
const tokens = [];
let acc = '';
let currentNumber = '';
for (let i = 0; i < expr.length; i++) {
const c = expr.charAt(i);
const prev_c = expr.charAt(i - 1); // '' if index out of range
const next_c = expr.charAt(i + 1); // '' if index out of range
const lastToken = top(tokens);
const numberParsingStarted = currentNumber !== '';
if (
// 1
/\d/.test(c) ||
// Unary operator: +1 or -1
((c === '+' || c === '-') &&
!numberParsingStarted &&
(lastToken === undefined ||
lastToken === ',' ||
lastToken === '(' ||
operatorsKeys.includes(lastToken)) &&
/\d/.test(next_c))
) {
currentNumber += c;
} else if (c === '.') {
if (numberParsingStarted && currentNumber.includes('.')) {
throw new Error(`Double '.' in number: '${currentNumber}${c}'`);
} else {
currentNumber += c;
}
} else if (c === ' ') {
if (/\d/.test(prev_c) && /\d/.test(next_c)) {
throw new Error(`Space in number: '${currentNumber}${c}${next_c}'`);
}
} else if (functionsKeys.includes(acc + c)) {
acc += c;
if (!functionsKeys.includes(acc + next_c)) {
tokens.push(acc);
acc = '';
}
} else if (operatorsKeys.includes(c) || c === '(' || c === ')' || c === ',') {
if (
operatorsKeys.includes(c) &&
!numberParsingStarted &&
operatorsKeys.includes(lastToken!)
) {
throw new Error(`Consecutive operators: '${lastToken!}${c}'`);
}
if (numberParsingStarted) {
tokens.push(currentNumber);
}
tokens.push(c);
currentNumber = '';
} else {
acc += c;
}
}
if (acc !== '') {
throw new Error(`Invalid characters: '${acc}'`);
}
// Add last number to the tokens
if (currentNumber !== '') {
tokens.push(currentNumber);
}
// ['+', '1'] => ['0', '+', '1']
// ['-', '1'] => ['0', '-', '1']
if (tokens[0] === '+' || tokens[0] === '-') {
tokens.unshift('0');
}
return tokens;
}
export function calculate(expression: string) {
const tokens = tokenize(expression);
const rpn = shuntingYard(tokens);
return evalReversePolishNotation(rpn);
}
// https://gist.github.com/tkrotoff/1a216f376cb4fba5bc7d8b5109c3a32e
// https://devblogs.microsoft.com/typescript/announcing-typescript-3-7/#assertion-functions
export function assert(_condition: boolean, _message?: string): asserts _condition {
// eslint-disable-next-line no-console, prefer-rest-params
console.assert(...arguments);
}
/* eslint-disable no-eval, unicorn/prefer-number-properties */
import { calculate, evalReversePolishNotation, shuntingYard, tokenize } from './calculator';
import { convertMathExpressionToEval, getRandomMathExpression } from './getRandomMathExpression';
import { getRandomInt } from './getRandomNumber';
test('shuntingYard()', () => {
{
// https://en.wikipedia.org/wiki/Shunting_yard_algorithm#Detailed_examples
const rpn = shuntingYard('3 + 4 * 2 / ( 1 - 5 ) ^ 2 ^ 3'.split(' '));
expect(rpn).toEqual(['3', '4', '2', '*', '1', '5', '-', '2', '3', '^', '^', '/', '+']);
}
{
// https://en.wikipedia.org/wiki/Shunting_yard_algorithm#Detailed_examples
const rpn = shuntingYard('sin ( max ( 2 3 ) / 3 * 3.14 )'.split(' '));
expect(rpn).toEqual(['2', '3', 'max', '3', '/', '3.14', '*', 'sin']);
}
// Parentheses mismatch
expect(() => shuntingYard(['('])).toThrow('Parentheses mismatch');
expect(() => shuntingYard([')'])).toThrow('Parentheses mismatch');
expect(() => shuntingYard('1 - ( 2 * 3 ) )'.split(' '))).toThrow('Parentheses mismatch');
expect(() => shuntingYard('1 - ( 2 * 3 ) ) + 4'.split(' '))).toThrow('Parentheses mismatch');
// Ignore ','
expect(shuntingYard('max ( 1 , 2 )'.split(' '))).toEqual(['1', '2', 'max']);
// if the token is: ',':
// while the operator at the top of the operator stack is not a left parenthesis:
// pop the operator from the operator stack into the output queue
expect(shuntingYard('max ( 0 + 1 , 2 )'.split(' '))).toEqual(['0', '1', '+', '2', 'max']);
// Misplaced ','
expect(() => shuntingYard('1 , 2'.split(' '))).toThrow("Misplaced ','");
expect(() => shuntingYard(', 1 / 2'.split(' '))).toThrow("Misplaced ','");
expect(() => shuntingYard('1 , / 2'.split(' '))).toThrow("Misplaced ','");
expect(() => shuntingYard('1 / , 2'.split(' '))).toThrow("Misplaced ','");
expect(() => shuntingYard('1 / 2 ,'.split(' '))).toThrow("Misplaced ','");
expect(() =>
shuntingYard('sin ( , max , ( , 2 , 3 , ) , / , 3 , * , 3.14 , )'.split(' '))
).not.toThrow();
// Edge cases
expect(shuntingYard([''])).toEqual(['']);
expect(shuntingYard([' '])).toEqual([' ']);
expect(shuntingYard(['1'])).toEqual(['1']);
expect(shuntingYard(['a'])).toEqual(['a']);
expect(shuntingYard(['1a'])).toEqual(['1a']);
expect(shuntingYard(['*'])).toEqual(['*']);
expect(shuntingYard(['/'])).toEqual(['/']);
// All together expression
expect(
shuntingYard(
'( ( 3.1 + cos ( -4 ) / 2 ) * max ( -6 , 6 ) ^ sin ( 6 ) * 9 ) / tan ( log ( 8.8 + -2 ) % 7 ) + ( 6 * -1 - min ( 6 , -4.2 ) )'.split(
' '
)
)
).toEqual(
'3.1 -4 cos 2 / + -6 6 max 6 sin ^ * 9 * 8.8 -2 + log 7 % tan / 6 -1 * 6 -4.2 min - +'.split(
' '
)
);
});
test('reversePolishNotation()', () => {
// https://rosettacode.org/wiki/Parsing/RPN_calculator_algorithm#JavaScript
expect(
evalReversePolishNotation(['3', '4', '2', '*', '1', '5', '-', '2', '3', '^', '^', '/', '+'])
).toEqual(3 + (4 * 2) / (1 - 5) ** (2 ** 3));
expect(
evalReversePolishNotation(['3', '4', '2', '*', '1', '5', '-', '2', '3', '^', '^', '/', '+'])
).toEqual(3.000_122_070_312_5);
// https://en.wikipedia.org/wiki/Shunting_yard_algorithm#Detailed_examples
expect(evalReversePolishNotation(['2', '3', 'max', '3', '/', '3.14', '*', 'sin'])).toEqual(
Math.sin((Math.max(2, 3) / 3) * 3.14)
);
expect(evalReversePolishNotation(['2', '3', 'max', '3', '/', '3.14', '*', 'sin'])).toEqual(
0.001_592_652_916_486_828_2
);
// Edge cases
expect(evalReversePolishNotation([''])).toEqual(0); // :-(
expect(evalReversePolishNotation([' '])).toEqual(0); // :-(
expect(evalReversePolishNotation(['1'])).toEqual(1);
expect(evalReversePolishNotation(['a'])).toBeNaN();
expect(evalReversePolishNotation(['1a'])).toBeNaN();
expect(evalReversePolishNotation(['*'])).toBeNaN();
expect(evalReversePolishNotation(['/'])).toBeNaN();
expect(() => evalReversePolishNotation(['1', '2'])).toThrow('Insufficient operators');
// All together expression
expect(
evalReversePolishNotation(
'3.1 -4 cos 2 / + -6 6 max 6 sin ^ * 9 * 8.8 -2 + log 7 % tan / 6 -1 * 6 -4.2 min - +'.split(
' '
)
)
).toEqual(
eval(
'((3.1 + Math.cos(-4) / 2) * Math.max(-6, 6) ** Math.sin(6) * 9) / Math.tan(Math.log(8.8 + -2) % 7) + (6 * -1 - Math.min(6, -4.2))'
)
);
});
test('tokenize()', () => {
// https://en.wikipedia.org/wiki/Shunting_yard_algorithm#Detailed_examples
expect(tokenize('3 + 4 * 2 / (1 - 5) ^ 2 ^ 3')).toEqual(
'3 + 4 * 2 / ( 1 - 5 ) ^ 2 ^ 3'.split(' ')
);
// https://en.wikipedia.org/wiki/Shunting_yard_algorithm#Detailed_examples
expect(tokenize('sin(max(2, 3) / 3 * 3.14)')).toEqual(
'sin ( max ( 2 , 3 ) / 3 * 3.14 )'.split(' ')
);
expect(tokenize('1+2')).toEqual(['1', '+', '2']);
expect(tokenize('min(1,2)')).toEqual(['min', '(', '1', ',', '2', ')']);
expect(tokenize('1.1+2.2')).toEqual(['1.1', '+', '2.2']);
expect(tokenize('min(1.1,2.2)')).toEqual(['min', '(', '1.1', ',', '2.2', ')']);
// Decimals
expect(tokenize('1.1 + 2.2 - 3.3 * 4.4 / 5.5 % 6.6 ^ 7.7')).toEqual(
'1.1 + 2.2 - 3.3 * 4.4 / 5.5 % 6.6 ^ 7.7'.split(' ')
);
// White spaces
expect(tokenize('')).toEqual([]);
expect(tokenize(' ')).toEqual([]);
expect(tokenize(' 1 + 2 ')).toEqual(['1', '+', '2']);
expect(tokenize('1 \n + \n 2')).toEqual(['1', '+', '2']);
expect(tokenize('1 \t + \t 2')).toEqual(['1', '+', '2']);
// Single number
expect(tokenize('0')).toEqual(['0']);
expect(tokenize('1')).toEqual(['1']);
expect(tokenize('-0')).toEqual(['-0']);
expect(tokenize('-1')).toEqual(['-1']);
expect(tokenize('(1)')).toEqual(['(', '1', ')']);
expect(tokenize('(-1)')).toEqual(['(', '-1', ')']);
expect(tokenize('-(1)')).toEqual(['0', '-', '(', '1', ')']);
// Starting with +/-
expect(tokenize('+0')).toEqual(['+0']);
expect(tokenize('+ 0')).toEqual(['0', '+', '0']);
expect(tokenize('-0')).toEqual(['-0']);
expect(tokenize('- 0')).toEqual(['0', '-', '0']);
expect(tokenize('+1')).toEqual(['+1']);
expect(tokenize('+ 1')).toEqual(['0', '+', '1']);
expect(tokenize('-1')).toEqual(['-1']);
expect(tokenize('- 1')).toEqual(['0', '-', '1']);
expect(tokenize('+1 + 1')).toEqual(['+1', '+', '1']);
expect(tokenize('+ 1 + 1')).toEqual(['0', '+', '1', '+', '1']);
expect(tokenize('-1 + 1')).toEqual(['-1', '+', '1']);
expect(tokenize('- 1 + 1')).toEqual(['0', '-', '1', '+', '1']);
expect(tokenize('+')).toEqual(['0', '+']);
expect(tokenize('-')).toEqual(['0', '-']);
// Do not confuse '+1' / '-1' with 'x + 1' / 'x - 1' depending on the context
expect(tokenize('(1+2)+1')).toEqual(['(', '1', '+', '2', ')', '+', '1']);
expect(tokenize('(1+2)-1')).toEqual(['(', '1', '+', '2', ')', '-', '1']);
expect(tokenize('1 + -2')).toEqual(['1', '+', '-2']);
expect(tokenize('1+-2')).toEqual(['1', '+', '-2']);
// Space in number
expect(() => tokenize('1 2')).toThrow("Space in number: '1 2'");
expect(() => tokenize('1 2')).toThrow("Space in number: '1 2'");
expect(() => tokenize('0 + 1 / (2 3) * 4')).toThrow("Space in number: '2 3'");
expect(() => tokenize('min(1 2)')).toThrow("Space in number: '1 2'");
// Double '.' in number
expect(() => tokenize('1+2.3.4')).toThrow("Double '.' in number: '2.3.'");
expect(() => tokenize('1+2.3.4.5')).toThrow("Double '.' in number: '2.3.'");
expect(() => tokenize('0 + 1 / 2.3.4 * 5')).toThrow("Double '.' in number: '2.3.'");
expect(() => tokenize('min(1, 2.3.4)')).toThrow("Double '.' in number: '2.3.'");
// Consecutive operators
expect(tokenize('1++2')).toEqual(['1', '+', '+2']);
expect(tokenize('1-+2')).toEqual(['1', '-', '+2']);
expect(tokenize('1--2')).toEqual(['1', '-', '-2']);
expect(() => tokenize('1++')).toThrow("Consecutive operators: '++'");
expect(() => tokenize('1-+')).toThrow("Consecutive operators: '-+'");
expect(() => tokenize('1--')).toThrow("Consecutive operators: '--'");
expect(() => tokenize('1-*2')).toThrow("Consecutive operators: '-*'");
expect(() => tokenize('0 + 1 / (2-*3) * 4')).toThrow("Consecutive operators: '-*'");
expect(() => tokenize('min(1-*2, 3)')).toThrow("Consecutive operators: '-*'");
// Other edge cases
expect(tokenize('1,2')).toEqual(['1', ',', '2']);
expect(tokenize('1+2+')).toEqual(['1', '+', '2', '+']); // :-(
expect(() => tokenize('1+2a')).toThrow("Invalid characters: 'a'");
expect(() => tokenize('10 Hello')).toThrow("Invalid characters: 'Hello'");
expect(tokenize('1-.')).toEqual(['1', '-', '.']); // :-(
expect(tokenize('*')).toEqual(['*']);
expect(tokenize('/')).toEqual(['/']);
// All together expression
expect(
tokenize(
'((3.1 + cos(-4) / 2) * max(-6, 6) ^ sin(6) * 9) / tan(log(8.8 + -2) % 7) + (6 * -1 - min(6, -4.2))'
)
).toEqual(
'( ( 3.1 + cos ( -4 ) / 2 ) * max ( -6 , 6 ) ^ sin ( 6 ) * 9 ) / tan ( log ( 8.8 + -2 ) % 7 ) + ( 6 * -1 - min ( 6 , -4.2 ) )'.split(
' '
)
);
expect(
tokenize('((3.1+cos(-4)/2)*max(-6,6)^sin(6)*9)/tan(log(8.8+-2)%7)+(6*-1-min(6,-4.2))')
).toEqual(
'( ( 3.1 + cos ( -4 ) / 2 ) * max ( -6 , 6 ) ^ sin ( 6 ) * 9 ) / tan ( log ( 8.8 + -2 ) % 7 ) + ( 6 * -1 - min ( 6 , -4.2 ) )'.split(
' '
)
);
});
test('calculate()', () => {
// https://en.wikipedia.org/wiki/Shunting_yard_algorithm#Detailed_examples
expect(calculate('3 + 4 * 2 / (1 - 5) ^ 2 ^ 3')).toEqual(3.000_122_070_312_5);
// https://en.wikipedia.org/wiki/Shunting_yard_algorithm#Detailed_examples
expect(calculate('sin(max(2, 3) / 3 * 3.14)')).toEqual(0.001_592_652_916_486_828_2);
expect(calculate('1+2')).toEqual(3);
expect(calculate('min(1,2)')).toEqual(1);
expect(calculate('1.1+2.2')).toEqual(3.300_000_000_000_000_3);
expect(calculate('min(1.1,2.2)')).toEqual(1.1);
// if the token is: ',':
// while the operator at the top of the operator stack is not a left parenthesis:
// pop the operator from the operator stack into the output queue
expect(calculate('max(0 + 1, 2)')).toEqual(2);
// Decimals
expect(calculate('1.1 + 2.2 - 3.3 * 4.4 / 5.5 % 6.6 ^ 7.7')).toEqual(
eval('1.1 + 2.2 - 3.3 * 4.4 / 5.5 % 6.6 ** 7.7')
);
// White spaces
expect(calculate('')).toBeNaN();
expect(calculate(' ')).toBeNaN();
expect(calculate(' 1 + 2 ')).toEqual(3);
expect(calculate('1 \n + \n 2')).toEqual(3);
expect(calculate('1 \t + \t 2')).toEqual(3);
// -0 hack
expect(calculate('-0 + -0')).toEqual(-0);
expect(calculate('-0 - 0')).toEqual(-0);
expect(calculate('0 * -1')).toEqual(-0);
expect(calculate('0 / -1')).toEqual(-0);
expect(calculate('-1 % 1')).toEqual(-0);
expect(calculate('-0 ^ 1')).toEqual(-0);
expect(calculate('min(-0, 1)')).toEqual(-0);
expect(calculate('max(-0, -1)')).toEqual(-0);
expect(calculate('sin(-0)')).toEqual(-0);
//expect(Math.cos(Math.PI / 2)).toEqual(0);
expect(calculate('tan(-0)')).toEqual(-0);
expect(calculate('log(1)')).toEqual(0); // No need for -0 hack
// Math.pow() vs **
expect(calculate('-2 ^ 2')).toEqual((-2) ** 2);
expect(eval('Math.pow(-2, 2)')).toEqual(4);
expect(() => eval('-2 ** 2')).toThrow(
'Unary operator used immediately before exponentiation expression.'
);
// Infinity/-Infinity
expect(calculate('1 / 0')).toEqual(Infinity);
expect(calculate('1 / -0')).toEqual(-Infinity);
expect(calculate('-1 / 0')).toEqual(-Infinity);
expect(calculate('1 + 1 / 0')).toEqual(Infinity);
expect(calculate('1 - 1 / 0')).toEqual(-Infinity);
expect(calculate('10 ^ 1000')).toEqual(Infinity);
expect(calculate('0 - 10 ^ 1000')).toEqual(-Infinity);
expect(calculate('0 ^ -1')).toEqual(Infinity);
expect(calculate('-0 ^ -1')).toEqual(-Infinity);
expect(calculate('log(0)')).toEqual(-Infinity);
// NaN
expect(calculate('log(-1)')).toBeNaN();
expect(calculate('-1 ^ 0.1')).toBeNaN();
expect(calculate('1 % 0')).toBeNaN();
expect(calculate('1 / 0 * 0')).toBeNaN();
// Single number
expect(calculate('0')).toEqual(0);
expect(calculate('1')).toEqual(1);
expect(calculate('-0')).toEqual(-0);
expect(calculate('-1')).toEqual(-1);
expect(calculate('(1)')).toEqual(1);
expect(calculate('(-1)')).toEqual(-1);
expect(calculate('-(1)')).toEqual(-1);
// Starting with +/-
expect(calculate('+0')).toEqual(0);
expect(calculate('+ 0')).toEqual(0);
expect(calculate('-0')).toEqual(-0);
expect(calculate('- 0')).toEqual(0);
expect(calculate('+1')).toEqual(1);
expect(calculate('+ 1')).toEqual(+1);
expect(calculate('-1')).toEqual(-1);
expect(calculate('- 1')).toEqual(-1);
expect(calculate('+1 + 1')).toEqual(2);
expect(calculate('+ 1 + 1')).toEqual(2);
expect(calculate('-1 + 1')).toEqual(0);
expect(calculate('- 1 + 1')).toEqual(0);
expect(calculate('+')).toBeNaN();
expect(calculate('-')).toBeNaN();
// Do not confuse '+1' / '-1' with 'x + 1' / 'x - 1' depending on the context
expect(calculate('(1+2)+1')).toEqual(4);
expect(calculate('(1+2)-1')).toEqual(2);
expect(calculate('1 + -2')).toEqual(-1);
expect(calculate('1+-2')).toEqual(-1);
// Space in number
expect(() => calculate('1 2')).toThrow("Space in number: '1 2'");
expect(() => calculate('1 2')).toThrow("Space in number: '1 2'");
expect(() => calculate('0 + 1 / (2 3) * 4')).toThrow("Space in number: '2 3'");
expect(() => calculate('min(1 2)')).toThrow("Space in number: '1 2'");
// Double '.' in number
expect(() => calculate('1+2.3.4')).toThrow("Double '.' in number: '2.3.'");
expect(() => calculate('1+2.3.4.5')).toThrow("Double '.' in number: '2.3.'");
expect(() => calculate('0 + 1 / 2.3.4 * 5')).toThrow("Double '.' in number: '2.3.'");
expect(() => calculate('min(1, 2.3.4)')).toThrow("Double '.' in number: '2.3.'");
// Consecutive operators
expect(calculate('1++2')).toEqual(3);
expect(calculate('1-+2')).toEqual(-1);
expect(calculate('1--2')).toEqual(3);
expect(() => calculate('1++')).toThrow("Consecutive operators: '++'");
expect(() => calculate('1-+')).toThrow("Consecutive operators: '-+'");
expect(() => calculate('1--')).toThrow("Consecutive operators: '--'");
expect(() => calculate('1-*2')).toThrow("Consecutive operators: '-*'");
expect(() => calculate('0 + 1 / (2-*3) * 4')).toThrow("Consecutive operators: '-*'");
expect(() => calculate('min(1-*2, 3)')).toThrow("Consecutive operators: '-*'");
// Misplaced ','
expect(() => calculate('1,2')).toThrow("Misplaced ','");
expect(() => calculate(',1/2')).toThrow("Misplaced ','");
expect(() => calculate('1,/2')).toThrow("Misplaced ','");
expect(() => calculate('1/,2')).toThrow("Misplaced ','");
expect(() => calculate('1/2,')).toThrow("Misplaced ','");
expect(() => calculate('sin(,max,(,2,3,),/,3,*,3.14,)')).toThrow('Insufficient operators');
expect(calculate('sin(,max(,2,3,),/3,*3.14,)')).toEqual(0.001_592_652_916_486_828_2);
// Other edge cases
expect(calculate('1+2+')).toBeNaN();
expect(() => calculate('1+2a')).toThrow("Invalid characters: 'a'");
expect(() => calculate('10 Hello')).toThrow("Invalid characters: 'Hello'");
expect(calculate('1-.')).toBeNaN();
expect(calculate('*')).toBeNaN();
expect(calculate('/')).toBeNaN();
// All together expression
expect(
calculate(
'((3.1 + cos(-4) / 2) * max(-6, 6) ^ sin(6) * 9) / tan(log(8.8 + -2) % 7) + (6 * -1 - min(6, -4.2))'
)
).toEqual(
eval(
'((3.1 + Math.cos(-4) / 2) * Math.max(-6, 6) ** Math.sin(6) * 9) / Math.tan(Math.log(8.8 + -2) % 7) + (6 * -1 - Math.min(6, -4.2))'
)
);
expect(
calculate('((3.1+cos(-4)/2)*max(-6,6)^sin(6)*9)/tan(log(8.8+-2)%7)+(6*-1-min(6,-4.2))')
).toEqual(
eval(
'((3.1+Math.cos(-4)/2)*Math.max(-6,6)**Math.sin(6)*9)/Math.tan(Math.log(8.8+-2)%7)+(6*-1-Math.min(6,-4.2))'
)
);
});
test('calculate() with getRandomMathExpression()', () => {
for (let i = 0; i < 1000; i++) {
const expr = getRandomMathExpression(getRandomInt(1, 100));
expect(calculate(expr)).toEqual(eval(convertMathExpressionToEval(expr)));
}
});
/* eslint-disable no-eval */
import { convertMathExpressionToEval, getRandomMathExpression } from './getRandomMathExpression';
test('getRandomMathExpression()', () => {
const numberRegex = /-?\d+(\.\d+)?/g;
for (let i = 0; i < 100; i++) {
// 13.69
// -97.11
{
const expr = getRandomMathExpression(1);
expect(expr).toMatch(/^-?\d+(\.\d+)?$/);
expect(eval(convertMathExpressionToEval(expr))).toEqual(expect.any(Number));
}
// cos(-20.85) * 65.04
// max(50.44, 66.98)
// (-13.33 / 70.81)
// -51.48 / -83.07
{
const expr = getRandomMathExpression(2);
expect(expr.match(numberRegex)).toHaveLength(2);
expect(eval(convertMathExpressionToEval(expr))).toEqual(expect.any(Number));
}
// min(-91.65, min(99.88, -33.67))
// (-77.28 % sin(-52.18) + -20.19)
// (67.58 % -32.31 * -7.73)
// (28.33) ^ (-32.59) ^ -80.54
{
const expr = getRandomMathExpression(3);
expect(expr.match(numberRegex)).toHaveLength(3);
expect(eval(convertMathExpressionToEval(expr))).toEqual(expect.any(Number));
}
// cos(max(24.57, 84.07)) ^ tan(51.78) - -45.52
// (min(-40.91, -67.48) * sin(-25.99) ^ -29.35)
// cos(1.61 - -22.15) % (-70.39 * 0.98)
// ((30.91) ^ -63.24) + 76.72 / 61.07
{
const expr = getRandomMathExpression(4);
expect(expr.match(numberRegex)).toHaveLength(4);
expect(eval(convertMathExpressionToEval(expr))).toEqual(expect.any(Number));
}
// tan((24.97) ^ 55.61) ^ (-46.74 % -31.38 * 84.34)
// max(tan(-7.78) + -2.43, max(35.48, (6.13 % 25.54)))
// ((5.66 / 23.21) - (-22.93 % 96.56 * 52.12))
// (((-40.93 % 13.72)) ^ (29.48 * 57.34 + 13.26))
{
const expr = getRandomMathExpression(5);
expect(expr.match(numberRegex)).toHaveLength(5);
expect(eval(convertMathExpressionToEval(expr))).toEqual(expect.any(Number));
}
}
// Torture test, should not throw
for (let i = 0; i < 100; i++) {
const expr = getRandomMathExpression(1000);
expect(expr.match(numberRegex)).toHaveLength(1000);
// The longer the expression, the more likely it will result in a NaN
expect(eval(convertMathExpressionToEval(expr))).toEqual(expect.any(Number));
}
});
import { assert } from './assert';
import { functions, functionsKeys, operatorsKeys } from './calculator';
import { getRandomFloat, getRandomInt } from './getRandomNumber';
// ⚠️ High probability that the expression calculation is NaN because of 'log(-1)', '-1 ^ 0.1', '1 % 0', '1 / 0 * 0'
// https://stackoverflow.com/q/32936045
// https://softwareengineering.stackexchange.com/q/195813
export function getRandomMathExpression(nbNodes: number): string {
assert(nbNodes > 0, 'nbNodes must be > 0');
if (nbNodes === 1) {
//return getRandomInt(-9, 9).toString();
return getRandomFloat(-100, 100, { decimalPlaces: 2 }).toString();
}
const operator = operatorsKeys[getRandomInt(0, operatorsKeys.length - 1)];
const func = functionsKeys[getRandomInt(0, functionsKeys.length - 1)];
const nbNodesLeft = Math.floor(nbNodes / 2);
const nbNodesRight = Math.ceil(nbNodes / 2);
const left = getRandomMathExpression(nbNodesLeft);
const right = getRandomMathExpression(nbNodesRight);
let expr;
if (Math.random() < 0.5) {
// eval("-1 ** 2") => eval("(-1) ** 2")
// Fix "SyntaxError: Unary operator used immediately before exponentiation expression..."
expr = operator === '^' ? `(${left}) ${operator} ${right}` : `${left} ${operator} ${right}`;
expr = Math.random() < 0.5 ? `(${expr})` : expr;
} else {
expr =
functions[func]!.arity === 2
? `${func}(${left}, ${right})`
: `${func}(${left}) ${operator} ${right}`;
}
return expr;
}
export function convertMathExpressionToEval(expr: string) {
let evalExpr = expr.replaceAll('^', '**');
functionsKeys.forEach(func => (evalExpr = evalExpr.replaceAll(func, `Math.${func}`)));
return evalExpr;
}
// https://gist.github.com/tkrotoff/f3f36926edeeb3f4ce4411151bde37b2
// Exported for testing purposes only
// https://stackoverflow.com/a/45736131
export function getNumberWithDecimalPlaces(num: number, decimalPlaces: number) {
const power = 10 ** decimalPlaces;
return Math.floor(num * power) / power;
}
type GetRandomNumberOptions = {
/**
* The number of digits to appear after the decimal point.
* https://ell.stackexchange.com/q/141863
*/
decimalPlaces?: number;
};
/**
* Generates a pseudo-random float (0.3546, 5.8557...) between min (included) and max (excluded).
*/
export function getRandomFloat(min: number, max: number, options: GetRandomNumberOptions = {}) {
const { decimalPlaces } = options;
const num = Math.random() * (max - min) + min;
if (decimalPlaces === undefined) {
return num;
}
return getNumberWithDecimalPlaces(num, decimalPlaces);
}
/**
* Generates a pseudo-random integer (0, 6...) between min (included) and max (included).
*/
export function getRandomInt(min: number, max: number) {
// https://stackoverflow.com/a/7228322
return Math.floor(Math.random() * (max - min + 1)) + min;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment