diff --git a/exercises/05_code_quality/currency/README.md b/exercises/05_code_quality/currency/README.md new file mode 100644 index 00000000..923f3332 --- /dev/null +++ b/exercises/05_code_quality/currency/README.md @@ -0,0 +1,4 @@ + +# Currency Converter + +This package converts from one currency to another using the current exchange rates. \ No newline at end of file diff --git a/exercises/05_code_quality/currency/index.js b/exercises/05_code_quality/currency/index.js new file mode 100644 index 00000000..b168850c --- /dev/null +++ b/exercises/05_code_quality/currency/index.js @@ -0,0 +1,53 @@ + +'use strict' + +const request = require('request') +const decimalPlaces = 2 + +exports.convert = (base, symbol, units, callback) => { + try { + checkValidCurrencyCode(base, err => { + if (err) callback(err) + checkValidCurrencyCode(symbol, err => { + if (err) callback(err) + base = base.trim() + symbol = symbol.trim() + getData(`http://api.fixer.io/latest?base=${base}&symbols=${symbol}`, (err, data) => { + if (err) callback(err) + const obj = JSON.parse(data) + //printObject(obj) + const result = parseFloat((obj.rates[symbol] * units).toFixed(decimalPlaces)) + //console.log(result) + callback(null, result) + }) + }) + }) + } catch(err) { + console.log(err.message) + } +} + +function checkValidCurrencyCode(code, callback) { + console.log(`currency: ${JSON.stringify(code)}`) + code = code.trim() + request('http://api.fixer.io/latest', (err, res, body) => { + if (err) callback(new Error('invalid API call')) + const rates = JSON.parse(body).rates + if (!rates.hasOwnProperty(code)) callback(new Error(`invalid currency code ${code}`)) + callback(null, true) + }) +} + +function getData(url, callback) { + request(url, (err, res, body) => { + if (err) callback(new Error('invalid API call')) + callback(null, body) + }) +} + +function printObject(data) { + const indent = 2 + const str = JSON.stringify(data, null, indent) + console.log(str) +} + diff --git a/exercises/05_code_quality/currency/package.json b/exercises/05_code_quality/currency/package.json new file mode 100644 index 00000000..328040ee --- /dev/null +++ b/exercises/05_code_quality/currency/package.json @@ -0,0 +1,12 @@ +{ + "name": "currency", + "version": "0.0.3", + "private": true, + "dependencies": { + "request": "^2.81.0" + }, + "devDependencies": { + "jasmine": "^2.5.3", + "jasmine-console-reporter": "^1.2.7" + } +} diff --git a/exercises/05_code_quality/currency/spec/currency-spec.js b/exercises/05_code_quality/currency/spec/currency-spec.js new file mode 100644 index 00000000..8ae73076 --- /dev/null +++ b/exercises/05_code_quality/currency/spec/currency-spec.js @@ -0,0 +1,36 @@ + +'use strict' + +// need to allow the use of arbitrary numbers when testing +/* eslint no-magic-numbers: 0 */ + +const currency = require('../index.js') + +describe('currency module', () => { + it('should return a number', done => { + currency.convert('GBP', 'USD', 100, (err, data) => { + try { + if(err) throw err // throw errors to the catch block + expect(data).toEqual(jasmine.any(Number)) + expect(data).toBeGreaterThan(0) + done() + } catch(err) { + expect(true).toBe(false) // no error should be thrown + } + }) + }) + + it('should return a value with 2 decimal places', done => { + currency.convert('GBP', 'USD', 111, (err, data) => { + try { + if(err) throw err // throw errors to the catch block + expect(data).toEqual(jasmine.any(Number)) + expect(data).toBeGreaterThan(0) + expect(data).toEqual(parseFloat(data.toFixed(2))) + done() + } catch(err) { + expect(true).toBe(false) // no error should be thrown + } + }) + }) +}) diff --git a/exercises/05_code_quality/currency/spec/jasmine.json b/exercises/05_code_quality/currency/spec/jasmine.json new file mode 100644 index 00000000..0ed867ba --- /dev/null +++ b/exercises/05_code_quality/currency/spec/jasmine.json @@ -0,0 +1,9 @@ + +{ + "spec_files": [ + "spec/currency-spec.js" + ], + "helpers": [ + "helpers/**/*.js" + ] +} \ No newline at end of file diff --git a/exercises/05_code_quality/currency/spec/runTests.js b/exercises/05_code_quality/currency/spec/runTests.js new file mode 100644 index 00000000..01aa6b56 --- /dev/null +++ b/exercises/05_code_quality/currency/spec/runTests.js @@ -0,0 +1,20 @@ + +'use strict' + +const Jasmine = require('jasmine') +const jasmine = new Jasmine() + +jasmine.loadConfigFile('spec/jasmine.json') + +const JasmineConsoleReporter = require('jasmine-console-reporter') +const reporter = new JasmineConsoleReporter({ + colors: true, + cleanStack: true, + verbosity: 4, + listStyle: 'indent', + activity: true +}) + +jasmine.addReporter(reporter) + +jasmine.execute() diff --git a/exercises/05_code_quality/currencyTest/index.js b/exercises/05_code_quality/currencyTest/index.js new file mode 100644 index 00000000..e00dd700 --- /dev/null +++ b/exercises/05_code_quality/currencyTest/index.js @@ -0,0 +1,37 @@ + +'use strict' + +const currency = require('currency') + +try { + getInput('enter base currency', (err, data) => { + if (err) throw err + const base = data + getInput('enter currency required', (err, data) => { + if (err) throw err + const symbol = data + getInput('enter amount to convert', (err, data) => { + if (err) throw err + const units = data + currency.convert(base, symbol, units, (err, data) => { + if (err) throw err + console.log(`${data} ${symbol}`) + }) + }) + }) + }) +} catch(err) { + console.log(err.message) +} + + +function getInput(prompt, callback) { + try { + process.stdin.resume() + process.stdin.setEncoding('utf8') + process.stdout.write(`${prompt}: `) + process.stdin.on('data', text => callback(null, text.trim())) + } catch(err) { + callback(err) + } +} diff --git a/exercises/05_code_quality/currencyTest/package.json b/exercises/05_code_quality/currencyTest/package.json new file mode 100644 index 00000000..f1385ba8 --- /dev/null +++ b/exercises/05_code_quality/currencyTest/package.json @@ -0,0 +1,14 @@ +{ + "name": "currency-test", + "version": "1.0.0", + "description": "test for the local currency package", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "Mark J Tyers", + "license": "ISC", + "dependencies": { + "currency": "file:///Users/marktyers/Documents/304CEM/06 Writing Robust Code/currency" + } +} diff --git a/exercises/05_code_quality/shopping/debug.js b/exercises/05_code_quality/shopping/debug.js new file mode 100644 index 00000000..85c44586 --- /dev/null +++ b/exercises/05_code_quality/shopping/debug.js @@ -0,0 +1,25 @@ + +'use strict' + +const list = require('./modules/shopping') +debugger +list.add('bread') +list.add('butter') +debugger +list.add('cheese') +list.add('bread') +list.add('bread') +list.add('butter') +debugger +try { + list.decrement('bread', 2) + list.decrement('butter', 4) + list.decrement('jam', 3) +} catch(err) { + console.log('ERROR: '+err.message) +} + +const items = list.getAll() +items.forEach( (element, index) => { + console.log(index+'. '+element.title+'\t x'+element.qty) +}) \ No newline at end of file diff --git a/exercises/05_code_quality/shopping/index.js b/exercises/05_code_quality/shopping/index.js new file mode 100644 index 00000000..cabc0a0e --- /dev/null +++ b/exercises/05_code_quality/shopping/index.js @@ -0,0 +1,27 @@ +"use strict" + +const readline = require("readline-sync"); + +const list = require('./modules/shopping'); + +do + { + var input = String(readline.question("enter command: ")).trim() + if (input.indexOf('add ') == 0) { + let space = input.indexOf(' '); + let item = input.substring(space).trim(); + /* Let's echo the item details back to the user before adding it. Notice the use of Template Literals (https://goo.gl/Yjxa5o), a part of ECMA6. This allows you to include placeholders for variables without the need for concatenation. The string must be enclosed by backticks (`) */ + console.log(`adding "${item}"`) + list.add(item) + } + if (input.indexOf("list") == 0) { + const items = list.getAll() + let i = 1 + for (var value of items) { + /* process.stdout.write prints to the terminal without adding a newline character at the end. the \t character inserts a tab and the \n character inserts a newline */ + process.stdout.write(`${i}. ${value.title}`) + process.stdout.write(`\t${value.qty}\n`) + i++ + } + } +} while (input !== "exit"); diff --git a/exercises/05_code_quality/shopping/modules/shopping.js b/exercises/05_code_quality/shopping/modules/shopping.js new file mode 100644 index 00000000..5b6c0c69 --- /dev/null +++ b/exercises/05_code_quality/shopping/modules/shopping.js @@ -0,0 +1,93 @@ +'use strict' + +/** + * Shopping Module. + * @module shopping + */ + +/** this global 'map' object will be used to store the items and quantities. It stores each item against a named key. */ +var data = new Map(); + +/** add a new item to the todo list. Notice that we are using the new 'Arrow Function' syntax from the ECMA6 specification. */ +exports.add = item => { + /* check if the item is already in the list */ + if (data.get(item) == undefined) + { + /* if the item is not found it is added to the list with its quantity set to 1 */ + data.set(item, {title: item, qty: 1}) + } + else + { + /* the item is already in the list so it is retrieved and the quantity is incremented */ + let i = data.get(item) + i.qty++ + /* the new data is then stored in the map */ + data.set(item, i) + } + return data.get(item) +} + + +/** calculates and returns the number of items in the list */ +exports.count = () => { + /* the _Map_ object has a _size_ property that returns the number of items */ + return data.size +} + +/** empties the list */ +exports.clear = () => { + data = new Map() +} + +/** Returns an array containing all todo items */ +exports.getAll = () => { + let data_array = [] + for (var value of data.values()) { + data_array.push(value) + } + return data_array +} + +/** + * Returns details for the named item. + * @param {string} item - The item name to retrieve. + * @returns {string} The name of the item + * @throws {InvalidItem} item not in list. + */ +exports.getItem = item => { + if (data.get(item) === undefined) { + throw new Error('item not in list') + } + return data.get(item) +} + +/** Removes item with the corresponding name. */ +exports.removeItem = item => { + console.log('removeItem') + if (data.get(item) === undefined) { + throw new Error('item not in list') + } + data.delete(item) +} + +/** Decrements the quantity of an item in the list. */ +exports.decrement = (item, count) => { + console.log(arguments) + if (item === undefined || count === undefined) { + throw new Error('function requires two parameters') + } + if (data.get(item) === undefined) { + throw new Error('item not in list') + } + if (isNaN(count)) { + throw new Error('second parameter should be a number') + } + count = parseInt(count) + let current = data.get(item) + if (current.qty <= count) { + data.delete(item) + return + } + current.qty = current.qty - count + data.set(item, current) +} diff --git a/exercises/05_code_quality/shopping/package.json b/exercises/05_code_quality/shopping/package.json new file mode 100644 index 00000000..ecbfeb5d --- /dev/null +++ b/exercises/05_code_quality/shopping/package.json @@ -0,0 +1,30 @@ +{ + "name": "shopping", + "version": "1.0.0", + "description": "simple shopping list", + "main": "index.js", + "scripts": { + "app": "node index.js", + "lint": "node_modules/.bin/eslint modules/", + "test": "node_modules/.bin/jasmine-node spec --color --verbose --autotest --watch .", + "coverage": "./node_modules/.bin/istanbul cover -x **spec/** -x **index.js** -x **debug.js** ./node_modules/.bin/jasmine-node spec", + "doc": "node_modules/.bin/jsdoc modules/" + }, + "keywords": [ + "list", + "jasmine", + "module" + ], + "author": "Mark J Tyers", + "license": "ISC", + "dependencies": { + "readline-sync": "^1.2.22" + }, + "devDependencies": { + "eslint": "^3.1.0", + "istanbul": "^0.4.0", + "jasmine-node": "^1.14.5", + "jsdoc": "^3.4.0", + "node-inspector": "^0.12.8" + } +} diff --git a/exercises/05_code_quality/simpleDebug.js b/exercises/05_code_quality/simpleDebug.js new file mode 100644 index 00000000..a1116a19 --- /dev/null +++ b/exercises/05_code_quality/simpleDebug.js @@ -0,0 +1,25 @@ + +'use strict' + +const data = new Map() + +const readline = require('readline-sync') +let input +do { + input = String(readline.question('enter command: ')).trim() + debugger + if (input.indexOf('add ') === 0) { + const space = input.indexOf(' ') + const item = input.substring(space).trim() + console.log(`adding '${item}'`) + let qty = 1 + debugger + if (data.has(item)) qty = data.get(item) + data.set(item, qty) + } + if (input.indexOf('list') === 0) { + data.forEach( (val, key) => { + process.stdout.write(`${key}\t${val}\n`) + }) + } +} while (input !== 'exit') diff --git a/exercises/06_unit_testing/mongo/index.js b/exercises/06_unit_testing/mongo/index.js new file mode 100755 index 00000000..0bddd83d --- /dev/null +++ b/exercises/06_unit_testing/mongo/index.js @@ -0,0 +1,23 @@ +#!/usr/bin/env node + +'use strict' + +const lists = require('./lists') + +const list = { + name: 'colours', + list: [ + 'red', + 'orange', + 'yellow' + ] +} + +lists.add(list, (err, data) => { + if (err) console.log(err) + console.log(data) + lists.count( (err, count) => { + if (err) console.log(err) + console.log(`${count} documents found`) + }) +}) diff --git a/exercises/06_unit_testing/mongo/jasmine.js b/exercises/06_unit_testing/mongo/jasmine.js new file mode 100755 index 00000000..b59c31c5 --- /dev/null +++ b/exercises/06_unit_testing/mongo/jasmine.js @@ -0,0 +1,21 @@ +#!/usr/bin/env node + +'use strict' + +const Jasmine = require('jasmine') +const jasmine = new Jasmine() + +jasmine.loadConfigFile('jasmine.json') + +const JasmineConsoleReporter = require('jasmine-console-reporter') +const reporter = new JasmineConsoleReporter({ + colors: true, + cleanStack: true, + verbosity: 3, + listStyle: 'indent', + activity: true +}) + +jasmine.addReporter(reporter) + +jasmine.execute() diff --git a/exercises/06_unit_testing/mongo/jasmine.json b/exercises/06_unit_testing/mongo/jasmine.json new file mode 100644 index 00000000..bf13d563 --- /dev/null +++ b/exercises/06_unit_testing/mongo/jasmine.json @@ -0,0 +1,8 @@ +{ + "spec_files": [ + "lists-spec.js" + ], + "helpers": [ + "helpers/**/*.js" + ] +} diff --git a/exercises/06_unit_testing/mongo/lists-spec.js b/exercises/06_unit_testing/mongo/lists-spec.js new file mode 100755 index 00000000..e8a4c65d --- /dev/null +++ b/exercises/06_unit_testing/mongo/lists-spec.js @@ -0,0 +1,73 @@ +#!/usr/bin/env node + +// node jasmine +// istanbul cover -x **spec/** -x **index.js** -x **debug.js** jasmine.js + +'use strict' + +// ./node_modules/.bin/jasmine-node --verbose lists-spec.js +// ./node_modules/.bin/istanbul cover ./node_modules/.bin/jasmine lists-spec.js + +// ./node_modules/.bin/istanbul cover -x **index.js** -x **lists-spec.js** -x **schema.js** ./node_modules/.bin/jasmine lists-spec.js + +const schema = require('./schema') +const lists = require('./lists') + +describe('shopping lists', () => { + beforeEach( done => { + schema.List.remove({}, err => { + if (err) expect(true).toBe(false) //error should not be thrown + new schema.List({name: 'colours', list: ['red', 'orange', 'yellow']}).save( (err, list) => { + if (err) expect(true).toBe(false) //error should not be thrown + schema.List.count({}, (err, count) => { + if (err) expect(true).toBe(false) //error should not be thrown + expect(count).toBe(1) + done() + }) + }) + }) + }) + + describe('add', () => { + it('should add a valid list', done => { + const shopping = { + name: 'shopping', + list: ['bread', 'butter', 'cheese'] + } + + lists.add(shopping, (err, data) => { + if (err) expect(true).toBe(false) + expect(data.name).toBe('shopping') + schema.List.count({}, (err, count) => { + if (err) expect(true).toBe(false) + expect(count).toBe(2) + done() + }) + }) + }) + }) + + describe('count', () => { + it('should find one list', done => { + lists.count( (err, count) => { + if (err) expect(true).toBe(false) + expect(count).toBe(1) + done() + }) + }) + }) + describe('remove', () => { + it('should remove an existing list', done => { + lists.remove('colours').then( () => { + schema.List.count({}, (err, count) => { + if (err) expect(true).toBe(false) + expect(count).toBe(0) + done() + }) + }).catch( err => { + if (err) expect(true).toBe(false) //error should not be thrown + }) + }) + }) + +}) diff --git a/exercises/06_unit_testing/mongo/lists.js b/exercises/06_unit_testing/mongo/lists.js new file mode 100755 index 00000000..85a4840d --- /dev/null +++ b/exercises/06_unit_testing/mongo/lists.js @@ -0,0 +1,49 @@ +#!/usr/bin/env node + +'use strict' + +// this module demonstrates the use of callbacks and +// connecting to a MongoDB database, +// there is also an example of a synchronous function. + +const schema = require('./schema') + +exports.add = (data, callback) => { + if (this.validateData(data) === false) return callback(new Error('invalid data')) + const list = new schema.List(data) + + list.save( (err, list) => { + if (err) { + console.log(err) + callback(err) + } + callback(null, data) + }) +} + +// this exported function returns how many documents +// there are in the MongoDB database +exports.count = callback => { + schema.List.count({}, (err, count) => { + if (err) callback(err) + callback(null, count) + }) +} + + +// this is a standard anonymous function, +// returns true or throws error. +exports.validateData = data => { + if (!data.name || !data.list) return false + if (typeof data.name !== 'string' || !Array.isArray(data.list)) return false + if (data.list.length === 0) return false + return true +} + +// this is an anonymous function that returns a promise. +exports.remove = listName => new Promise( (resolve, reject) => { + schema.List.find({name: listName}).remove( err => { + if (err) return reject(err) + resolve() + }) +}) diff --git a/exercises/06_unit_testing/mongo/schema.js b/exercises/06_unit_testing/mongo/schema.js new file mode 100755 index 00000000..097bf9a4 --- /dev/null +++ b/exercises/06_unit_testing/mongo/schema.js @@ -0,0 +1,23 @@ +#!/usr/bin/env node + +'use strict' + +// import the mongoose package +const mongoose = require('mongoose') +const db = { + user: 'testuser', + pass: 'password' +} + +mongoose.connect(`mongodb://${db.user}:${db.pass}@ds143777.mlab.com:43777/marktyers`) +mongoose.Promise = global.Promise +const Schema = mongoose.Schema + +// create a schema +const listSchema = new Schema({ + name: String, + list: [{type: String}] +}) + +// create a model using the schema +exports.List = mongoose.model('List', listSchema) diff --git a/exercises/06_unit_testing/shopping/debug.js b/exercises/06_unit_testing/shopping/debug.js new file mode 100755 index 00000000..05287374 --- /dev/null +++ b/exercises/06_unit_testing/shopping/debug.js @@ -0,0 +1,27 @@ +#!/usr/bin/env node +/* eslint no-magic-numbers: 0 */ + +'use strict' + +const list = require('./modules/shopping') +debugger +list.add('bread') +list.add('butter') +debugger +list.add('cheese') +list.add('bread') +list.add('bread') +list.add('butter') +debugger +try { + list.decrement('bread', 2) + list.decrement('butter', 4) + list.decrement('jam', 3) +} catch(err) { + console.log('ERROR: '+err.message) +} + +const items = list.getAll() +items.forEach( (element, index) => { + console.log(index+'. '+element.title+'\t x'+element.qty) +}) diff --git a/exercises/06_unit_testing/shopping/index.js b/exercises/06_unit_testing/shopping/index.js new file mode 100755 index 00000000..8efcbcdd --- /dev/null +++ b/exercises/06_unit_testing/shopping/index.js @@ -0,0 +1,29 @@ +#!/usr/bin/env node + +'use strict' + +const readline = require('readline-sync') + +const list = require('./modules/shopping'); + +let input +do { + input = String(readline.question('enter command: ')).trim() + if (input.indexOf('add ') === 0) { + const space = input.indexOf(' ') + const item = input.substring(space).trim() + /* Let's echo the item details back to the user before adding it. Notice the use of Template Literals (https://goo.gl/Yjxa5o), a part of ECMA6. This allows you to include placeholders for variables without the need for concatenation. The string must be enclosed by backticks (`) */ + console.log(`adding "${item}"`) + list.add(item) + } + if (input.indexOf('list') === 0) { + const items = list.getAll() + let i = 1 + for (const value of items) { + /* process.stdout.write prints to the terminal without adding a newline character at the end. the \t character inserts a tab and the \n character inserts a newline */ + process.stdout.write(`${i}. ${value.title}`) + process.stdout.write(`\t${value.qty}\n`) + i++ + } + } +} while (input !== 'exit') diff --git a/exercises/06_unit_testing/shopping/modules/shopping.js b/exercises/06_unit_testing/shopping/modules/shopping.js new file mode 100755 index 00000000..ae7ab4c5 --- /dev/null +++ b/exercises/06_unit_testing/shopping/modules/shopping.js @@ -0,0 +1,89 @@ +#!/usr/bin/env node + +'use strict' + +/** + * Shopping Module. + * @module shopping + */ + +/** this global 'map' object will be used to store the items and quantities. It stores each item against a named key. */ +const data = new Map() + +/** add a new item to the todo list. Notice that we are using the new 'Arrow Function' syntax from the ECMA6 specification. */ +exports.add = item => { + /* check if the item is already in the list */ + if (data.get(item) === undefined) { + /* if the item is not found it is added to the list with its quantity set to 1 */ + data.set(item, {title: item, qty: 1}) + } else { + /* the item is already in the list so it is retrieved and the quantity is incremented */ + const i = data.get(item) + i.qty++ + /* the new data is then stored in the map */ + data.set(item, i) + } + return data.get(item) +} + + +/** calculates and returns the number of items in the list */ +exports.count = () => data.size + +/** empties the list */ +exports.clear = () => { + data = new Map() +} + +/** Returns an array containing all todo items */ +exports.getAll = () => { + const dataArray = [] + for (const value of data.values()) { + dataArray.push(value) + } + return dataArray +} + +/** + * Returns details for the named item. + * @param {string} item - The item name to retrieve. + * @returns {string} The name of the item + * @throws {InvalidItem} item not in list. + */ +exports.getItem = item => { + if (data.get(item) === undefined) { + throw new Error('item not in list') + } + return data.get(item) +} + +/** Removes item with the corresponding name. */ +exports.removeItem = item => { + console.log('removeItem') + if (data.get(item) === undefined) { + throw new Error('item not in list') + } + data.delete(item) +} + +/** Decrements the quantity of an item in the list. */ +exports.decrement = (item, count) => { + console.log(arguments) + if (item === undefined || count === undefined) { + throw new Error('function requires two parameters') + } + if (data.get(item) === undefined) { + throw new Error('item not in list') + } + if (isNaN(count)) { + throw new Error('second parameter should be a number') + } + count = parseInt(count) + const current = data.get(item) + if (current.qty <= count) { + data.delete(item) + return + } + current.qty = current.qty - count + data.set(item, current) +} diff --git a/exercises/06_unit_testing/shopping/package.json b/exercises/06_unit_testing/shopping/package.json new file mode 100644 index 00000000..5d35a295 --- /dev/null +++ b/exercises/06_unit_testing/shopping/package.json @@ -0,0 +1,31 @@ +{ + "name": "shopping", + "version": "1.0.0", + "description": "simple shopping list", + "main": "index.js", + "scripts": { + "app": "node index.js", + "lint": "node_modules/.bin/eslint modules/", + "test": "node_modules/.bin/jasmine-node spec --color --verbose --autotest --watch .", + "coverage": "./node_modules/.bin/istanbul cover -x **spec/** -x **index.js** -x **debug.js** ./node_modules/.bin/jasmine-node spec", + "doc": "node_modules/.bin/jsdoc modules/" + }, + "keywords": [ + "list", + "jasmine", + "module" + ], + "author": "Mark J Tyers", + "license": "ISC", + "dependencies": { + "readline-sync": "^1.2.22" + }, + "devDependencies": { + "eslint": "^3.1.0", + "istanbul": "^0.4.0", + "jasmine": "^2.6.0", + "jasmine-console-reporter": "^1.2.7", + "jsdoc": "^3.4.0", + "node-inspector": "^0.12.8" + } +} diff --git a/exercises/06_unit_testing/shopping/spec/jasmine.json b/exercises/06_unit_testing/shopping/spec/jasmine.json new file mode 100644 index 00000000..f3fa9dc0 --- /dev/null +++ b/exercises/06_unit_testing/shopping/spec/jasmine.json @@ -0,0 +1,5 @@ +{ + "spec_files": [ + "spec/shopping-spec.js" + ] +} diff --git a/exercises/06_unit_testing/shopping/spec/runTests.js b/exercises/06_unit_testing/shopping/spec/runTests.js new file mode 100755 index 00000000..14be68e0 --- /dev/null +++ b/exercises/06_unit_testing/shopping/spec/runTests.js @@ -0,0 +1,21 @@ +#!/usr/bin/env node + +'use strict' + +const Jasmine = require('jasmine') +const jasmine = new Jasmine() + +jasmine.loadConfigFile('spec/jasmine.json') + +const JasmineConsoleReporter = require('jasmine-console-reporter') +const reporter = new JasmineConsoleReporter({ + colors: true, + cleanStack: true, + verbosity: 4, + listStyle: 'indent', + activity: true +}) + +jasmine.addReporter(reporter) + +jasmine.execute() diff --git a/exercises/06_unit_testing/shopping/spec/shopping-spec.js b/exercises/06_unit_testing/shopping/spec/shopping-spec.js new file mode 100755 index 00000000..68b7570c --- /dev/null +++ b/exercises/06_unit_testing/shopping/spec/shopping-spec.js @@ -0,0 +1,79 @@ +#!/usr/bin/env node + +'use strict' +/*istanbul ignore next*/ +/* eslint no-magic-numbers: 0 */ +/* global expect */ + +const list = require('../modules/shopping') + +describe('Shopping List', () => { + /* this code is run before each of the tests */ + beforeEach(() => { + list.add('bread') + list.add('bread') + list.add('butter') + }) + + /* this code is fun after EACH test */ + afterEach(() => { + list.clear() + }) + + it('should add a new item', () => { + list.add('cheese') + expect(list.count()).toBe(3) + }) + + it('should increment the qty if item exists', () => { + list.add('bread') + expect(list.count()).toBe(2) + }) + + it('should return an array containing all items in order of entry', () => { + const items = list.getAll() + expect(items[0].title).toBe('bread') + expect(items[0].qty).toBe(2) + expect(items[1].title).toBe('butter') + expect(items[1].qty).toBe(1) + }) + + /* by placing an 'x' in front of the function name we set its status to 'pending' which means the test won't run. In this way we can write lots of tests but focus on passing one test at a time. */ + it('should be able to return a shopping item', () => { + expect(list.getItem('bread').title).toBe('bread') + expect(list.getItem('bread').qty).toBe(2) + }) + + xit('should throw an error if item not in list', () => { + try { + list.getItem('jam') + expect(true).toBe(false) // we want to fail test if this line is run. + } catch(err) { + expect(err.message).toBe('item not in list') + } + }) + + xit('should delete first item', () => { + list.removeItem('bread') + expect(list.count()).toBe(1) + const items = list.getAll() + expect(items[0].title).toBe('butter') + }) + + xit('should delete last item', () => { + list.removeItem('butter') + expect(list.count()).toBe(1) + const items = list.getAll() + expect(items[0].title).toBe('bread') + }) + + xit('should throw error if item not in list', () => { + try { + list.removeItem('jam') + expect(true).toBe(false) + } catch(err) { + expect(err.message).toBe('item not in list') + } + }) + +}) diff --git a/exercises/06_unit_testing/taxi/index.js b/exercises/06_unit_testing/taxi/index.js new file mode 100755 index 00000000..28757ec1 --- /dev/null +++ b/exercises/06_unit_testing/taxi/index.js @@ -0,0 +1,40 @@ +#!/usr/bin/env node + +'use strict' + +const taxi = require('./modules/taxi') + + +console.time('set ecb as home') +taxi.setHome('Gulson Road, Coventry', data => { + console.log('Setting ECB as home') + console.log(data) + console.log(data.lat) + console.timeEnd('set ecb as home') +}) + +console.time('standard fare') +taxi.getFare('University Road, Coventry', data => { + console.log(data) + console.timeEnd('standard fare') +}) + +console.time('set cathedral as home') +taxi.setHome('Coventry Cathedral', data => { + console.log('Coventry Cathedral') + console.log(data) + console.log(data.lat) + console.timeEnd('set cathedral as home') +}) + +console.time('long fare'); +taxi.getFare('Warwick Castle', data => { + console.log(data) + console.timeEnd('long fare') +}) + +console.time('short fare') +taxi.getFare('Broadgate, Coventry', data => { + console.log(data) + console.timeEnd('short fare') +}) diff --git a/exercises/06_unit_testing/taxi/modules/taxi.js b/exercises/06_unit_testing/taxi/modules/taxi.js new file mode 100644 index 00000000..a85ea8f3 --- /dev/null +++ b/exercises/06_unit_testing/taxi/modules/taxi.js @@ -0,0 +1,49 @@ +#!/usr/bin/env node +/* eslint no-magic-numbers: 0 */ + +'use strict' + +const sync = require('sync-request') + +let home + +exports.setHome = (location, callback) => { + //const loc = getLatLon(location) + home = getLatLon(location) + const latLng = home.split(',') + callback({lat: parseFloat(latLng[0]), lng: parseFloat(latLng[1])}) +} + +exports.getFare = function(destination, callback) { + const dest = getLatLon(destination) + const data = getRouteData(home, dest) // this is making the live API call + const distance = data.routes[0].legs[0].distance.value + const duration = data.routes[0].legs[0].duration.value + const cost = calculateFare(distance, duration) + callback({distance: distance, duration: duration, cost: parseFloat(cost)}) +} + +/* this function returns API data but the data won't vary between calls. We could get away without mocking this call. */ +function getLatLon(address) { + const url = `https://maps.googleapis.com/maps/api/geocode/json?region=uk&address=${address}` + console.log(url) + const res = sync('GET', url) + const data = JSON.parse(res.getBody().toString('utf8')) + const loc = data.results[0].geometry.location + return `${loc.lat.toFixed(6)}, ${loc.lng.toFixed(6)}` +} + +/* this function also returns live API data but this data will vary continously based on time of day and traffic conditions. This means it will require mocking in tests. by storing the function in a private variable we can substitute for a different function when testing. Because we are replacing the code block it will never be called by our test suite so we flag the code coverage tool to ignore it. */ +/* istanbul ignore next */ +const getRouteData = function(start, end) { + const url = `https://maps.googleapis.com/maps/api/directions/json?origin=${start}&destination=${end}` + console.log(url) + const res = sync('GET', url) + return JSON.parse(res.getBody().toString('utf8')) +} + +function calculateFare(distance, duration) { + console.log(`distance: ${distance}km, duration: ${duration} min`) + const cost = distance/127*0.2 + return cost.toFixed(2) +} diff --git a/exercises/06_unit_testing/taxi/package.json b/exercises/06_unit_testing/taxi/package.json new file mode 100644 index 00000000..cde8641a --- /dev/null +++ b/exercises/06_unit_testing/taxi/package.json @@ -0,0 +1,25 @@ +{ + "name": "taxi", + "version": "1.0.0", + "description": "taxi fare calculator", + "main": "index.js", + "scripts": { + "test": "jasmine-node spec --color --verbose --noStack --autotest --watch .", + "coverage": "istanbul cover -x **/spec/** -x **index.js** --include-all-sources jasmine-node spec" + }, + "keywords": [ + "taxi", + "fare", + "testing" + ], + "author": "Mark J Tyers", + "license": "ISC", + "dependencies": { + "sync-request": "^2.1.0" + }, + "devDependencies": { + "jasmine-node": "^1.14.5", + "istanbul": "^0.4.0", + "rewire": "^2.3.4" + } +} diff --git a/exercises/06_unit_testing/taxi/spec/medium-spec.js b/exercises/06_unit_testing/taxi/spec/medium-spec.js new file mode 100755 index 00000000..7f310ffd --- /dev/null +++ b/exercises/06_unit_testing/taxi/spec/medium-spec.js @@ -0,0 +1,36 @@ +#!/usr/bin/env node +/* eslint no-magic-numbers: 0 */ + +'use strict' + +//const fs = require('fs') +const rewire = require('rewire') +const taxi = rewire('../modules/taxi') + +describe('Medium Length Route', () => { + + /* the routedata comes from an external API that is not guaranteed to return consistent data. We substitute a different function for testing that returns a fixed object. */ + /*taxi.__set__('getRouteData', function(start, end) { + console.log('MOCK 1') + const data = fs.readFileSync('spec/routedata/cov_war_uni.json', "utf8") + return JSON.parse(data) + })*/ + + it('should set Gulson Road, Coventry as the current location', done => { + taxi.setHome('Gulson Road, Coventry', data => { + expect(data.lat).toEqual(52.405899) + expect(data.lng).toEqual(-1.495929) + done() + }) + }) + + it('should calculate the fare to University Road, Coventry', done => { + taxi.getFare('University Road, Coventry', data => { + expect(data.distance).toEqual(7123) + expect(data.duration).toEqual(767) + expect(data.cost).toEqual(11.22) + done() + }) + }) + +}) diff --git a/exercises/06_unit_testing/taxi/spec/routedata/cov_uni_cat.json b/exercises/06_unit_testing/taxi/spec/routedata/cov_uni_cat.json new file mode 100644 index 00000000..bd0fae3e --- /dev/null +++ b/exercises/06_unit_testing/taxi/spec/routedata/cov_uni_cat.json @@ -0,0 +1,327 @@ +{ + "geocoded_waypoints": [ + { + "geocoder_status": "OK", + "place_id": "EjI4IENoYXJ0ZXJob3VzZSBSZCwgQ292ZW50cnksIFdlc3QgTWlkbGFuZHMgQ1YxLCBVSw", + "types": [ + "street_address" + ] + }, + { + "geocoder_status": "OK", + "place_id": "ChIJJwRfs7tLd0gR7AmZx2aFwmk", + "types": [ + "street_address" + ] + } + ], + "routes": [ + { + "bounds": { + "northeast": { + "lat": 52.4077306, + "lng": -1.4959289 + }, + "southwest": { + "lat": 52.40312549999999, + "lng": -1.5090762 + } + }, + "copyrights": "Map data ©2015 Google", + "legs": [ + { + "distance": { + "text": "1.7 km", + "value": 1700 + }, + "duration": { + "text": "5 mins", + "value": 291 + }, + "end_address": "24 Bayley Ln, Coventry, West Midlands CV1 5RJ, UK", + "end_location": { + "lat": 52.4077306, + "lng": -1.5071756 + }, + "start_address": "8 Charterhouse Rd, Coventry, West Midlands CV1, UK", + "start_location": { + "lat": 52.4058994, + "lng": -1.4959289 + }, + "steps": [ + { + "distance": { + "text": "0.4 km", + "value": 422 + }, + "duration": { + "text": "1 min", + "value": 80 + }, + "end_location": { + "lat": 52.4041836, + "lng": -1.5013684 + }, + "html_instructions": "Head west on Gulson Rd towards Vecqueray St", + "polyline": { + "points": "{oz~HpdcHRhBRfBPz@R~@VrALn@p@hDFRt@`Db@hBNl@@@BLDZJf@@@@BBB^V" + }, + "start_location": { + "lat": 52.4058994, + "lng": -1.4959289 + }, + "travel_mode": "DRIVING" + }, + { + "distance": { + "text": "0.1 km", + "value": 131 + }, + "duration": { + "text": "1 min", + "value": 22 + }, + "end_location": { + "lat": 52.4042584, + "lng": -1.502777 + }, + "html_instructions": "At the roundabout, take the 1st exit onto Short St", + "maneuver": "roundabout-left", + "polyline": { + "points": "cez~HpfdHLJPNHJFJDLDR@XAN?JAHCJCHCFMXIPSTGHEJEL" + }, + "start_location": { + "lat": 52.4041836, + "lng": -1.5013684 + }, + "travel_mode": "DRIVING" + }, + { + "distance": { + "text": "0.2 km", + "value": 158 + }, + "duration": { + "text": "1 min", + "value": 13 + }, + "end_location": { + "lat": 52.4044801, + "lng": -1.5050599 + }, + "html_instructions": "Take the slip road on the right to A45/City Centre/Ring Road/Birmingham", + "polyline": { + "points": "sez~HjodHCPCJAJAJ?JANAv@CfAC`BA~@AVCNKj@" + }, + "start_location": { + "lat": 52.4042584, + "lng": -1.502777 + }, + "travel_mode": "DRIVING" + }, + { + "distance": { + "text": "44 m", + "value": 44 + }, + "duration": { + "text": "1 min", + "value": 3 + }, + "end_location": { + "lat": 52.40447080000001, + "lng": -1.5057057 + }, + "html_instructions": "Merge onto Ringway St Johns/A4053", + "maneuver": "merge", + "polyline": { + "points": "_gz~Hr}dH?NAX@T?`@@^" + }, + "start_location": { + "lat": 52.4044801, + "lng": -1.5050599 + }, + "travel_mode": "DRIVING" + }, + { + "distance": { + "text": "0.2 km", + "value": 171 + }, + "duration": { + "text": "1 min", + "value": 16 + }, + "end_location": { + "lat": 52.4036568, + "lng": -1.5077488 + }, + "html_instructions": "At junction 5, take the New Union St exit to Barracks/Coventry University/Council House/Police Station/Law Courts/Tech Park/Service Areas", + "maneuver": "ramp-left", + "polyline": { + "points": "}fz~HtaeHJXDNDNDRJx@Hj@Hh@Jf@Lb@Rn@HVHJNNPH" + }, + "start_location": { + "lat": 52.40447080000001, + "lng": -1.5057057 + }, + "travel_mode": "DRIVING" + }, + { + "distance": { + "text": "0.2 km", + "value": 246 + }, + "duration": { + "text": "1 min", + "value": 37 + }, + "end_location": { + "lat": 52.4044562, + "lng": -1.5089504 + }, + "html_instructions": "At the roundabout, take the 4th exit onto New Union St", + "maneuver": "roundabout-left", + "polyline": { + "points": "{az~HlneHh@FTBFBHDDDDFBLBLBN?F@F?F?FAD?BCLCDADEFQLEDEDMFMFIDE@C@C?C?EAKAMAKCKAUCM?MBMDKFQLMN" + }, + "start_location": { + "lat": 52.4036568, + "lng": -1.5077488 + }, + "travel_mode": "DRIVING" + }, + { + "distance": { + "text": "0.2 km", + "value": 244 + }, + "duration": { + "text": "1 min", + "value": 44 + }, + "end_location": { + "lat": 52.4064844, + "lng": -1.5084757 + }, + "html_instructions": "Turn right onto Little Park St/B4544", + "maneuver": "turn-right", + "polyline": { + "points": "{fz~H|ueHOXMUYy@GGQMGA}@@_@?sBEe@AaAI" + }, + "start_location": { + "lat": 52.4044562, + "lng": -1.5089504 + }, + "travel_mode": "DRIVING" + }, + { + "distance": { + "text": "61 m", + "value": 61 + }, + "duration": { + "text": "1 min", + "value": 14 + }, + "end_location": { + "lat": 52.4069663, + "lng": -1.5081887 + }, + "html_instructions": "Slight left to stay on Little Park St/B4544", + "maneuver": "turn-slight-left", + "polyline": { + "points": "osz~H~reH_@@WAKKKMIOGO" + }, + "start_location": { + "lat": 52.4064844, + "lng": -1.5084757 + }, + "travel_mode": "DRIVING" + }, + { + "distance": { + "text": "58 m", + "value": 58 + }, + "duration": { + "text": "1 min", + "value": 11 + }, + "end_location": { + "lat": 52.406971, + "lng": -1.507342 + }, + "html_instructions": "Slight right onto Earl St/B4544", + "maneuver": "turn-slight-right", + "polyline": { + "points": "qvz~HdqeHCOAU?U@c@BiA" + }, + "start_location": { + "lat": 52.4069663, + "lng": -1.5081887 + }, + "travel_mode": "DRIVING" + }, + { + "distance": { + "text": "54 m", + "value": 54 + }, + "duration": { + "text": "1 min", + "value": 11 + }, + "end_location": { + "lat": 52.4069016, + "lng": -1.5065598 + }, + "html_instructions": "Keep left to stay on Earl St/B4544", + "maneuver": "keep-left", + "polyline": { + "points": "qvz~HzkeHDuAD}@@G" + }, + "start_location": { + "lat": 52.406971, + "lng": -1.507342 + }, + "travel_mode": "DRIVING" + }, + { + "distance": { + "text": "0.1 km", + "value": 111 + }, + "duration": { + "text": "1 min", + "value": 40 + }, + "end_location": { + "lat": 52.4077306, + "lng": -1.5071756 + }, + "html_instructions": "Turn left onto Bayley Ln
Destination will be on the right
", + "maneuver": "turn-left", + "polyline": { + "points": "cvz~H~feHI?I@gATQDKDKFIFIHGLAHAH?Z" + }, + "start_location": { + "lat": 52.4069016, + "lng": -1.5065598 + }, + "travel_mode": "DRIVING" + } + ], + "via_waypoint": [ ] + } + ], + "overview_polyline": { + "points": "{oz~HpdcHf@pEd@zB~A`IjBzHVrAr@j@ZZLXFl@AZETGPWj@[^KXG\\Er@K`HEf@Kz@@pBPh@Jb@TdBTpA`@rARb@`@X~@JPHJLJr@?\\I\\c@`@k@VQ?q@Ic@C[H]T]h@g@oAYUeA?sCEgBKw@?WYQ_@Ee@@y@PeFS@yAZWLSPIVAd@" + }, + "summary": "Gulson Rd", + "warnings": [ ], + "waypoint_order": [ ] + } + ], + "status": "OK" +} \ No newline at end of file diff --git a/exercises/06_unit_testing/taxi/spec/routedata/cov_war_uni.json b/exercises/06_unit_testing/taxi/spec/routedata/cov_war_uni.json new file mode 100644 index 00000000..34b5cd42 --- /dev/null +++ b/exercises/06_unit_testing/taxi/spec/routedata/cov_war_uni.json @@ -0,0 +1,448 @@ +{ + "geocoded_waypoints": [ + { + "geocoder_status": "OK", + "place_id": "ChIJs9bxBshLd0gRwDkP8im__p4", + "types": [ + "street_address" + ] + }, + { + "geocoder_status": "OK", + "place_id": "ChIJ-3ysL8VKd0gRjGGe73NrzSE", + "types": [ + "route" + ] + } + ], + "routes": [ + { + "bounds": { + "northeast": { + "lat": 52.40494640000001, + "lng": -1.4994726 + }, + "southwest": { + "lat": 52.3818345, + "lng": -1.5676994 + } + }, + "copyrights": "Map data ©2015 Google", + "legs": [ + { + "distance": { + "text": "7.1 km", + "value": 7123 + }, + "duration": { + "text": "13 mins", + "value": 767 + }, + "end_address": "University Rd, Coventry, West Midlands CV4 7HP, UK", + "end_location": { + "lat": 52.382963, + "lng": -1.561438 + }, + "start_address": "26 Gulson Rd, Coventry, West Midlands CV1 2JH, UK", + "start_location": { + "lat": 52.405899, + "lng": -1.890401 + }, + "steps": [ + { + "distance": { + "text": "7.1 km", + "value": 7123 + }, + "duration": { + "text": "13 min", + "value": 767 + }, + "end_location": { + "lat": 52.4041836, + "lng": -1.5013684 + }, + "html_instructions": "Head south-west on Gulson Rd towards London Rd/A4114", + "polyline": { + "points": "}iz~HtzcHd@lBb@hBNl@@@BLDZJf@@@@BBB^V" + }, + "start_location": { + "lat": 52.40494640000001, + "lng": -1.4994726 + }, + "travel_mode": "DRIVING" + }, + { + "distance": { + "text": "0.1 km", + "value": 131 + }, + "duration": { + "text": "1 min", + "value": 22 + }, + "end_location": { + "lat": 52.4042584, + "lng": -1.502777 + }, + "html_instructions": "At the roundabout, take the 1st exit onto Short St", + "maneuver": "roundabout-left", + "polyline": { + "points": "cez~HpfdHLJPNHJFJDLDR@XAN?JAHCJCHCFMXIPSTGHEJEL" + }, + "start_location": { + "lat": 52.4041836, + "lng": -1.5013684 + }, + "travel_mode": "DRIVING" + }, + { + "distance": { + "text": "0.2 km", + "value": 158 + }, + "duration": { + "text": "1 min", + "value": 13 + }, + "end_location": { + "lat": 52.4044801, + "lng": -1.5050599 + }, + "html_instructions": "Take the slip road on the right to A45/City Centre/Ring Road/Birmingham", + "polyline": { + "points": "sez~HjodHCPCJAJAJ?JANAv@CfAC`BA~@AVCNKj@" + }, + "start_location": { + "lat": 52.4042584, + "lng": -1.502777 + }, + "travel_mode": "DRIVING" + }, + { + "distance": { + "text": "0.4 km", + "value": 446 + }, + "duration": { + "text": "1 min", + "value": 27 + }, + "end_location": { + "lat": 52.4028714, + "lng": -1.510877 + }, + "html_instructions": "Merge onto Ringway St Johns/A4053
Continue to follow A4053
", + "maneuver": "merge", + "polyline": { + "points": "_gz~Hr}dH?NAX@T?`@@^JzAH|@DVDN@LNp@Nl@^fAN`@h@xAFLRh@Rn@HPRl@b@hA@FHXLj@BPBTBN@N@L@X@P@R?~@" + }, + "start_location": { + "lat": 52.4044801, + "lng": -1.5050599 + }, + "travel_mode": "DRIVING" + }, + { + "distance": { + "text": "0.2 km", + "value": 216 + }, + "duration": { + "text": "1 min", + "value": 21 + }, + "end_location": { + "lat": 52.4028959, + "lng": -1.5140252 + }, + "html_instructions": "At junction 6, take the A429 exit to Stivichall/Kenilworth", + "maneuver": "ramp-left", + "polyline": { + "points": "}|y~H~afHJz@DVBd@Cf@C~@AjBCrBIrCC^" + }, + "start_location": { + "lat": 52.4028714, + "lng": -1.510877 + }, + "travel_mode": "DRIVING" + }, + { + "distance": { + "text": "44 m", + "value": 44 + }, + "duration": { + "text": "1 min", + "value": 9 + }, + "end_location": { + "lat": 52.4029947, + "lng": -1.5146582 + }, + "html_instructions": "Keep right to continue towards Warwick Rd/A429", + "maneuver": "keep-right", + "polyline": { + "points": "c}y~HtufHCl@CVIv@" + }, + "start_location": { + "lat": 52.4028959, + "lng": -1.5140252 + }, + "travel_mode": "DRIVING" + }, + { + "distance": { + "text": "0.2 km", + "value": 158 + }, + "duration": { + "text": "1 min", + "value": 24 + }, + "end_location": { + "lat": 52.4016992, + "lng": -1.515556 + }, + "html_instructions": "Turn left onto Warwick Rd/A429", + "maneuver": "turn-left", + "polyline": { + "points": "u}y~HryfHBDNPb@`@LHNJf@RXJPF@?F@VHLDJHFHFJDDDB" + }, + "start_location": { + "lat": 52.4029947, + "lng": -1.5146582 + }, + "travel_mode": "DRIVING" + }, + { + "distance": { + "text": "2.3 km", + "value": 2343 + }, + "duration": { + "text": "4 mins", + "value": 255 + }, + "end_location": { + "lat": 52.3864252, + "lng": -1.5349187 + }, + "html_instructions": "At the roundabout, take the 1st exit and stay on Warwick Rd/A429
Continue to follow A429
", + "maneuver": "roundabout-left", + "polyline": { + "points": "suy~Hf_gHD?L?JCL?LBTHB?DBF@\\Nx@d@NHbAZd@LdARNBnAVr@JxATV@F?xD\\`DVH@`@JPFFDLJJHFFFFFJDJFJFLFLFRFTFVFb@Db@NjA`AnHf@tD`ArHfBnMDZDXh@nDT`BTfBP`ATbANh@Rb@T^LRHL^`@`@`@`@`@dExDrEfErBhBHJ|CbDZXrDdDdEtDfAhArApA" + }, + "start_location": { + "lat": 52.4016992, + "lng": -1.515556 + }, + "travel_mode": "DRIVING" + }, + { + "distance": { + "text": "0.9 km", + "value": 938 + }, + "duration": { + "text": "1 min", + "value": 79 + }, + "end_location": { + "lat": 52.3903106, + "lng": -1.5469829 + }, + "html_instructions": "Turn right onto Fletchamstead Hwy/A45", + "maneuver": "turn-right", + "polyline": { + "points": "evv~HfxjHRPsAnF_@vA_BhGu@dDcB~GU~@g@vBoA~FkCnMc@pBKb@Qz@UbAU|@e@dB" + }, + "start_location": { + "lat": 52.3864252, + "lng": -1.5349187 + }, + "travel_mode": "DRIVING" + }, + { + "distance": { + "text": "31 m", + "value": 31 + }, + "duration": { + "text": "1 min", + "value": 4 + }, + "end_location": { + "lat": 52.3902657, + "lng": -1.5474076 + }, + "html_instructions": "Slight left onto Fletchamstead Hwy", + "maneuver": "turn-slight-left", + "polyline": { + "points": "mnw~HrcmHCZBTFb@" + }, + "start_location": { + "lat": 52.3903106, + "lng": -1.5469829 + }, + "travel_mode": "DRIVING" + }, + { + "distance": { + "text": "0.5 km", + "value": 498 + }, + "duration": { + "text": "1 min", + "value": 40 + }, + "end_location": { + "lat": 52.39035459999999, + "lng": -1.5546744 + }, + "html_instructions": "At the roundabout, continue straight onto Charter Ave", + "maneuver": "roundabout-left", + "polyline": { + "points": "enw~HhfmH@?@??@@??@@??@@??@?@@??@?@@@?@?@?@?@?@?@?@?@?@?@?@?@A@?@?@A??@?@A??@@dAIbJCtEC|EGnFCbC?zABhA" + }, + "start_location": { + "lat": 52.3902657, + "lng": -1.5474076 + }, + "travel_mode": "DRIVING" + }, + { + "distance": { + "text": "0.1 km", + "value": 104 + }, + "duration": { + "text": "1 min", + "value": 17 + }, + "end_location": { + "lat": 52.3896208, + "lng": -1.5555726 + }, + "html_instructions": "At the roundabout, take the 1st exit onto Sir Henry Parkes Rd", + "maneuver": "roundabout-left", + "polyline": { + "points": "unw~HtsnHFH@DDJ@P\\P\\b@`@n@PPNL" + }, + "start_location": { + "lat": 52.39035459999999, + "lng": -1.5546744 + }, + "travel_mode": "DRIVING" + }, + { + "distance": { + "text": "1.0 km", + "value": 1007 + }, + "duration": { + "text": "2 mins", + "value": 91 + }, + "end_location": { + "lat": 52.3849217, + "lng": -1.5676747 + }, + "html_instructions": "At the roundabout, take the 2nd exit onto Kirby Corner Rd", + "maneuver": "roundabout-left", + "polyline": { + "points": "cjw~HhynHB@FHBH@L?PALINIZE\\AV?VFr@Ln@Nj@Rt@JVHNxAbC^~@`@`BXjAV|Ad@bD^`BP|@T|@JZp@bCnAlDbApCVp@|@pBh@hAb@z@^r@t@xAVl@\\~@h@nB" + }, + "start_location": { + "lat": 52.3896208, + "lng": -1.5555726 + }, + "travel_mode": "DRIVING" + }, + { + "distance": { + "text": "0.3 km", + "value": 272 + }, + "duration": { + "text": "1 min", + "value": 31 + }, + "end_location": { + "lat": 52.38298440000001, + "lng": -1.5654529 + }, + "html_instructions": "At the roundabout, take the 1st exit onto Gibbet Hill Rd", + "maneuver": "roundabout-left", + "polyline": { + "points": "wlv~H|dqH@?@A@?@?@?@?@?@??@@?@@@??@@@?@Z]TS`@]x@k@PQJKFORg@f@qAVs@FMFKLMLMXW\\]" + }, + "start_location": { + "lat": 52.3849217, + "lng": -1.5676747 + }, + "travel_mode": "DRIVING" + }, + { + "distance": { + "text": "0.5 km", + "value": 525 + }, + "duration": { + "text": "1 min", + "value": 83 + }, + "end_location": { + "lat": 52.3818345, + "lng": -1.5597443 + }, + "html_instructions": "At the roundabout, take the 1st exit onto University Rd", + "maneuver": "roundabout-left", + "polyline": { + "points": "s`v~H`wpHACAA?A?AAC?A?A?C?A?C?A?A?C?A?A@C?A?A@A?C@A?A@A?A@A}AeDi@iAMWGOISEWCQCSAU?Q@SBS@Q@A@KBIDKDMBGn@eAZi@vCwER[PW`@w@lA{B" + }, + "start_location": { + "lat": 52.38298440000001, + "lng": -1.5654529 + }, + "travel_mode": "DRIVING" + }, + { + "distance": { + "text": "0.1 km", + "value": 132 + }, + "duration": { + "text": "1 min", + "value": 28 + }, + "end_location": { + "lat": 52.38261430000001, + "lng": -1.5582716 + }, + "html_instructions": "Turn left onto Academic Loop Rd
Destination will be on the left
", + "maneuver": "turn-left", + "polyline": { + "points": "myu~HjsoHQc@oAyCy@gB" + }, + "start_location": { + "lat": 52.3818345, + "lng": -1.5597443 + }, + "travel_mode": "DRIVING" + } + ], + "via_waypoint": [ ] + } + ], + "overview_polyline": { + "points": "}iz~HtzcHzAfGVrAr@j@ZZLXFl@AZETGPWj@[^KXG\\Er@K`HEf@Kz@@pBTxCJf@P~@n@tBtArDtAxD\\~AJbAD~BPrABd@Cf@EjDMfGGlAMnARVp@j@v@^j@Rn@PRRLPJBXCZBXHLDvAt@rAd@jB`@~AZlC`@^@zIt@j@LXLh@d@\\p@N`@Nl@~AbMvEt]zArKf@dCb@lAb@r@h@n@|MdM|BtBxD|DxJzIzCzCRPsAnF_C`JwE|RkGdZcBbHCZBTHb@@@BBDPAPCFK`SKlMC~EBhAFHFP@P\\P~@rAl@j@DVA^Sj@Gt@FjA\\zA^lAbBrC^~@`@`Bp@hDd@bD^`Bf@zB|@~CrC~HtAbDlAdCtAlCt@lBh@nB@?BAF?DBBBrAmAjA}@R[zA{DTYf@e@\\]ACACAI?KBSBI@CgCoFUg@Ok@Ge@Ag@H{@Po@fFoId@s@nBsDaB}Dy@gB" + }, + "summary": "A429", + "warnings": [ ], + "waypoint_order": [ ] + } + ], + "status": "OK" + } + \ No newline at end of file diff --git a/exercises/06_unit_testing/taxi/spec/short-spec.js b/exercises/06_unit_testing/taxi/spec/short-spec.js new file mode 100755 index 00000000..a9ea2189 --- /dev/null +++ b/exercises/06_unit_testing/taxi/spec/short-spec.js @@ -0,0 +1,36 @@ +#!/usr/bin/env node +/* eslint no-magic-numbers: 0 */ + +'use strict' + +const fs = require('fs') +const rewire = require('rewire') +const taxi = rewire('../modules/taxi') + +describe('Short Route', () => { + + /* the routedata comes from an external API that is not guaranteed to return consistent data. We substitute a different function for testing that returns a fixed object. */ + taxi.__set__('getRouteData', (start, end) => { + console.log('MOCK 2') + const data = fs.readFileSync('spec/routedata/cov_uni_cat.json', 'utf8') + return JSON.parse(data) + }) + + it('should set Gulson Road, Coventry as the current location', done => { + taxi.setHome('Gulson Road, Coventry', data => { + expect(data.lat).toEqual(52.405899) + expect(data.lng).toEqual(-1.495929) + done() + }) + }) + + it('should calculate the fare to Coventry Cathedral', done => { + taxi.getFare('Coventry Cathedral', data => { + expect(data.distance).toEqual(1700) + expect(data.duration).toEqual(291) + expect(data.cost).toEqual(2.4) + done() + }) + }) + +}) diff --git a/exercises/06_unit_testing/todo/index.js b/exercises/06_unit_testing/todo/index.js new file mode 100755 index 00000000..4d2f9d24 --- /dev/null +++ b/exercises/06_unit_testing/todo/index.js @@ -0,0 +1,90 @@ +#!/usr/bin/env node +/* eslint no-magic-numbers: 0 */ + +'use strict' + +/* import the 'restify' module and create an instance. */ +const restify = require('restify') +const server = restify.createServer() + +/* import the required plugins to parse the body and auth header. */ +server.use(restify.fullResponse()) +server.use(restify.bodyParser()) +server.use(restify.authorizationParser()) + +/* import our custom module. */ +const lists = require('./lists.js') + +/* if we receive a GET request for the base URL redirect to /lists */ +server.get('/', (req, res, next) => { + res.redirect('/lists', next) +}) + +/* this route provides a URL for the 'lists' collection. It demonstrates how a single resource/collection can have multiple representations. */ +server.get('/lists', (req, res) => { + console.log('getting a list of all the lists') + /* we will be including URLs to link to other routes so we need the name of the host. Notice also that we are using an 'immutable variable' (constant) to store the host string since the value won't change once assigned. The 'const' keyword is new to ECMA6 and is supported in NodeJS. */ + const host = req.headers.host + console.log(host) + /* creating some empty variables */ + let data //type + /* is the client requesting xml data? The req.header object stores any headers passed in the request. The 'Accept' header lets the client express a preference for the format of the representation. Note you should always provide a sensible default. */ + if (req.header('Accept') === 'application/xml') { + data = lists.getAllXML(host) + } else { + data = lists.getAll(host) + } + /* we need to set the content-type to match the data we are sending. We then send the response code and body. Finally we signal the end of the response. */ + res.setHeader('content-type', data.contentType) + res.send(data.code, data.response) + res.end() +}) + +/* This route provides a URL for each list resource. It includes a parameter (indicated by a :). The string entered here is stored in the req.params object and can be used by the script. */ +server.get('/lists/:listID', (req, res) => { + console.log('getting a list based on its ID') + /* Here we store the id we want to retrieve in an 'immutable variable'. */ + const listID = req.params.listID + /* Notice that all the business logic is kept in the 'lists' module. This stops the route file from becoming cluttered and allows us to implement 'unit testing' (we cover this in a later topic) */ + const data = lists.getByID(listID) + res.setHeader('content-type', 'application/json') + res.send(data.code, data.response) + res.end() +}) + +/* This route points to the 'lists' collection. The POST method indicates that we indend to add a new resource to the collection. Any resource added to a collection using POST should be assigned a unique id by the server. This id should be returned in the response body. */ +server.post('/lists', (req, res) => { + console.log('adding a new list') + /* The req object contains all the data associated with the request received from the client. The 'body' property contains the request body as a string. */ + const body = req.body + /* Since we are using the authorization parser plugin we gain an additional object which contains the information from the 'Authorization' header extracted into useful information. Here we are displaying it in the console so you can understand its structure. */ + const auth = req.authorization + console.log(auth) + const data = lists.addNew(auth, body) + res.setHeader('content-type', data.contentType) + res.send(data.code, data.response) + res.end() +}) + +/* The PUT method is used to 'update' a named resource. This is not only used to update a named resource that already exists but is also used to create a NEW RESOURCE at the named URL. It's important that you understand how this differs from a POST request. */ +server.put('/lists/:listID', (req, res) => { + res.setHeader('content-type', 'application/json') + //res.send(data.code, {status: data.status, message: 'this should update the specified resource'}) + res.end() +}) + +/* The DELETE method removes the resource at the specified URL. */ +server.del('/lists/:listID', (req, res) => { + res.setHeader('content-type', 'application/json') + //res.send(data.code, {status: data.status, message: 'this should delete the specified resource'}) + res.end() +}) + +const port = process.env.PORT || 8080 +server.listen(port, err => { + if (err) { + console.error(err) + } else { + console.log('App is ready at : ' + port) + } +}) diff --git a/exercises/06_unit_testing/todo/lists.js b/exercises/06_unit_testing/todo/lists.js new file mode 100755 index 00000000..ae00a951 --- /dev/null +++ b/exercises/06_unit_testing/todo/lists.js @@ -0,0 +1,120 @@ +#!/usr/bin/env node +/* eslint no-magic-numbers: 0 */ + +'use strict' + +/* We import the packages needed for this module. These should be listed in the 'package.json' file and can then be imported automatically using 'npm install' */ +const rand = require('csprng') +const builder = require('xmlbuilder') + +/* This array is used to store the lists. In a production system you would need to implement some form of data persistence such as a database. */ +const lists = [] + +/* this is a private function that can only be accessed from inside the module. It checks that the json data supplied in the request body comprises a single array of strings. The parameter contains the string passed in the request body. */ +function validateJson(json) { + /* */ + if (typeof json.name !== 'string') { + console.log('name not a string') + return false + } + /* returns false if the list key is not an Array */ + if (!Array.isArray(json.list)) { + console.log('json.list is not an array') + return false + } + /* */ + for(const i=0; i ({name: item.name, link: `http://${host}/lists/${item.id}`})) + return {code: 200, contentType: 'application/json', response: {status: 'success', message: lists.length+' lists found', data: notes}} +} + +/* This is a sister property to 'getAll' but returns an XML representation instead. */ +exports.getAllXML = function(host) { + console.log('getAllXML') + /* Here the xmlbuilder module is used to construct an XML document. */ + const xml = builder.create('root', {version: '1.0', encoding: 'UTF-8', standalone: true}) + /* If there are no lists in the array we build a single message node. */ + if (lists.length === 0) { + xml.ele('message', {status: 'error'}, 'no lists found') + } else { + /* If there are lists we build a different message node. */ + xml.ele('message', {status: 'success'}, 'lists found') + /* Then we create a 'lists' node which will be used to contain each list node. */ + const xmlLists = xml.ele('lists', {count: lists.length}) + /* We now loop through the lists, create a list node for each and add the details. */ + for(const i=0; i < lists.length; i++) { + const list = xmlLists.ele('list', {id: lists[i].id}) + list.ele('name', lists[i].name) + list.ele('link', {href: `http://${host}/lists/${lists[i].id}`}) + } + } + /* Once the XML document is complete we call 'end()' to turn it into an XML string. */ + xml.end({pretty: true}) + /* There is a logical error in this code, can you spot it? */ + return {code: 200, contentType: 'application/xml', response: xml} +} + +/* This public property contains a function to add a new list to the module. */ +exports.addNew = function(auth, body) { + console.log('addNew') + /* The first parameter should contain the authorization data. We check that it contains an object called 'basic' */ + if (auth.basic === undefined) { + console.log('missing basic auth') + return {code: 401, contentType: 'application/json', response: { status: 'error', message: 'missing basic auth' }} + } + console.log('A') + /* In this simple example we have hard-coded the username and password. You should be storing this somewhere are looking it up. */ + if (auth.basic.username !== 'testuser' || auth.basic.password !== 'p455w0rd') { + console.log('invalid credentials') + return {code: 401, contentType: 'application/json', response: { status: 'error', message: 'invalid credentials' }} + } + console.log('B') + console.log(body) + /* The second parameter contains the request body as a 'string'. We need to turn this into a JavaScript object then pass it to a function to check its structure. */ + const json = body + console.log('C') + console.log(json) + const valid = validateJson(json) + /* If the 'validateJson()' function returns 'false' we need to return an error. */ + if (valid === false) { + return {code: 400 ,contentType: 'application/json', response: { status: 'error', message: 'JSON data missing in request body' }} + } + /* In this example we use the csprng module to generate a long random string. In a production system this might be generated by the chosen data store. */ + const newId = rand(160, 36) + /* We now create an object and push it onto the array. */ + const newList = {id: newId, name: json.name, list: json.list} + lists.push(newList) + /* And return a success code. */ + return {code: 201, contentType: 'application/json', response: { status: 'success', message: 'new list added', data: newList }} +} diff --git a/exercises/06_unit_testing/todo/package.json b/exercises/06_unit_testing/todo/package.json new file mode 100644 index 00000000..90aac883 --- /dev/null +++ b/exercises/06_unit_testing/todo/package.json @@ -0,0 +1,25 @@ +{ + "name": "todo", + "version": "1.0.0", + "description": "Simple API to maintain to do lists.", + "main": "index.js", + "scripts": { + "start": "node index.js" + }, + "keywords": [ + "api", + "restify", + "tutorial" + ], + "author": "Mark J Tyers", + "license": "ISC", + "dependencies": { + "csprng": "^0.1.1", + "restify": "^4.0.3", + "xmlbuilder": "^3.1.0" + }, + "devDependencies": { + "frisby": "^0.8.5", + "jasmine-node": "^1.14.5" + } +} diff --git a/exercises/06_unit_testing/todo/test/todo-spec.js b/exercises/06_unit_testing/todo/test/todo-spec.js new file mode 100755 index 00000000..c8f1281d --- /dev/null +++ b/exercises/06_unit_testing/todo/test/todo-spec.js @@ -0,0 +1,60 @@ +#!/usr/bin/env node + +'use strict' + +const frisby = require('frisby') + +const status = { + 'ok': 200, + 'created': 201, + 'notModified': 304, + 'badRequest': 400, + 'unauthorised': 401, + 'notFound': 404 +} + +const firstIndex = 0 +const single = 1 + +/* // globalSetup defines any settigs used for ALL requests */ +frisby.globalSetup({ + request: { + headers: {'Authorization': 'Basic dGVzdHVzZXI6cDQ1NXcwcmQ=','Content-Type': 'application/json'} + } +}) + +/* here is a simple automated API call making a GET request. We check the response code, one of the response headers and the content of the response body. After completing the test we call 'toss()' which moves the script to the next test. */ +frisby.create('get empty list') + .get('http://localhost:8080/lists') + .expectStatus(status.notFound) + .expectHeaderContains('Content-Type', 'application/json') + .expectJSON({message: 'no lists found'}) + .toss() + +/* in this second POST example we don't know precisely what values will be returned but we can check for the correct data types. Notice that the request body is passed as the second parameter and we need to pass a third parameter to indicate we are passing the data in json format. */ +frisby.create('add a new list') + .post('http://localhost:8080/lists', {'name': 'shopping', 'list': ['Cheese', 'Bread', 'Butter']}, {json: true}) + .expectStatus(status.created) + .afterJSON( json => { + expect(json.message).toEqual('new list added') + expect(json.data.name).toEqual('shopping') + expect(json.data.list.length).toEqual(3) + }) + .toss() + +/* Since Frisby is built on the Jasmine library we can use any of the standard matchers by enclosing them in an anonymous function passed to the 'afterJSON()' method. */ +frisby.create('check number of lists') + .get('http://localhost:8080/lists') + .expectStatus(status.ok) + .afterJSON( json => { + // you can retrieve args using json.args.x + /* these are standard Jasmine matchers as covered in the first worksheet. */ + expect(json.message).toContain('1') + expect(json.data.length).toEqual(single) + /* We can even use the data returned to make additional API calls. Remember the JS scoping rules? */ + frisby.create('Second test, run after first is completed') + .get(json.data[firstIndex].link) + .expectStatus(status.ok) + .toss() + }) + .toss()