Saturday, February 13, 2016

Unit Testing Plain Old JavaScript (Part 3)

After the first two parts of this guide we have a single method and a single test.  At this point we're going to really pick it up.  There should be a lot less "talk" and a lot more "action" in part 3.  I'll try to only explain new concepts.

UPDATE: I've completed the tests.  Check out Part 4 to see the code coverage provided by blanket.js.

The tests (comments in the code):
describe('Hangman', function() {    
    describe('buildAlphabetArray()', function() {
        it('should populate alphabet with 26 characters', function() {
            buildAlphabetArray();
            
            expect(alphabet.length).toBe(26);
        });
    });
    
    describe('newGame()', function(){
        beforeEach(function() {
            // functions are added to the window object when they're not explicitly
            // by creating a spy like this we're telling Jasmine that we want
            // to keep an eye on that method
            spyOn(window, 'buildAlphabetDisplay');
            spyOn(window, 'getNewWord');
            
            // since our newGame() function is going to manipulate the canvas object in the DOM,
            // we need to add it to the DOM before our tests run
            var canvas = document.createElement('canvas');
            canvas.id = "canvas";
            document.body.appendChild(canvas);
        });
        
        afterEach(function() {
            // since we added the canvas to the DOM before the tests, we want to remove it
            // from the DOM after each test
            document.body.removeChild(document.getElementById('canvas'));
        });
                
        it('should set badGuesses to 0', function() {
            badGuesses = 15;
            
            newGame();
            
            expect(badGuesses).toBe(0);
        });
        
        it('should set correctGuesses to 0', function() {
            correctGuesses = 15;
            
            newGame();
            
            expect(correctGuesses).toBe(0);
        });
        
        it('should call getNewWord', function() {
            newGame();
            
            // this expectation is possibly because we spied on this function in the beforeEach
            expect(window.getNewWord).toHaveBeenCalled();
        });
        
        it('should call buildAlphabetDisplay', function() {
            newGame();
            
            expect(window.buildAlphabetDisplay).toHaveBeenCalled();
        });
    });
    
    describe('getNewWord()', function() {
        beforeEach(function() {
            wordToGuess = '';
            
            // when Math.random() is called we want to spy on it (so we'll know it
            // was called), but we also want it to go ahead and return a random number
            spyOn(Math, 'random').and.callThrough();
            // when Math.floor() is called we want to spy on it and always return a 1
            spyOn(Math, 'floor').and.returnValue(1);
            spyOn(window, 'buildPlaceholders');
        });
        
        it('should call Math.random', function() {
            getNewWord();
            
            expect(Math.random).toHaveBeenCalled();
        });
        
        it('should call Math.floor', function() {
            getNewWord();
            
            expect(Math.floor).toHaveBeenCalled();
        });
        
        it('should set wordToGuess to word randomly selected from array', function() {            
            getNewWord();
            
            expect(wordToGuess).toBe('aberrant');
        });
        
        it('should call buildPlaceholders', function() {
            getNewWord();
            
            expect(buildPlaceholders).toHaveBeenCalled();
        });
    });
    
    describe('buildPlaceholders()', function() {
        beforeEach(function() {
            spyOn(document, 'getElementById').and.callThrough();
            
            var wordDiv = document.createElement('div');
            wordDiv.id = "word";
            document.body.appendChild(wordDiv);
        });
        
        afterEach(function() {
            document.body.removeChild(document.getElementById('word'));
        });
        
        it('should call document.getElementById for word', function() {            
            buildPlaceholders();
            
            expect(document.getElementById).toHaveBeenCalledWith('word');
        });
        
        it('should add an element for each letter in wordToGuess', function() {
            wordToGuess = 'apple';
            buildPlaceholders();
            
            var placeholdersDiv = document.getElementById('word');
            expect(placeholdersDiv.innerHTML.length).toBe(5);
        });
        
        it('should add an underscore for each letter in wordToGuess', function() {
            wordToGuess = 'apple';
            buildPlaceholders();
            
            var placeholdersDiv = document.getElementById('word');
            expect(placeholdersDiv.innerHTML[0]).toBe('_');
        });
    });
    
    describe('buildAlphabetDisplay()', function() {
        beforeEach(function() {
            spyOn(window, 'buildAlphabetArray');
            spyOn(document, 'getElementById').and.callThrough();
            spyOn(document, 'createDocumentFragment').and.callThrough();
            spyOn(window, 'buildSingleLetter').and.callThrough();;
            
            var lettersDiv = document.createElement('div');
            lettersDiv.id = "letters";
            document.body.appendChild(lettersDiv);
        });
        
        afterEach(function() {
            document.body.removeChild(document.getElementById('letters'));
        });
        
        it('should call buildAlphabetArray', function() {            
            buildAlphabetDisplay();
            
            expect(window.buildAlphabetArray).toHaveBeenCalled();
        });
        
        it('should call document.getElementById for letters', function() {            
            buildAlphabetDisplay();
            
            expect(document.getElementById).toHaveBeenCalledWith('letters');
        });
        
        it('should call buildSingleLetter once for each letter in alphabet', function() {
            buildAlphabetDisplay();
            
            var lettersDiv = document.getElementById('letters');
            // this expectation is to verify that the function (buildSingleLetter) was called exactly 26 times
            expect(window.buildSingleLetter.calls.count()).toEqual(26);
        });
        
        it('should pass each letter in alphabet once to buildSingleLetter', function() {
            buildAlphabetDisplay();
            
            var lettersDiv = document.getElementById('letters');
            expect(window.buildSingleLetter.calls.allArgs()).toEqual([['A'],['B'],['C'],['D'],['E'],['F'],['G'],['H'],['I'],['J'],['K'],['L'],['M'],['N'],['O'],['P'],['Q'],['R'],['S'],['T'],['U'],['V'],['W'],['X'],['Y'],['Z']]);
        });
        
        it('should call document.createDocumentFragment', function() {
            buildAlphabetDisplay();
            
            expect(document.createDocumentFragment).toHaveBeenCalled();
        });
        
        it('should add a div for each letter', function() {
            buildAlphabetDisplay();
            
            expect(document.getElementById('letters').children.length).toBe(26);
        });
    });
    
    describe('buildSingleLetter()', function() {
        beforeEach(function() {
            spyOn(document, 'createElement').and.callThrough();
        });
        
        it('should call document.createElement', function() {
            buildSingleLetter();
            
            expect(document.createElement).toHaveBeenCalled();
        });
        
        it('should set cursor style to pointer', function() {
            var div = buildSingleLetter('A');
            
            expect(div.style.cursor).toBe('pointer');
        });
        
        it('should set innerHTML to letter passed', function() {
            var div = buildSingleLetter('A');
            
            expect(div.innerHTML).toBe('A');
        });
        
        it('should set onclick event', function() {
            var div = buildSingleLetter('A');
            
            expect(div.onclick).not.toBe(null);
        });
    });
    
    describe('evaluateGuess()', function() {
        beforeEach(function() {
            var letterDiv = document.createElement('div');
            letterDiv.id = 'A';
            letterDiv.innerHTML = 'A';
            letterDiv.style.cursor = 'pointer';
            document.body.appendChild(letterDiv);
            spyOn(document, 'getElementById').and.returnValue(letterDiv);
            spyOn(window, 'checkForGuessedLetter');
        });
        
        afterEach(function() {
            document.body.removeChild(document.getElementById('A'));
        });
        
        it('should call checkForGuessedLetter', function() {
            evaluateGuess();
            
            expect(window.checkForGuessedLetter).toHaveBeenCalled();
        });
        
        it('should set innerHTML of clicked element to non-breaking space', function() {
            var letterDiv = document.getElementById('A');
            evaluateGuess();
            
            expect(letterDiv.innerHTML).toBe(' ');
        });
        
        it('should set cursor style of clicked element to default', function() {
            var letterDiv = document.getElementById('A');
            evaluateGuess();
            
            expect(letterDiv.style.cursor).toBe('default');
        });
        
        it('should set onclick event to null', function() {
            var letterDiv = document.getElementById('A');
            letterDiv.onclick = function() {};
            evaluateGuess();
            
            expect(letterDiv.onclick).toBe(null);
        });
    });
    
    describe('checkForGuessedLetter()', function() {
        beforeEach(function() {
            spyOn(document, 'getElementById').and.callThrough();
            // we can spy on pretty much anything (I haven't found something I wasn't able to spy on),
            // including JavaScript prototype functions like string.split()...
            spyOn(String.prototype, 'split').and.callThrough();
            spyOn(window, 'draw');
            // and Array.indexOf()
            spyOn(Array.prototype, 'indexOf').and.callThrough();
            
            var wordDiv = document.createElement('div');
            wordDiv.id = "word";
            wordDiv.innerHTML = '______';
            document.body.appendChild(wordDiv);
            
            wordToGuess = 'Applea';
        });
        
        afterEach(function() {
            document.body.removeChild(document.getElementById('word'));
        });
        
        it('should call document.getElementById', function() {
            checkForGuessedLetter('A');
            
            expect(document.getElementById).toHaveBeenCalledWith('word');
        });
        
        it('should split string into array', function() {
            checkForGuessedLetter('A');
            
            expect(String.prototype.split).toHaveBeenCalled();
        });
        
        it('should call Array.indexOf', function() {
            checkForGuessedLetter('A');
            
            expect(Array.prototype.indexOf).toHaveBeenCalledWith('A');
        });
        
        it('should call draw when letter is not in word', function() {
            wordToGuess = 'Apple';
            checkForGuessedLetter('Z');
            
            expect(window.draw).toHaveBeenCalled();
        });
        
        it('should not call draw when letter is in word', function() {
            wordToGuess = 'Apple';
            checkForGuessedLetter('A');
            
            expect(window.draw).not.toHaveBeenCalled();
        });
        
        it('should replace all underscores with letter when letter matches', function() {
            checkForGuessedLetter('A');
            var wordDiv = document.getElementById('word');
            
            expect(wordDiv.innerHTML).toBe('A____a');
        });
        
        it('should increment badGuesses by one when letter is not found', function() {
            badGuesses = 1;            
            checkForGuessedLetter('Z');
            
            expect(badGuesses).toBe(2);
        });
        
        it('should not increment badGuesses when letter is found', function() {
            badGuesses = 1;
            checkForGuessedLetter('A');
            
            expect(badGuesses).toBe(1);
        });
        
        it('should increment correctGuesses when letter is found', function() {
            correctGuesses = 1;
            checkForGuessedLetter('A');
            
            expect(correctGuesses).toBe(3);
        });
        
        it('should not increment correctGuesses when letter is not found', function() {
            correctGuesses = 1;
            checkForGuessedLetter('Z');
            
            expect(correctGuesses).toBe(1);
        });
    });
    
    describe('draw()', function() {
        var passedContext, passedStart, passedEnd;
        beforeEach(function() {
            spyOn(document, 'getElementById').and.callThrough();
            spyOn(window, 'showResult');
            spyOn(HTMLCanvasElement.prototype, 'getContext').and.callThrough();
            spyOn(CanvasRenderingContext2D.prototype, 'lineTo').and.callThrough();
            spyOn(CanvasRenderingContext2D.prototype, 'stroke').and.callThrough();
            spyOn(CanvasRenderingContext2D.prototype, 'beginPath').and.callThrough();
            spyOn(CanvasRenderingContext2D.prototype, 'moveTo').and.callThrough();
            spyOn(CanvasRenderingContext2D.prototype, 'arc').and.callThrough();
            spyOn(CanvasRenderingContext2D.prototype, 'fillText').and.callThrough();
            // here we're specifying that when the drawLine function is called we invoke
            // an entirely different, anonymous function
            spyOn(window, 'drawLine').and.callFake(function(context, start, end) {
                passedContext = context;
                passedStart = start;
                passedEnd = end;
            });
            
            var canvas = document.createElement('canvas');
            canvas.id = "canvas";
            document.body.appendChild(canvas);
            
            var letters = document.createElement('div');
            letters.id = "letters";
            letters.innerHTML = 'placeholder text';
            document.body.appendChild(letters);
            
            wordToGuess = 'Apple';
            badGuesses = 0;
            correctGuesses = 0;
            
            passedContext = null;
            passedStart = [0,0];
            passedEnd = [0,0];
        });
        
        afterEach(function() {
            document.body.removeChild(document.getElementById('canvas'));
            document.body.removeChild(document.getElementById('letters'));
        });
        
        it('should call document.getElementById', function() {
            draw();
            
            expect(document.getElementById).toHaveBeenCalledWith('canvas');
        });
        
        it('should call HTMLCanvasElement.getContext', function() {
            draw();
            
            expect(HTMLCanvasElement.prototype.getContext).toHaveBeenCalledWith('2d');
        });
        
        it('should set line color to black', function() {
            draw();
            
            expect(passedContext.fillStyle).toBe('#a52a2a');
        });
        
        it('should set line width to 10', function() {
            draw();
            
            expect(passedContext.lineWidth).toBe(10);
        });
        
        it('should call drawLine', function() {
            draw();
            
            expect(window.drawLine).toHaveBeenCalled();
        });
        
        it('should call drawLine twice when one bad guess has been made', function() {
            badGuesses = 1;
            draw();
            
            expect(window.drawLine.calls.count()).toBe(2);
        });
        
        it('should pass in coordinates to start gallow pole when one bad guess has been made', function() {
            badGuesses = 1;
            draw();
            
            // this expectation is checking the arguments passed to the most recent call
            // to the drawLine function
            expect(window.drawLine.calls.mostRecent().args[1]).toEqual([30,185]);
        });
        
        it('should pass in coordinates to end gallow pole when one bad guess has been made', function() {
            badGuesses = 1;
            draw();
            
            expect(window.drawLine.calls.mostRecent().args[2]).toEqual([30,10]);
        });
        
        it('should draw gallow arm when two bad guesses have been made', function() {
            badGuesses = 2;
            draw();
            
            expect(CanvasRenderingContext2D.prototype.lineTo).toHaveBeenCalled();
            expect(CanvasRenderingContext2D.prototype.stroke).toHaveBeenCalled();
        });
        
        it('should call drawLine three times when three bad guesses have been made', function() {
            badGuesses = 3;
            draw();
            
            expect(window.drawLine.calls.count()).toBe(3);
        });
        
        it('should pass in coordinates to start noose when three bad guesses have been made', function() {
            badGuesses = 3;
            draw();
            
            expect(window.drawLine.calls.mostRecent().args[1]).toEqual([145,15]);
        });
        
        it('should pass in coordinates to end noose when three bad guesses have been made', function() {
            badGuesses = 3;
            draw();
            
            expect(window.drawLine.calls.mostRecent().args[2]).toEqual([145,30]);
        });
        
        it('should draw head when three bad guesses have been made', function() {
            badGuesses = 3;
            draw();
            
            // although most testing experts consider testing multiple expectations in a single
            // spec to be bad form, this is one of the situations where it didn't make sense to me to break it out into 
            // 11 separate tests
            expect(CanvasRenderingContext2D.prototype.beginPath.calls.count()).toBe(1);
            expect(CanvasRenderingContext2D.prototype.moveTo.calls.count()).toBe(1);
            expect(CanvasRenderingContext2D.prototype.moveTo.calls.mostRecent().args[0]).toBe(160);
            expect(CanvasRenderingContext2D.prototype.moveTo.calls.mostRecent().args[1]).toBe(45);
            expect(CanvasRenderingContext2D.prototype.arc.calls.count()).toBe(1);
            expect(CanvasRenderingContext2D.prototype.arc.calls.mostRecent().args[0]).toBe(145);
            expect(CanvasRenderingContext2D.prototype.arc.calls.mostRecent().args[1]).toBe(45);
            expect(CanvasRenderingContext2D.prototype.arc.calls.mostRecent().args[2]).toBe(15);
            expect(CanvasRenderingContext2D.prototype.arc.calls.mostRecent().args[3]).toBe(0);
            expect(CanvasRenderingContext2D.prototype.arc.calls.mostRecent().args[4]).toBe((Math.PI/180)*360);
            expect(CanvasRenderingContext2D.prototype.stroke.calls.count()).toBe(2);
        });
        
        it('should call drawLine four times when four bad guesses have been made', function() {
            badGuesses = 4;
            draw();
            
            expect(window.drawLine.calls.count()).toBe(4);
        });
        
        it('should pass in coordinates to start body when four bad guesses have been made', function() {
            badGuesses = 4;
            draw();
            
            expect(window.drawLine.calls.mostRecent().args[1]).toEqual([145,60]);
        });
        
        it('should pass in coordinates to end body when four bad guesses have been made', function() {
            badGuesses = 4;
            draw();
            
            expect(window.drawLine.calls.mostRecent().args[2]).toEqual([145,130]);
        });
        
        it('should call drawLine five times when five bad guesses have been made', function() {
            badGuesses = 5;
            draw();
            
            expect(window.drawLine.calls.count()).toBe(5);
        });
        
        it('should pass in coordinates to start left arm when five bad guesses have been made', function() {
            badGuesses = 5;
            draw();
            
            expect(window.drawLine.calls.mostRecent().args[1]).toEqual([145,80]);
        });
        
        it('should pass in coordinates to end left arm when five bad guesses have been made', function() {
            badGuesses = 5;
            draw();
            
            expect(window.drawLine.calls.mostRecent().args[2]).toEqual([110,90]);
        });
        
        it('should call drawLine six times when six bad guesses have been made', function() {
            badGuesses = 6;
            draw();
            
            expect(window.drawLine.calls.count()).toBe(6);
        });
        
        it('should pass in coordinates to start right arm when six bad guesses have been made', function() {
            badGuesses = 6;
            draw();
            
            expect(window.drawLine.calls.mostRecent().args[1]).toEqual([145,80]);
        });
        
        it('should pass in coordinates to end right arm when six bad guesses have been made', function() {
            badGuesses = 6;
            draw();
            
            expect(window.drawLine.calls.mostRecent().args[2]).toEqual([180,90]);
        });
        
        it('should call drawLine seven times when seven bad guesses have been made', function() {
            badGuesses = 7;
            draw();
            
            expect(window.drawLine.calls.count()).toBe(7);
        });
        
        it('should pass in coordinates to start left leg when seven bad guesses have been made', function() {
            badGuesses = 7;
            draw();
            
            expect(window.drawLine.calls.mostRecent().args[1]).toEqual([145,130]);
        });
        
        it('should pass in coordinates to end left leg when seven bad guesses have been made', function() {
            badGuesses = 7;
            draw();
            
            expect(window.drawLine.calls.mostRecent().args[2]).toEqual([130,170]);
        });
        
        it('should call drawLine eight times when eight bad guesses have been made', function() {
            badGuesses = 8;
            draw();
            
            expect(window.drawLine.calls.count()).toBe(8);
        });
        
        it('should pass in coordinates to start right leg when eight bad guesses have been made', function() {
            badGuesses = 8;
            draw();
            
            expect(window.drawLine.calls.mostRecent().args[1]).toEqual([145,130]);
        });
        
        it('should pass in coordinates to end right leg when eight bad guesses have been made', function() {
            badGuesses = 8;
            draw();
            
            expect(window.drawLine.calls.mostRecent().args[2]).toEqual([160,170]);
        });
        
        it('should call fillText with Game Over when eight bad guesses have been made', function() {
            badGuesses = 8;
            draw();
            
            expect(CanvasRenderingContext2D.prototype.fillText).toHaveBeenCalledWith('Game Over!', 45, 110);
        });
        
        it('should clear alphabet when eight bad guesses have been made', function() {
            badGuesses = 8;
            draw();
            
            var letters = document.getElementById('letters');
            expect(letters.innerHTML).toBe('');
        });
        
        it('should clear alphabet when word has been guessed correctly', function() {
            correctGuesses = wordToGuess.length;
            draw();
            
            var letters = document.getElementById('letters');
            expect(letters.innerHTML).toBe('');
        });
        
        it('should call fillText with You Won when word has been guessed correctly', function() {
            correctGuesses = wordToGuess.length;
            draw();
            
            expect(CanvasRenderingContext2D.prototype.fillText).toHaveBeenCalledWith('You Won!', 45, 110);
        });
    });

    describe('init()', function() {
        beforeEach(function() {
            var loading = document.createElement('p');
            loading.id = 'loading';
            document.body.appendChild(loading);
            
            var play = document.createElement('div');
            play.id = 'play';
            play.style.display = 'none';
            play.onclick = null;
            document.body.appendChild(play);
            
            var clear = document.createElement('div');
            clear.id = 'clear';
            clear.style.display = 'none';
            clear.onclick = null;
            document.body.appendChild(clear);
            
            var help = document.createElement('div');
            help.id = 'help';
            help.onclick = null;
            help.style.display = 'none';
            document.body.appendChild(help);
            
            var helpText = document.createElement('div');
            helpText.id = 'helpText';
            helpText.style.display = 'none';
            document.body.appendChild(helpText);
            
            var close = document.createElement('div');
            close.id = 'close';
            close.onclick = null;
            close.style.display = 'none';
            document.body.appendChild(close);
        });
        
        afterEach(function() {
            document.body.removeChild(document.getElementById('loading'));
            document.body.removeChild(document.getElementById('play'));
            document.body.removeChild(document.getElementById('clear'));
            document.body.removeChild(document.getElementById('help'));
            document.body.removeChild(document.getElementById('helpText'));
            document.body.removeChild(document.getElementById('close'));
        });
        
        it('should hide loading div', function() {
            init();
            
            expect(document.getElementById('loading').style.display).toBe('none');
        });
        
        it('should show play div', function() {
            init();
            
            expect(document.getElementById('play').style.display).toBe('inline-block');
        });
        
        it('should show clear div', function() {
            init();
            
            expect(document.getElementById('clear').style.display).toBe('inline-block');
        });
        
        it('should set onclick event of help div', function() {
            init();
            var help = document.getElementById('help');
            
            expect(help.onclick).not.toBe(null);
        });
        
        it('should set onclick event of close help div', function() {
            init();
            var close = document.getElementById('close');
            
            expect(close.onclick).not.toBe(null);
        });
    });
    
    describe('showHelp()', function() {
        beforeEach(function() {
            spyOn(document.body, 'appendChild').and.callThrough();
            
            var help = document.createElement('div');
            help.id = 'help';
            help.onclick = null;
            help.style.display = 'none';
            document.body.appendChild(help);
            
            var helpText = document.createElement('div');
            helpText.id = 'helpText';
            helpText.style.display = 'none';
            document.body.appendChild(helpText);
        });
        
        afterEach(function() {
            document.body.removeChild(document.getElementById('helpText'));
            document.body.removeChild(document.getElementById('help'));
            document.body.removeChild(document.getElementById('mask'));
        });
        
        it('should append mask div to body', function() {
            showHelp();
            
            expect(document.body.appendChild.calls.count()).toBe(3);
        });
        
        it('should display helpText div', function() {
            showHelp();
            
            expect(document.getElementById('helpText').style.display).toBe('block');
        });
    });
    
    describe('closeHelp()', function() {
        beforeEach(function() {
            var close = document.createElement('div');
            close.id = 'close';
            close.onclick = null;
            close.style.display = 'none';
            document.body.appendChild(close);
            
            var mask = document.createElement('div');
            mask.id = 'mask';
            document.body.appendChild(mask);
        });
        
        afterEach(function() {
            document.body.removeChild(document.getElementById('close'));
        });
        
        it('should remove mask from body', function() {
            closeHelp();
            
            var mask = document.getElementById('mask');
            expect(mask).toBe(null);
        });
    });
    
    describe('showResult()', function() {
        beforeEach(function() {
            spyOn(document, 'getElementById').and.callThrough();
            spyOn(String.prototype, 'split').and.callThrough();
            spyOn(Array.prototype, 'join').and.callThrough();
            
            var wordDiv = document.createElement('div');
            wordDiv.id = "word";
            wordDiv.innerHTML = 'a__l_';
            document.body.appendChild(wordDiv);
            
            showResult();
        });
        
        afterEach(function() {
            document.body.removeChild(document.getElementById('word'));
        });
        
        it('should call document.getElementById', function() {            
            expect(document.getElementById).toHaveBeenCalledWith('word');
        });

        it('should call String.split', function() {            
            expect(String.prototype.split).toHaveBeenCalledWith('');
        });
        
        it('should call Array.join', function() {            
            expect(Array.prototype.join).toHaveBeenCalledWith('');
        });
        
        it('should replace all underscores with their letter', function() {            
            var wordDiv = document.getElementById('word');
            expect(wordDiv.innerHTML).toBe('a<span style="color:red">P</span><span style="color:red">P</span>l<span style="color:red">E</span>');
        });
    });
    
    describe('drawLine()', function() {
        var context;
        
        beforeEach(function() {
            spyOn(CanvasRenderingContext2D.prototype, 'beginPath').and.callThrough();
            spyOn(CanvasRenderingContext2D.prototype, 'moveTo').and.callThrough();
            spyOn(CanvasRenderingContext2D.prototype, 'lineTo').and.callThrough();
            spyOn(CanvasRenderingContext2D.prototype, 'stroke').and.callThrough();
            
            var canvas = document.createElement('canvas');
            canvas.id = "canvas";
            document.body.appendChild(canvas);
            
            context = canvas.getContext('2d');
            drawLine(context, [145,15], [145,30]);
        });
        
        afterEach(function() {
            document.body.removeChild(document.getElementById('canvas'));
        });
        
        it('should invoke context.beginPath', function() {
            expect(CanvasRenderingContext2D.prototype.beginPath).toHaveBeenCalled();
        });
        
        it('should invoke context.moveTo', function() {
            expect(CanvasRenderingContext2D.prototype.moveTo).toHaveBeenCalledWith(145, 15);
        });
        
        it('should invoke context.lineTo', function() {
            expect(CanvasRenderingContext2D.prototype.lineTo).toHaveBeenCalledWith(145, 30);
        });
        
        it('should invoke context.beginPath', function() {
            expect(CanvasRenderingContext2D.prototype.stroke).toHaveBeenCalled();
        });
    });
});

