Last active
September 3, 2024 10:20
-
-
Save tkrotoff/b0b1d39da340f5fc6c5e2a79a8b6cec0 to your computer and use it in GitHub Desktop.
Calculator function using shunting yard algorithm and reverse Polish notation (RPN)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* 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))); | |
} | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* 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)); | |
} | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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
Hi!
Thanks for this extremely useful gist!
Just a note: I tried adding
toDeg
,toRad
andpi
functions since I plan to use it with such features.For
pi
, I tried adding this function:And I added test cases. It was working well until the test case:
The calculation returns
NaN
.From my investigations, I realized that using
-
just before a function does not work well. For instance, this test fails: