CMSI 386
Homework #1
Due: 2018-09-11

For this homework assignment, you'll be warming up to JavaScript using Node.js, and hopefully getting pretty good at it. Because this is your first assignment, I will supply all the unit tests.

Readings

Please read:

Instructions

All of your work for this class is to be kept in a private GitHub repository called lmu-cmsi-386 or something similar. Structure your repository as follows:

  .
  ├── README.md
  ├── .gitignore
  └── homework1/
      ├── package.json
      ├── .eslintrc.yml
      ├── src/
      │   └── warmup.js
      └── test/
          └── warmup-test.js

Your package.json must be set up so that when I clone your repository and go to the homework1 directory, running npm test will run all of the tests in the test folder.

I encourage you to work in pairs. Please submit only one solution set for your team. It does not matter under whose GitHub account your work is stored. What matters, if you wish to not receive a zero, is that I can clone your repo and run my tests. Since the repo is to be private, please add me as a contributor to your repo (so I can run and comment on your work). My github name is rtoal.

Make sure you are using a linter. Non-linted code will be severely marked down to the point where your grade is not a passing one. I prefer that you use eslint and the airbnb style guide, unless you have made other arrangements with me.

Your homework submission will be the state of your repository on the branch master, at 23:59 in the America/Los Angeles time zone on the due date above.

Setting up your Project

Here are instructions for creating your project. I assume you are already signed up with GitHub, already know how to create private repositories and clone them, and know the basics of git.

  1. Create your repo at GitHub, Check the private radio button and MAKE SURE TO CHECK “Initialize with a README.”
  2. Add me as a contributor.
  3. Clone your repo. Add some content to the README. Create the .gitignore file (with node_modules as the sole entry), and create the homework1 folder.
  4. Ensure you have the latest versions of Node.js and npm on your machine.
  5. In the homework1 folder, run the following commands. Most of the prompts have pretty obvious answers, but a few do matter. For npm init’s Test command, enter mocha. For your eslint initialization, select the airbnb style guide and use YAML, not JSON, for your config format.
    $ npm init
    $ npm install --save request
    $ npm install --save request-promise
    $ npm install --save-dev mocha
    $ npm install --save-dev should
    $ npm install eslint --save-dev
    $ ./node_modules/.bin/eslint --init
    
    You should now have package.json and .eslintrc.yml created.
  6. Edit your .eslintrc.yml file to let it know about mocha. It should look like this:
    extends: airbnb-base
    env:
      mocha: true
    
  7. Create your src and test folders. Create the file test/warmup-test.js using for its content the script at the bottom of this assignment page.
  8. Run npm test to make sure you can. It will be full of errors and failures.
  9. Do the problems below as a module in src/warmup.js. Write a little, test a little, back and forth.

I also recommend that you use an eslint plugin for your editor.

The Module You Are To Write