The code:
var alphabet = [];
var badGuesses, correctGuesses;
var wordToGuess = '';
var wordArray = new Array('abate','aberrant','abscond','accolade','acerbic','acumen','adulation','adulterate','aesthetic','aggrandize','alacrity','alchemy','amalgamate','ameliorate','amenable','anachronism','anomaly','approbation','archaic','arduous','ascetic','assuage','astringent','audacious','austere','avarice','aver','axiom','bolster','bombast','bombastic','bucolic','burgeon','cacophony','canon','canonical','capricious','castigation','catalyst','caustic','censure','chary','chicanery','cogent','complaisance','connoisseur','contentious','contrite','convention','convoluted','credulous','culpable','cynicism','dearth','decorum','demur','derision','desiccate','diatribe','didactic','dilettante','disabuse','discordant','discretion','disinterested','disparage','disparate','dissemble','divulge','dogmatic','ebullience','eccentric','eclectic','effrontery','elegy','eloquent','emollient','empirical','endemic','enervate','enigmatic','ennui','ephemeral','equivocate','erudite','esoteric','eulogy','evanescent','exacerbate','exculpate','exigent','exonerate','extemporaneous','facetious','fallacy','fawn','fervent','filibuster','flout','fortuitous','fulminate','furtive','garrulous','germane','glib','grandiloquence','gregarious','hackneyed','halcyon','harangue','hedonism','hegemony','heretical','hubris','hyperbole','iconoclast','idolatrous','imminent','immutable','impassive','impecunious','imperturbable','impetuous','implacable','impunity','inchoate','incipient','indifferent','inert','infelicitous','ingenuous','inimical','innocuous','insipid','intractable','intransigent','intrepid','inured','inveigle','irascible','laconic','laud','loquacious','lucid','luminous','magnanimity','malevolent','malleable','martial','maverick','mendacity','mercurial','meticulous','misanthrope','mitigate','mollify','morose','mundane','nebulous','neologism','neophyte','noxious','obdurate','obfuscate','obsequious','obstinate','obtuse','obviate','occlude','odious','onerous','opaque','opprobrium','oscillation','ostentatious','paean','parody','pedagogy','pedantic','penurious','penury','perennial','perfidy','perfunctory','pernicious','perspicacious','peruse','pervade','pervasive','phlegmatic','pine','pious','pirate','pith','pithy','placate','platitude','plethora','plummet','polemical','pragmatic','prattle','precipitate','precursor','predilection','preen','prescience','presumptuous','prevaricate','pristine','probity','proclivity','prodigal','prodigious','profligate','profuse','proliferate','prolific','propensity','prosaic','pungent','putrefy','quaff','qualm','querulous','query','quiescence','quixotic','quotidian','rancorous','rarefy','recalcitrant','recant','recondite','redoubtable','refulgent','refute','relegate','renege','repudiate','rescind','reticent','reverent','rhetoric','salubrious','sanction','satire','sedulous','shard','solicitous','solvent','soporific','sordid','sparse','specious','spendthrift','sporadic','spurious','squalid','squander','static','stoic','stupefy','stymie','subpoena','subtle','succinct','superfluous','supplant','surfeit','synthesis','tacit','tenacity','terse','tirade','torpid','torque','tortuous','tout','transient','trenchant','truculent','ubiquitous','unfeigned','untenable','urbane','vacillate','variegated','veracity','vexation','vigilant','vilify','virulent','viscous','vituperate','volatile','voracious','waver','zealous');

