I have been writing a javascript calculator for about a few weeks. It uses a shuntyard algorithm to do order of operations. Some unit tests I have not finished yet and there is some functionality missing (e.g. no display limitations, some display errors) but the core logic behaves as expected.
My goal was to practice functional-programming principles, TDD, and code organization.
The hardest part in writing this was
- Writing in a clean concise scalable testable manner
- Which ES6 syntax I could use for conciseness
- On a
MV*
Pattern, deciding the functionality logic on the*
pattern - Determining the functionality of the render method
Function wise I had these issues
- Debating on what arguments and parameters functions should have
- Trying to avoid functions with side effects
- Trying to avoid multiple return paths in a function
- Deciding how to group similar functions
What I wrote below is pretty sloppy IMO but I need advice on what I can do better
https://codepen.io/Kagerjay/pen/XqNGqv
// https://stackoverflow.com/questions/5834318/are-variable-operators-possible
// Math library
var operations = {
'x': function(a,b) { return b*a},
'÷': function(a,b) { return b/a},
'+': function(a,b) { return b+a},
'-': function(a,b) { return b-a},
}
const isOper = /(-|\+|÷|x)/;
var util = {
splitNumAndOper: function(rawString){
// https://stackoverflow.com/questions/49546448/javascript-split-a-string-into-array-matching-parameters
// Clean up data before Tokenization by applying Math Associative Property
rawString = rawString.replace(/\-/, "+-");
if(rawString.charAt(0) == "+"){
rawString = rawString.substring(1);
}
// Tokenize operators from numeric strings
let splitArray = rawString.split(/([^-0-9.]+)/);
// Parse numeric tokens into floats to prevent string concatenation during calculation
splitArray = splitArray.map(function(el){
if($.isNumeric(el)){
return parseFloat(el);
} else {
return el;
}
});
return splitArray;
},
exceedDisplay: function(rawString){
return (rawString.length > 9) ? true : false;
},
shuntyardSort: function(rawArr){
if(!Array.isArray(rawArr)){
console.error("shuntyardSort did not receive an Array");
}
let valueStack = [];
let operStack = [];
let isOperPushReady = false;
const PEMDAS = {
"x": 2,
"÷": 2,
"+": 1,
"-": 1
}
// Convert infix to PEMDAS postfix
rawArr.forEach(function(el,index,arr){
if($.isNumeric(el)){ // We have a number
valueStack.push(el);
// Oper always adjacent to left and right num, this accounts for right num
if(isOperPushReady){
valueStack = valueStack.concat(operStack.reverse());
operStack = [];
isOperPushReady = false;
}
} else { // We have an operator
operStack.push(el);
// Need at least 2 oper to compare if current operator has higher precedence than previous
if(operStack.length !== 1 && (PEMDAS[el] > PEMDAS[operStack.slice(-2)[0]])){
isOperPushReady = true;
}
}
});
// Push remaining operators onto valuestack
valueStack = valueStack.concat(operStack);
return valueStack;
},
shuntyardCalc: function(rawArr){
// Find first Operator except (-) because its reserved as a neg num not an operator anymore
function findFirstOperator(element){
return /(\+|÷|x)/.test(element);
}
if(!Array.isArray(rawArr)){
console.error("shuntyardCalc did not receive an Array");
}
let infiniteLoopCounter = 0;
let index = 0;
let evalPartial = 0;
let firstNum = 0;
let secondNum = 0;
let op = 0;
/*
* Calculate the postfix after Djikstras Shuntyard Sort Algo
* By finding the first operator index, calculating operand + 2previous values
* and pushing result back in
* Repeat until everything is calculated
*/
while(rawArr.length > 1){
index = rawArr.findIndex(findFirstOperator);
firstNum = parseFloat(rawArr.splice(index-1,1));
secondNum = parseFloat(rawArr.splice(index-2,1));
op = rawArr.splice(index-2,1);
evalPartial = operations[op](firstNum, secondNum);
evalPartial = Math.round(evalPartial * 10000000000)/10000000000;
rawArr.splice(index-2,0, evalPartial);
infiniteLoopCounter++;
if(infiniteLoopCounter > 10){
debugger;
};
}
return rawArr.toString();
},
grabLastToken: function(rawStr){
//https://stackoverflow.com/questions/49546448/javascript-split-a-string-into-array-matching-parameters
return (rawStr == "" || rawStr == "0.") ? rawStr : rawStr.match(/\d+|[\+-\/x÷]/g).pop();
}
}
var view = {
render: function(cache,buttonValue){
// Use placeholder vars for display to prevent 0 and "" confusion
let topDisplay = util.grabLastToken(cache);
let botDisplay = cache;
if(buttonValue == "CE"){
topDisplay = 0;
}
if(botDisplay == ""){
botDisplay = 0;
}
if(topDisplay == ""){
topDisplay = 0;
}
$('#topDisplay').html(topDisplay);
$('#botDisplay').html(botDisplay);
}
}
var model = {
getAnswer: function(cache){
return cache.split('=')[1];
},
pushDot: function(cache, lastCall){
if(lastCall=="calculate" || cache ==""){
cache = "0";
}
// Grab last numeric token and check if it has a "." in it
return (cache.match(/[\d.]+$/g)[0].includes("."))
? cache : cache+".";
},
pushNumber: function(cache, buttonValue, lastCall) {
return lastCall == "calculate" ? buttonValue : cache+buttonValue;
},
pushOperator: function(cache, buttonValue, lastCall){
if(cache==""){
return cache;
}
if(isOper.test(cache.slice(-1))){
cache = cache.slice(0,-1);
}
return cache+buttonValue;
},
clearAll: function(cache, lastCall){
return '';
},
clearEntry: function(cache, lastCall){
//https://stackoverflow.com/questions/11134004/regex-that-will-match-the-last-occurrence-of-dot-in-a-string/
// targets last operator +÷x- and its' remaining string .......replaces it with nothing
// 1. (\+|÷|x|-) Seek Operators.
// 2. (?= Conditional check....
// 3. [^(\+|÷|x|\-)] For any other operators until end.
// 4. *$)(.*)/ Grab everything after
const lastEntry = /(\+|÷|x|-)(?=[^(\+|÷|x|\-)]*$)(.*)/;
if (cache.includes("=")){
cache = "";
} else if(isOper.test(cache.slice(-1))){ // if lastchar is operator
cache = cache.slice(0,-1); // delete
} else if(isOper.test(cache)){ // If string has operator
cache = cache.replace(lastEntry, '$1'); // remove numbers ahead
} else { // no operators
cache = '';
}
return cache;
},
calculate: function(cache, lastCall){
if( isOper.test(cache.slice(-1)) || cache.slice(-1) == "."){
return cache;
}
let tempArr = util.splitNumAndOper(cache);
// Edsger Dijkstra - Shuntyard Algorithm
tempArr = util.shuntyardSort(tempArr);
tempArr = util.shuntyardCalc(tempArr);
cache = cache + "=" + tempArr.toString();
return cache;
},
};
// Display, Read, Update, Destroy
// VIEWS + CONTROLLER IN JQUERY
$(document).ready(function(){
let cache = '';
let lastCall = 'clearAll'; // Assume last functionCall is a hard reset
// Condense down into one click button
$("button").on("click", function(){
let buttonValue = $(this).attr("value");
switch(buttonValue) {
// Numbers
case '.':
cache = model.pushDot(cache, lastCall);
lastCall = "pushDot";
break;
case '0':
case '1':
case '2':
case '3':
case '4':
case '5':
case '6':
case '7':
case '8':
case '9':
cache = model.pushNumber(cache, buttonValue, lastCall);
lastCall = "pushNumber";
break;
case 'x':
case '÷':
case '-':
case '+':
cache = model.pushOperator(cache, buttonValue, lastCall);
lastCall = "pushOperator";
break;
case 'AC':
cache = model.clearAll(cache, lastCall);
lastCall = "clearAll";
break;
case 'CE':
cache = model.clearEntry(cache, lastCall);
lastCall = "clearEntry";
break;
case '=':
cache = model.calculate(cache, lastCall);
lastCall = "calculate";
break;
default:
console.log('ERROR DEFAULT CASE SHOULD NOT RUN!');
break;
}
view.render(cache,buttonValue);
if(lastCall == "calculate"){
cache = model.getAnswer(cache);
}
});
});
// TESTS
// MOCHA - test
// UI
mocha.setup('bdd')
mocha.setup({
ui:'bdd',
})
// CHAI
var assert = chai.assert;
var expect = chai.expect;
var should = chai.should();
// Based on http://yeoman.io/contributing/testing-guidelines.html
describe('MODEL', function(){
describe('getAnswer', function(){
it('grab number token after =', function(){
assert.equal("99",model.getAnswer("44+55=99"));
})
})
describe("pushDot", () =>{
it('forbid multiple "." for one token', () =>{
assert.equal("9.99",model.pushDot("9.99"));
})
it('add dot if none present', () =>{
assert.equal("999x9.",model.pushDot("999x9"));
})
it('add zero if empty cache', () =>{
assert.equal("0.",model.pushDot(""));
})
it('reset to zero if calculate lastcall', () =>{
assert.equal("0.",model.pushDot("999","calculate"));
})
it('limit one "." per token', function(){
assert.equal("12.34+56.",model.pushDot("12.34+56"));
})
})
describe("pushNumber", () =>{
it("push number as a char", () =>{
assert.equal("9",model.pushNumber("", 9));
})
it("concatenate as chars not add", () =>{
assert.equal("99", model.pushNumber('9', '9'));
})
it('reset if lastCall is calculate', () =>{
assert.equal("5",model.pushNumber("999","5","calculate"));
})
})
describe("pushOperator", () =>{
it('forbid sequential operators', () =>{
assert.equal("999+555+", model.pushOperator("999+555+","+"));
})
it('forbid operators on empty cache', () =>{
assert.equal("",model.pushOperator("","+"));
})
it('allow swappable operators', () => {
assert.equal("123+", model.pushOperator("123-", "+"));
})
})
describe("clearAll", () =>{
it("clear everything", () =>{
assert.equal("", model.clearAll("555+555"));
})
})
describe("clearEntry", () =>{
it("delete all if no operators", () =>{
assert.equal("", model.clearEntry("5555"));
})
it("delete operator if cache's last char", () =>{
assert.equal("555",model.clearEntry("555+"));
})
it("delete number token before operator",() =>{
assert.equal("555+",model.clearEntry("555+444"));
})
it('delete all if calculate lastcall', () =>{
assert.equal("",model.clearEntry("5+5=10"));
})
})
describe("calculate", () =>{
it("do order of operations", () =>{
assert.equal("5+5=10",model.calculate("5+5"));
})
it('handle 1 float calc',()=>{
assert.equal("12.34+5=17.34", model.calculate("12.34+5"));
})
it('handle 2 float calc', () => {
assert.equal("6.6+3.3=9.9", model.calculate("6.6+3.3"));
})
it('forbid incomplete operation', () => {
assert.equal("6+", model.calculate("6+"));
})
})
}) // END MODEL
///////////////////////////////////////////////////////////
describe('VIEW', function(){
describe("render", () =>{
it('throw "Digit Limit Met" if lastNumSeq > 9 chars', () =>{
})
it('throw "Digit Limit Met" if calculation > 9 chars', () =>{
})
it('throw "Digit Limit Met" if cache > 26 char', () =>{
})
it('show 0 if cache is blank', () =>{
})
it('render curBuffer after Clearall or clearEntry', () =>{
})
})
describe('render CACHE RESETS', () =>{
it('return the number after "=" if it is present', () =>{
})
})
}) // END VIEW
///////////////////////////////////////////////////////////
describe('UTIL', function(){
describe("splitNumAndOper", () =>{
it('do simple math', () =>{
assert.deepEqual([6,'+',4,'+',3], util.splitNumAndOper("6+4+3"));
})
it('tokenize negative numbers', () =>{
assert.deepEqual([-1,'+',7], util.splitNumAndOper('-1+7'));
})
it('tokenize decimal numbers', function(){
assert.deepEqual([12.34, '+', 5], util.splitNumAndOper('12.34+5'));
})
})
describe('shuntyardSort', () =>{
it('convert infix to sorted postfix', () =>{
const infix = [1,'+',2,'x',3,'+',4];
const postfix = [1,2,3,'x','+',4,'+'];
assert.deepEqual(postfix, util.shuntyardSort(infix));
})
})
describe('shuntyardCalc', () =>{
it('calculate postfix', () =>{
const sortedPostfix = [1,2,3,'x','+',4,'+'];
assert.equal(11, util.shuntyardCalc(sortedPostfix));
})
it('calculate postfix with float values', () => {
assert.equal(17.34,util.shuntyardCalc([12.34, 5, "+"]));
})
it('calculate postfix with negative numbers', () => {
assert.equal(-1,util.shuntyardCalc([2,-3,"+"]));
})
})
describe('grabLastToken', () =>{
it('grab last numeric token', () =>{
assert.equal("123",util.grabLastToken("99999+123"));
})
it('do nothing if arg is empty', () =>{
assert.equal("",util.grabLastToken(""));
})
it('return operator if last char', () =>{
assert.equal("+",util.grabLastToken("99+"));
})
it('handle floats', () =>{
assert.equal("0.",util.grabLastToken("0."));
})
})
}) // END UTIL
// RUN MOCHA
mocha.run()
/*********************** MOCHA TDD STYLES ****************/
.error {
max-height: 25px !important;
}
/*********************** GLOBAL ****************/
.container {
display: flex;
justify-content: center;
}
h2#title {
margin: 2px;
text-align: center;
}
.calculator {
padding: 10px;
border: 2px solid black;
border-radius: 10px;
background-color: #dfd8d0;
/* light pink */
}
.display {
background-color: #c3c2ab;
/* retro green */
border-radius: 10px;
border: 2px solid black;
text-align: right;
padding-right: 5px;
}
.display #output {
font-size: 20px;
}
.display #entry {
color: grey;
}
.display p {
margin: 0px;
}
/*********************** BUTTONS ****************/
/* https://gridbyexample.com/examples/example19/ */
.buttons {
display: grid;
grid-template-columns: repeat(4, 50px);
grid-template-rows: repeat(5, 20%);
grid-gap: 10px;
margin-top: 10px;
}
.buttons button {
padding: 5px;
border-radius: 5px;
font-size: 110%;
background-color: black;
color: white;
}
.buttons button[value="AC"], .buttons button[value="CE"] {
background-color: #a72d45;
/* dark red */
}
.buttons #equal-button {
grid-column: 0.8;
grid-row: 0.66667;
}
.buttons #zero-button {
grid-row: 0.83333;
grid-column: 0.33333;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<!-- <link rel="stylesheet" type="text/css" href="../bootstrap.css"/> -->
<head>
<link rel="stylesheet" type="text/css" href="style.min.css"/>
<link rel="stylesheet" type="text/css" href="https://cdnjs.cloudflare.com/ajax/libs/mocha/2.2.5/mocha.min.css">
</head>
<body>
<div class="container">
<div class="calculator">
<!-- TITLE -->
<h2 id="title">Electronic Calculator</h2>
<!-- DISPLAY -->
<div class="display">
<p id="topDisplay">0</p>
<p id="botDisplay">0</p>
</div>
<!-- BUTTONS -->
<div class="buttons"> <!-- button order from topleft to bottom right-->
<button value="AC">AC</button>
<button value="CE">CE</button>
<button value="÷">÷</button>
<button value="x">X</button>
<button value="7" class="num">7</button>
<button value="8" class="num">8</button>
<button value="9" class="num">9</button>
<button value="-">-</button>
<button value="4" class="num">4</button>
<button value="5" class="num">5</button>
<button value="6" class="num">6</button>
<button value="+">+</button>
<button value="1" class="num">1</button>
<button value="2" class="num">2</button>
<button value="3" class="num">3</button>
<button value="=" id="equal-button">=</button> <!-- grid case -->
<button value="0" class="num" id="zero-button" >0</button> <!-- grid case -->
<button value=".">.</button>
</div>
<!-- end buttons-->
</div>
<!--end calculator -->
</div>
<!-- end container -->
<div id="mocha"></div>
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/mocha/2.2.5/mocha.min.js"></script>
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/chai/2.3.0/chai.min.js"></script>
<script type="text/javascript" src="../jquery-3.2.1.min.js"></script>
<script type="text/javascript" src="script.js"></script>
<script type="text/javascript" src="script.test.js"></script>
</body>