The file warmup.js should be a JavaScript module exporting the following functions:

  1. A function that accepts a number of U.S. cents and returns an array containing, respectively, the smallest number of U.S. quarters, dimes, nickels, and pennies that equal the given amount.
    > change(96)
    [ 3, 2, 0, 1 ]
    > change(8)
    [ 0, 0, 1, 3 ]
    > change(-4)
    RangeError: amount cannot be negative
    > change(33.25)
    [ 1, 0, 1, 3.25 ]
    
  2. A function that accepts a string and returns a new string equivalent to the argument but with all apostrophes and double quotes removed.
    > stripQuotes(`a\"\""""\'to\"\"'\"\"''z`)
    'atoz'
    
  3. A function that randomly permutes a string. What does random mean? It means that each time you call the function for a given argument, all possible permutations are equally likely. Random is not the same as arbitrary.
    > scramble('Hello, world')
    'lo,Hll erwdo'
    > scramble('Hello, world')
    'rewllH,ool d'
    
  4. A function that yields successive powers of a base starting at 1 and going up to some limit. Consume the values with a callback.
    > powers(2, 70, p => console.log(p))
    1
    2
    4
    8
    16
    32
    64
    
  5. A JavaScript generator function that yields successive powers of a base starting at 1 and going up to some limit.
    > const p = powersGenerator(2, 7);
    > p.next()
    { value: 1, done: false }
    > p.next()
    { value: 2, done: false }
    > p.next()
    { value: 4, done: false }
    > p.next()
    { value: undefined, done: true }
    
  6. A “chainable” function that accepts one string per call, but when called without arguments, returns the words previously passed, in order, separated by a single space.
    > say('Hello')('my')('name')('is')('Colette')();
    'Hello my name is Colette'
    
  7. A function that interleaves an array with a bunch of values. If the array length is not the same as the number of values to interleave, the “extra” elements should end up at the end of the result.
    > interleave(['a', 'b'], 1, 2, true, null)
    ['a', 1, 'b', 2, true, null]
    > interleave([7, 3, 'dog'], false)
    [7, false, 3, 'dog']
    
  8. A function that creates a cylinder object in the “Crockford Classless” style. Both the radius and height should default to 1 if not passed in. Include volume and surface area methods, as well as a widen method that mutates the radius by a given factor and a stretch method to grow the height. Expose the radius and height via getters.
    > c = cylinder({radius: 5, height: 12})
    > c.surfaceArea()
    534.0707511102648
    > c.volume()
    942.4777960769379
    > c.radius
    5
    > c.height
    12
    > c.widen(3)
    > c.toString()
    'Cylinder with radius 15 and height 12'
    > c.stretch(50)
    > c.toString()
    'Cylinder with radius 15 and height 600'
    > c.radius
    15
    > c.radius = 90
    > c.radius
    15
    
  9. A function that accepts two arguments: a crypto key and a crypto algorithm, and returns an array of two functions that use the key and algorithm. The first returned function is an encryption function that turns a string into a hex string, and the second is a decryption function that turns the hex string into a string. Use the built-in Node crypto module.
    > const [encrypt, decrypt] = makeCryptoFunctions('super dog', 'aes-256-cbc')
    > encrypt('Hello world')
    9714236cbedfd8d9799acea4ea79e6fe
    > decrypt('9714236cbedfd8d9799acea4ea79e6fe')
    Hello world
    > const [encrypt, decrypt] = makeCryptoFunctions('super dog', 'sdkjfhsdkll7dasf')
    > encrypt('Hello world')
    Error: Unknown cipher
    
  10. A function that returns a promise for a random name from the uinames API. Pass the URL query parameters in an object. The only parameters your function are to accept are gender and region. Do not just pass all paramters from your caller through to uinames. Do pass the value 1 to uinames for the API parameter amount. Simply for practice, your promise should with resolve successfully with a string of the form 'Surname, name', or reject if there are any problems. (Let the rejection happen naturally with whatever the module request-promise does. You’ll get an object with a message field that has a response code and message from the API provider.)
    > randomName({gender: 'female', region: 'turkey'}).then(s => console.log(s))
    .
    .
    .
    Ergüç, Semiha
    > randomName({region: 'canada', gender: 'female'}).then(s => console.log(s))
    .
    .
    .
    Gagné, Stella
    > randomName({region: 'russia', gender: 'male'}).then(s => console.log(s))
    .
    .
    .
    Казаков, Артём
    > randomName({region: 'canada', gender: 'covfefe'}).catch(e => console.log(e.message))
    .
    .
    .
    400 - {"error":"Invalid gender"}
    

Grading

Ten points per function.

The Unit Tests

Place the following file in your test folder:

const {
  change, stripQuotes, scramble, say, powers, interleave,
  powersGenerator, cylinder, makeCryptoFunctions, randomName,
} = require('../src/warmup');

require('should'); // eslint-disable-line import/no-extraneous-dependencies


// Helper for the scramble tests
function anagramsOfEachOther(s, t) {
  return s.split('').sort().join('') === t.split('').sort().join('');
}

// Helper for testing the callbacky problems
function generatorToArray(generator, ...args) {
  const result = [];
  generator(...args, x => result.push(x));
  return result;
}

describe('change', () => {
  it('handles zero', () => {
    change(0).should.eql([0, 0, 0, 0]);
  });

  it('computes answers for small integer values fine', () => {
    change(97).should.eql([3, 2, 0, 2]);
    change(8).should.eql([0, 0, 1, 3]);
    change(250).should.eql([10, 0, 0, 0]);
    change(144).should.eql([5, 1, 1, 4]);
    change(97).should.eql([3, 2, 0, 2]);
  });

  it('handles large values', () => {
    // This test only passes if the solution is efficient
    change(100000000000).should.eql([4000000000, 0, 0, 0]);
  });

  it('throws the proper exception for negative arguments', () => {
    (() => change(-50)).should.throw(RangeError);
  });
});

describe('stripQuotes', () => {
  it('works on the empty string', () => {
    stripQuotes('').should.eql('');
  });

  it('strips quotes properly for non-empty strings', () => {
    stripQuotes('Hello, world').should.eql('Hello, world');
    stripQuotes('"\'').should.eql('');
    stripQuotes('a"""\'\'"z').should.eql('az');
  });
});

describe('scramble', () => {
  it('scrambles properly', () => {
    ['a', 'rat', 'JavaScript testing', '', 'zzz', '^*^*)^▱ÄÈËɡɳɷ'].forEach(s =>
      anagramsOfEachOther(s, scramble(s)).should.be.true);
  });

  it('is really random (produces all permutations)', () => {
    const possibilities = new Set('ABC ACB BAC BCA CAB CBA'.split(' '));
    for (let i = 0; i < 200; i += 1) {
      possibilities.delete(scramble('ABC'));
    }
    possibilities.size.should.eql(0);
  });
});