function buildAlphabetArray() {
    alphabet = ['A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z'];
}

function buildAlphabetDisplay() {
    buildAlphabetArray();
    
    var letters = document.getElementById('letters');
    var fragment = document.createDocumentFragment();
    
    letters.innerHTML = '';
    
    for(var i = 0; i < alphabet.length; i++) {
        var div = buildSingleLetter(alphabet[i]);
        div.id = alphabet[i];
        fragment.appendChild(div);
    }
    
    letters.appendChild(fragment);
}

function buildPlaceholders() {
    var word = document.getElementById('word');
    word.innerHTML = '';
    for(var i = 0; i < wordToGuess.length; i++){
        word.innerHTML += '_';
    }
}

function buildSingleLetter(letter) {
    var div = document.createElement('div');
    div.style.cursor = 'pointer';
    div.innerHTML = letter;
    div.onclick = evaluateGuess;
    return div;
}

function checkForGuessedLetter(letter) {
    var placeholders = document.getElementById('word').innerHTML;
    
    // split the placeholders into an array
    placeholders = placeholders.split('');
    
    var letterArray = wordToGuess.split('');
    if (letterArray.indexOf(letter) === -1 && letterArray.indexOf(letter.toLowerCase()) === -1) {
        badGuesses++;
        draw();
    } else {        
        for (var i = 0; i < placeholders.length; i++) {
            if (wordToGuess.charAt(i).toLowerCase() == letter.toLowerCase()) {
                placeholders[i] = wordToGuess.charAt(i);
                correctGuesses++;
            }
        }
        
        if (correctGuesses === wordToGuess.length) {
            draw();
        }
    }
    
    word.innerHTML = placeholders.join('');
}

