DEV Community

Cover image for Building a HTML Calculator in Vanilla Javascript using Infix Postfix and TDD
Martin Rombach
Martin Rombach

Posted on • Edited on

Building a HTML Calculator in Vanilla Javascript using Infix Postfix and TDD

TLDR? Github repository is here.

People say you can build a calculator in any programming language. Today I'm going to do it in Vanilla Javascript. I've spent a lot of time using frameworks these days (don't we all) so I think using Vanilla is good way of sharpening my core js.

Prerequisites and Tech: Javascript Knowledge, VS Code, Node.js, Jest

We're going to go very basic. One HTML file, one Javascript file for the calculator class and one file to create an instance and call a method. Each file js file has a test file to match. The calculator will run using alerts in the browser. User inputs a string, gets a result for their calculation if it's valid. Simple.

Image description

1) Prep: Make a project folder with a HTML file and a js file.

js_calculator.html


<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Calculator</title>
</head>
<body>
    Calculator
</body>
<script type="module" src="run_calculator.js"></script>
</html>

Enter fullscreen mode Exit fullscreen mode

Here are some handy VS Code shortcuts. Run them in the file to speed up HTML.
html:5 - generate template html file
src:module - generate script line

You also need:
An empty run_calculator.js file
An empty calculator.js file

2) Prep: Install Node.js and Jest.

So building a calculator properly is a complicated process, so I highly recommended you do some TDD for your more intense functions.

Install Node.js if you haven't, and then run this in the terminal of your project:

npm init -y (or npm init then answer the questions)
npm install jest
Enter fullscreen mode Exit fullscreen mode

With Jest, you'll be able to code more efficiently. When you write tests for your code, you're forced to make code that passes the tests. It speeds up your thinking process, so in TDD we work by building the tests along with pseudo code before the functions.

Here is a good video on Jest.

3) Prep: Restructure the folders for testing.

If you're using testing, I find this folder structure useful. However different companies have different rules, so be prepared to be flexible.

src ->
component/class folder -> component or class
testing folder -> test files

Convention says each test file should be name with in the style of component.test.js so:

calculator.test.js

Image description

Tip: I like to make a txt file for pseudo code.

4) Let's make a class!
So there are four main functions your calculator class needs:

  1. An input collection function that converts strings to arrays.

  2. An infix to postfix function that converts the 'human ordered' calculation (infix) to a 'computer readable' calculation (postfix). (more on this later)

  3. A postfix reading function that returns a single integer from the computer readable calculation.

  4. A main function to run all the other functions.

// module.exports = class Calculator {
export default class Calculator {
    constructor () {
        this.regex = /\d+|[\(\)\+\-\*\//]+/g;
        this.operators = /[\(\)\+\-\*\//]+/g;
    }

    getInputArray = (userInput) => {
    }

    convertInfixToPostfix = (calcArray) => {
    }

    runPostfixOperations(postfixArray) {
    }

    calculate = (userCalculation) => {
    };

}
Enter fullscreen mode Exit fullscreen mode

Let's look at each function one by one.

5) Input Collection Function
For this function, I used a regex that detects all numbers and the operators within a string and returns them in an array. (My calculator handles parentheses but not exponents -> expect a change in version 2.)

In TDD we make the tests first,, so I made this test.