describe('say', () => {
  it('works when there are no words', () => {
    say().should.eql('');
  });

  it('works when there are words', () => {
    say('hi')().should.eql('hi');
    say('hi')('there')().should.eql('hi there');
    say('hello')('my')('name')('is')('Colette')().should.eql('hello my name is Colette');
  });
});

describe('powers', () => {
  it('generates sequences of powers properly', () => {
    generatorToArray(powers, 2, -5).should.eql([]);
    generatorToArray(powers, 7, 0).should.eql([]);
    generatorToArray(powers, 3, 1).should.eql([1]);
    generatorToArray(powers, 2, 63).should.eql([1, 2, 4, 8, 16, 32]);
    generatorToArray(powers, 2, 64).should.eql([1, 2, 4, 8, 16, 32, 64]);
  });
});

describe('The powers generator', () => {
  it('works as expected', () => {
    const g1 = powersGenerator(2, 1);
    g1.next().should.eql({ value: 1, done: false });
    g1.next().should.eql({ value: undefined, done: true });
    const g2 = powersGenerator(3, 100);
    g2.next().should.eql({ value: 1, done: false });
    g2.next().should.eql({ value: 3, done: false });
    g2.next().should.eql({ value: 9, done: false });
    g2.next().should.eql({ value: 27, done: false });
    g2.next().should.eql({ value: 81, done: false });
    g2.next().should.eql({ value: undefined, done: true });
  });
});

describe('interleave', () => {
  it('interleaves properly', () => {
    interleave([]).should.eql([]);
    interleave([1, 4, 6]).should.eql([1, 4, 6]);
    interleave([], 2, 3).should.eql([2, 3]);
    interleave([1], 9).should.eql([1, 9]);
    interleave([8, 8, 3, 9], 1).should.eql([8, 1, 8, 3, 9]);
    interleave([2], 7, '8', {}).should.eql([2, 7, '8', {}]);
  });
});

describe('The cylinder function', () => {
  it('makes a cylinder from both arguments', () => {
    const c = cylinder({ radius: 10, height: 5 });
    c.height.should.eql(5);
    c.radius.should.eql(10);
  });
  it('defaults the radius to 1', () => {
    const c = cylinder({ height: 5 });
    c.height.should.eql(5);
    c.radius.should.eql(1);
  });
  it('defaults the height to 1', () => {
    const c = cylinder({ radius: 5 });
    c.height.should.eql(1);
    c.radius.should.eql(5);
  });
  it('accepts an empty object', () => {
    const c = cylinder({});
    c.height.should.eql(1);
    c.radius.should.eql(1);
  });
  it('computes volumes and surface areas correctly', () => {
    const c = cylinder({ radius: 2, height: 10 });
    c.volume().should.be.approximately(40 * Math.PI, 0.000001);
    c.surfaceArea().should.be.approximately(48 * Math.PI, 0.000001);
  });
  it('mutates with stretch and widen', () => {
    const c = cylinder({ radius: 2, height: 10 });
    c.widen(3);
    c.radius.should.eql(6);
    c.stretch(2);
    c.height.should.eql(20);
  });
  it('changes volumes after stretch and widen', () => {
    const c = cylinder({ radius: 2, height: 10 });
    c.widen(3);
    c.volume().should.be.approximately(360 * Math.PI, 0.000001);
    c.stretch(2);
    c.volume().should.be.approximately(720 * Math.PI, 0.000001);
  });
  it('has an immutable radius field', () => {
    const c = cylinder({ radius: 2, height: 10 });
    c.radius = 100;
    c.radius.should.eql(2);
  });
  it('has an immutable height field', () => {
    const c = cylinder({ radius: 2, height: 10 });
    c.height = 100;
    c.height.should.eql(10);
  });
});

describe('crypto functions', () => {
  it('encrypt and decrypt okay', () => {
    const [e, d] = makeCryptoFunctions('super dog', 'aes-256-cbc');
    e('Hello world').should.eql('9714236cbedfd8d9799acea4ea79e6fe');
    d('9714236cbedfd8d9799acea4ea79e6fe').should.eql('Hello world');
    ['', 'abc', 'zπøj#•¶å≈’’'].forEach(s => d(e(s)).should.eql(s));
  });
  it('throws an error for an unknown algorithm', () => {
    (() => makeCryptoFunctions('super dog', 'asdf*')[0]('Hello')).should.throw(Error);
  });
});

describe('The random name function', () => {
  it('returns a promise, anyway', () => {
    const p = randomName({ gender: 'female', region: 'canada' });
    ('then' in p).should.be.ok();
  });
  it('produces a resolved promise with a name and surname', (done) => {
    randomName({ gender: 'female', region: 'canada' }).then((name) => {
      // You might try: console.log(name); // eslint-disable-line no-console
      name.length.should.be.above(3);
      name.should.containEql(' ');
    }).then(done, done);
  });
  it('produces a rejected promise for an unknown gender', (done) => {
    randomName({ gender: 'fefwefemale', region: 'canada' }).catch((error) => {
      error.message.should.containEql('400');
    }).then(done, done);
  });
});