function closeHelp() {
    document.body.removeChild(document.getElementById('mask'));
}

function draw() {
    var canvas = document.getElementById('canvas');
    var context = canvas.getContext('2d');
        
    context.lineWidth = 10;
    context.fillStyle = 'brown';    
    // draw the ground
    drawLine(context, [20,190], [180,190]);
    
    if (badGuesses > 0) {
        drawLine(context, [30,185], [30,10]);
        
        if (badGuesses > 1) {
            context.lineTo(150, 10);
            context.stroke();
        }
        
        if (badGuesses > 2) {
            // draw rope
            drawLine(context, [145,15], [145,30]);
            // draw head
            context.beginPath();
            context.moveTo(160, 45);
            context.arc(145, 45, 15, 0, (Math.PI/180)*360);
            context.stroke();
        }
        
        if (badGuesses > 3) {
            // draw body
            drawLine(context, [145,60], [145,130]);
        }
        
        if (badGuesses > 4) {
            // draw left arm
            drawLine(context, [145,80], [110,90]);
        }
        
        if (badGuesses > 5) {
            // draw right arm
            drawLine(context, [145,80], [180,90]);
        }
        
        if (badGuesses > 6) {
            // draw left leg
            drawLine(context, [145,130], [130,170]);
        }
        
        if (badGuesses > 7) {
            // draw right leg
            drawLine(context, [145,130], [160,170]);
            // display game over message
            context.fillText('Game Over!', 45, 110);
            // clear alphabet
            document.getElementById('letters').innerHTML = '';
            
            setTimeout(showResult, 200);
        }
    }
    
    if (correctGuesses == wordToGuess.length) {
        document.getElementById('letters').innerHTML = '';
        context.fillText('You Won!', 45,110);
    }
}