    test('getInputArray -> (1+1)(1+1) becomes [ ( 1 + 1 ) ( 1 + 1 ) ]', () => {
        expect(testCalc.getInputArray('(1+1)(1+1)')).toStrictEqual(['(','1','+','1',')''('1','+','1',')'])
        })
Enter fullscreen mode Exit fullscreen mode

My code looked like this

//input collection
getInputArray = (userInput) => {
        return userInput.match(this.regex)

//main calculate function:
calculate = () => {
        let userCalculation = prompt("Please enter your calculation");
        let calcArray = this.getInputArray(userCalculation)
Enter fullscreen mode Exit fullscreen mode

I expect an array like this with my test.
[ '(','1','+','1',')','(','1','+','1',')']
But instead I received:
[ '(','1','+','1',')(','1','+','1',')']

The brackets were joined by the regex. The regex should return combined numbers (e.g. 1000 not 1,0,0,0) so I had to use it, but for the string operators I need to target them once they were in the array. So I made this test:

  test('getInputArray -> )( becomes [ ) ( ]', () => {
        expect(testCalc.getInputArray(')(')).toStrictEqual([')','('])
        })
Enter fullscreen mode Exit fullscreen mode

And came to the conclusion that a flatMap would be the best approach. Flat map allows a call back to be run on each iteration, allowing me to split conjoined brackets.

//input collection
getInputArray = (userInput) => {
        //create array from regex + if string has two characters, split it 
        return userInput.match(this.regex).flatMap((char) => (char.length > 0 && char.match(this.operators) ? char.split('') : char));
    }

//main calculate function:
calculate = () => {
        let userCalculation = prompt("Please enter your calculation");
        let calcArray = this.getInputArray(userCalculation)

Enter fullscreen mode Exit fullscreen mode

6) Convert Infix to Postfix

This function was the real work, spawning several other helper functions. Before embarking on this part of calculator I highly recommend this video:

Education4U - Infix to Postfix

It's not a coding video, rather a math teacher explaining the concept. As I said earlier, infix to postfix is about converting human readable calculations into computer readable code. This is because a computer will run human readable calculations in the wrong order if not given a post fix expression.

It works like this:

  • Build an operator stack and an output stack
  • Follow a set of rules for every number and operator to make sure the output stack is in the right order.

Rules:

  1. A number should be put straight in the output stack.

  2. For operators, priorities must be followed.
    High - ^
    Mid - * /
    Low - + -

  3. Operators of same priority cannot stay together. In case of meeting, pop the top element and add to postfix.

  4. If the top element of stack is higher than current element, pop the top element and add to postfix. If the top element is lower, the higher element can be added.

  5. If there is a left bracket wait for right to appear.

  6. If there is a right bracket, pop everything after the left bracket.

  7. When any rule is broken, pop all numbers that are broken.
    E.g. current num is -. stack contains * +. * and + must be popped at this point.

My functions related to infix->postfix are below. It checks each item in the array, pushes numbers and runs conditions on the operators to decide whether the operator can go straight in the stack or triggers any pops.

precedenceSameOrHigher = (stack, newItem) => {
        //compare top operators in stack with new item (current iteration)
        let topItem = stack[stack.length -1]
        const operationRanks = {
            "^": 3,
            "*" : 2, 
            "/": 2,
            "+": 1,
            "-": 1,
            "": 0,
        }
        return operationRanks[topItem] >= operationRanks[newItem]
    }

    isOperator = (item) => {
        return item.match(this.operators)
    }

    pushBracketOperators = (stack, postfixResult) => {
        //slice our all relevant operators and delete left bracket, append postfixResult
        let bracketOperators = stack.splice(stack.indexOf("("), stack.length - 1);
        bracketOperators.shift()
        return [...postfixResult, ...bracketOperators]

    }

    convertInfixToPostfix = (calcArray) => {
        //opStack acts as a stack, the end is the top, and the start is the bottom (lifo)
        let postfixResult = []
        const opStack = []
        let bracketOpen = false;

        for (let current in calcArray) {
            let c = calcArray[current];
            let temp = parseInt(c);

                        //test if number
            if (Number.isInteger(temp)) {
                postfixResult.push(temp)
                continue
            }
                        //test for parentheses
            if(c === "(") {
                opStack.push(c)
                bracketOpen = true;
                continue
            }

            if (bracketOpen && c === ")") {
                postfixResult = this.pushBracketOperators(opStack, postfixResult)       
                bracketOpen = false
                continue
            }

            if(this.isOperator(c) && bracketOpen && !this.precedenceSameOrHigher(opStack, c)) {
                opStack.push(c)
                continue
            }

            if(this.isOperator(c) && this.precedenceSameOrHigher(opStack, c)) {
                postfixResult.push(opStack.pop())

                //keep comparing and empty stack of rule breaking items
                while(this.precedenceSameOrHigher(opStack, c)) {
                    postfixResult.push(opStack.pop())
                }
                opStack.push(c)
                continue
            }

            if(this.isOperator(c) && !this.precedenceSameOrHigher(opStack, c)) {
                opStack.push(c)
                continue
            }

        }
        if(opStack.length > 0 ) {
            postfixResult = [...postfixResult, ...opStack.reverse()]
        }
        return postfixResult
    }
Enter fullscreen mode Exit fullscreen mode

For my stacks, I used javascript arrays, treating the final element as the top of the stack. I could then use pop() and push() to return an array of elements in the correct order.

For a problem this complicated, it's important to test each expected behavior as well as testing final output, i.e. divide the problem.

    test('convertInfixToPostfix -> postfix collects all numbers and opstack collects all operators on loop', () => {
        expect(testCalc.convertInfixToPostfix(['1', '+', '1', '*', '1', '/','1','-', '1'])).toStrictEqual(
            {0: [1,1,1,1,1],
            1: ['+','*','/','-']})
    })
Enter fullscreen mode Exit fullscreen mode

The test above wouldn't work on the final function, but it helped me confirm that the numbers were being placed directly in the postfix result while the operators were being gathered in the stack.

The task was too big, so I divided the problem into smaller goals:

  • put the numbers and operators into the correct stacks
  • decide when to push or pop the operators

Once the first goal was accomplished, I could test the second goal like this with some simple output.

    test('convertInfixToPostfix -> 1 * 2 + 3 -> 1 2 * 3 +', () => {
        expect(testCalc.convertInfixToPostfix(['1', '*', '2', '+', '3'])).toStrictEqual([1, 2, '*', 3, '+'])
        })
Enter fullscreen mode Exit fullscreen mode

I could then use the calculation in the video above as a input as well. Handling parens needed its own separate function, pushBracketOperators( ), probably the most complicated part of testing. The key thing about this calculation is not the parens, but the fact that you need to pop every element that clashes with your current number.

    test('convertInfixToPostfix -> Infix 1+2/3 * (4+5) - 6 -> 1 2 3 / 4 5 + * + 6 - ', () => {
        expect(testCalc.convertInfixToPostfix(['1','+', '2' ,'/','3','*','(','4','+','5',')','-','6'])).toStrictEqual([1, 2, 3, '/', 4, 5,'+', '*', '+', 6, '-'])
    })
Enter fullscreen mode Exit fullscreen mode

7) Run the calculations in postfix order and return the result.

Making the postfix list needed a stack, so it shouldn't be too surprising that actually running the calculations involves a stack as well. The main function runPostfixOperations() loops over the array generated by convertInfixToPostfix() and builds a stack, running operations after each 2 numbers is added to the stack. It keeps going until the stack contains one number, the result.

    runOperation = (operator, base, newnum) => {
        switch (operator) {
            case "+":
                return parseFloat(base) + parseFloat(newnum);
            case "-":
                return parseFloat(base) - parseFloat(newnum);
            case "*":
                return parseFloat(base) * parseFloat(newnum);
            case "/":
                return parseFloat(base) / parseFloat(newnum);
            default:
                return 0;
        }
    };

    runPostfixOperations(postfixArray) {
        let stack = []
        for (let current in postfixArray) {
            let c = postfixArray[current]
            if(Number.isInteger(c)) {
                stack.push(c)
                continue
            }

            if(this.isOperator(c)) {
                let num1 = stack.pop();
                let num2 = stack.pop();
                stack.push(this.runOperation(c, num2, num1))
                continue
            }


        }
        return stack[0]
    }

    test('calculate -> 1+2/3 * (4+5) - 6', () => {
    expect(testCalc.calculate('1 + 2 / 3 * ( 4 + 5 ) - 6')).toStrictEqual(1)
    })

Enter fullscreen mode Exit fullscreen mode

So, that's how you build a calculator. Use a regex for the input, use infix to postfix to get it ready, and use a loop to return the result. Thanks for reading.

Top comments (0)