function drawLine(context, from, to) {
    context.beginPath();
    context.moveTo(from[0], from[1]);
    context.lineTo(to[0], to[1]);
    context.stroke();
}

function evaluateGuess() {
    var letter = document.getElementById(this.id);
    checkForGuessedLetter(letter.innerHTML);
    letter.innerHTML = '&nbsp;';
    letter.style.cursor = 'default';
    letter.onclick = null;
}

function getNewWord() {
    var index = parseInt(Math.floor(Math.random() * wordArray.length));
    wordToGuess = wordArray[index];
    buildPlaceholders();
}

function init() {
    document.getElementById('loading').style.display = 'none';
    document.getElementById('play').style.display = 'inline-block';
    document.getElementById('clear').style.display = 'inline-block';
    document.getElementById('help').onclick = showHelp;
    document.getElementById('close').onclick = closeHelp;
    document.getElementById('play').onclick = newGame;
}

function newGame() {    
    badGuesses = 0;
    correctGuesses = 0;
    getNewWord();    
    buildAlphabetDisplay();
    var canvas = document.getElementById('canvas');
    canvas.width = canvas.width;
}

function showHelp() {
    var mask = document.createElement('div');
    mask.id = 'mask';
    document.body.appendChild(mask);
    
    document.getElementById('helpText').style.display = 'block';
}

// When the game is over, display missing letters in red
function showResult() {
    var word = document.getElementById('word');
    var placeholders = word.innerHTML;
    placeholders = placeholders.split('');
    for (i = 0; i < placeholders.length; i++) {
        if (placeholders[i] == '_') {
            placeholders[i] = '<span style="color:red">' + wordToGuess.charAt(i).toUpperCase() + '</span>';
        }
    }
    word.innerHTML = placeholders.join('');
}

The markup:
<!DOCTYPE HTML>
<html class="no-js">
<head>
<meta charset="utf-8">
<title>Hangman</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="styles/hangman.css" rel="stylesheet" type="text/css">
<script src="src/hangman.js"></script>
</head>

<body>
<h1>Hangman</h1>
<div id="help"></div>
<div id="helptext">
    <h2>How to Play</h2>
    <div id="close"></div>
    <p>Hangman is a word-guessing game. Click or tap New Game to display the letters of the alphabet and a row of dashes indicating the number of letters to be guessed. Click or tap a letter. If it's in the word, it replaces the dash(es). Each wrong guess results in a stroke being added to a gallows and its victim. Your role is to guess the word correctly before the victim meets his grisly fate.</p>
</div>
<p id="loading">Game loading. . .</p>
<canvas id="canvas" width="200" height="200">Sorry, your browser needs to support canvas for this game.</canvas>
<div id="play">New Game</div> <div id="clear">Clear Score</div>
<p id="word"></p>
<div id="letters"></div>
<script>
    init();
</script>
</body>
</html>

And now you have a working, fully tested, pure JavaScript/HTML version of Hangman.  h/t to David Powers at adobe.com for doing Hangman first.  I used a lot of what he wrote (including his CSS), but modified it to be TDD and pure JavaScript.

No comments:

Post a Comment