Saturday, 3 September 2016

JS: next Friday puzzle: displaying bytes in "nearest unit"

G'day:
Here's my answer for the next Friday puzzle that was posted on the CFML Slack channel. I skipped last week's one as... well... I couldn't be arsed, but this was an easy one and I could sort it out whilst cursing my jet lag (I've just done London -> Auckland this week).

The details of the puzzle are in this gist, but basically it's to take a number of bytes and "round" it to the appropriate closest unit of bytes (eg: kB, MB, GB etc), up to PB. There's more detail than that, but that's the bit I'm paying attention do. Doing CFML is a waste of time, so this week I've decided to do it in JavaScript instead, and re-polish my piss-poor ES2015 / Node.js / Mocha skills. Although not too much.

First I tried a solution which treated the number as a string and just reduced it down to the most significant figures and slapped a unit onto the end of it. Whilst this did what I set out to do, it only dealt with decimal groupings, not 1024-based ones. Here's the code anyhow. It's not polished up cos I abandoned it half-way through:

f = function(x) {
    var units = ["B", "kB", "MB", "GB", "TB", "PB"];
    var ordersOfMagnitude = 3;

    var numberAsString = x.toString();
    var lengthOfNumber = numberAsString.length; 

    var numberOfDigits = lengthOfNumber % ordersOfMagnitude;
    var unitIndex = Math.floor(lengthOfNumber / ordersOfMagnitude);

    if (numberOfDigits == 0) {
        numberOfDigits = 3;
        unitIndex--;
    }
    if (unitIndex+1 > units.length){
        unitIndex = units.length - 1;
        numberOfDigits = lengthOfNumber - (unitIndex * ordersOfMagnitude);
    }
    
    var digits = numberAsString.substring(0, numberOfDigits);
    var unitToUse = units[unitIndex];

    var result = digits + unitToUse;
    
    return result;
}

This was more just a spike to get me thinking.

Then I tried another version where I did a reduction on the units array, but that was crap as it was quite side-effect-y, and I don't really think it was a good use of a reduce operation. I did not keep the code for that one, so I can't show you.

Next I decided to stop messing around and come up with an answer that actually worked and wasn't daft, so I knocked this one together:

"use strict";
 
let numberToMemoryUnits = function(bytes) {
    let units = ["kB", "MB", "GB", "TB", "PB"];
    let binaryDivisor = 1024;
    let numberOfBytesAsUnit = bytes;
    let unit = "B";
    while (numberOfBytesAsUnit >= binaryDivisor && units.length){
        numberOfBytesAsUnit /= binaryDivisor;
        unit = units.shift();
    }
    let roundedValue = Math.floor(numberOfBytesAsUnit);

    return `${roundedValue}${unit}`;
}

module.exports = numberToMemoryUnits;

That's OK-ish I guess. The only thing I don't like is how I have that first assignment of the units separate to the loop. It seems like dodgy code doubling-up to me, and I should get rid of that somehow, but can't be bothered thinking it through (I'm slightly hungover, which doesn't help).

Update:

I've tweaked this slightly since I first posted it:
  • Improved the argument name from a rather lazy-arsed x to the more descriptive bytes.
  • Likewise improved the name of the variable which was digits to be numberOfBytesAsUnit.
  • Used the intermediary variable roundedValue,
  • and used an interpolated string instead of string concatenation with the return value.
  • improved the way I initialised the units object in the tests, from being individual assignment statements to being a reduction of the units array.
This was off the back of a discussion about my implementation that I had with Brendan on the Slack channel. He asked me why I had the separate variable digits (now numberOfBytesAsUnit) instead of just using the argument x (bytes). This was because whilst the value passed to the function is indeed a number of bytes, once I start using it - specifically in that loop - it's no longer a value in bytes, so I use a new - more descriptively accurate - variable name instead. We also discussed my separate handling of bytes and the other units coming from the array, and I'm still not 100% happy with it, but in using the better variable names around it I mind it less than I did before.

Any other code review input is welcomed, btw.

I was a bit naughty as I tested it by hand whilst developing it, but then felt guilty and formalised a bunch of tests after the fact. I guess I still did TDD whilst throwing this together, but not as deliberately as perhaps I shoulda. Anyhow, here are the tests too:

"use strict";

let assert = require("chai").assert;

let numberToMemoryUnits = require("../src/numberToMemoryUnits.js");

let binaryFactor = 1024;

let units = ["kB", "MB", "GB", "TB", "PB"].reduce(function(units, unit, index){
    units[unit] = Math.pow(binaryFactor, index + 1);
    return units;
}, {});

describe("Tests for each unit", function(){
    it("should work for bytes", function(){
        let result = numberToMemoryUnits(123);
        let expectation = "123B";
        assert.equal(expectation, result);
    });
    it("should work for kB", function(){
        let result = numberToMemoryUnits(2345);
        let expectation = "2kB";
        assert.equal(expectation, result);
    });
    it("should work for MB", function(){
        let result = numberToMemoryUnits(3456789);
        let expectation = "3MB";
        assert.equal(expectation, result);
    });
    it("should work for GB", function(){
        let result = numberToMemoryUnits(4567890123);
        let expectation = "4GB";
        assert.equal(expectation, result);
    });
    it("should work for TB", function(){
        let result = numberToMemoryUnits(5678901234567);
        let expectation = "5TB";
        assert.equal(expectation, result);
    });
    it("should work for PB", function(){
        let result = numberToMemoryUnits(6789012345678901);
        let expectation = "6PB";
        assert.equal(expectation, result);
    });
    
});

describe("Test exact units", function(){
    it("should work for 1kB", function(){
        let result = numberToMemoryUnits(units.kB);
        let expectation = "1kB";
        assert.equal(expectation, result);
    });
    it("should work for 1MB", function(){
        let result = numberToMemoryUnits(units.MB);
        let expectation = "1MB";
        assert.equal(expectation, result);
    });
    it("should work for 1GB", function(){
        let result = numberToMemoryUnits(units.GB);
        let expectation = "1GB";
        assert.equal(expectation, result);
    });
    it("should work for 1TB", function(){
        let result = numberToMemoryUnits(units.TB);
        let expectation = "1TB";
        assert.equal(expectation, result);
    });
    it("should work for 1PB", function(){
        let result = numberToMemoryUnits(units.PB);
        let expectation = "1PB";
        assert.equal(expectation, result);
    });
});
describe("Test off by one", function(){
    it("should work for <1kB", function(){
        let result = numberToMemoryUnits(units.kB-1);
        let expectation = "1023B";
        assert.equal(expectation, result);
    });
    it("should work for >1kB", function(){
        let result = numberToMemoryUnits(units.kB+1);
        let expectation = "1kB";
        assert.equal(expectation, result);
    });
    it("should work for <1MB", function(){
        let result = numberToMemoryUnits(units.MB-1);
        let expectation = "1023kB";
        assert.equal(expectation, result);
    });
    it("should work for >1MB", function(){
        let result = numberToMemoryUnits(units.MB+1);
        let expectation = "1MB";
        assert.equal(expectation, result);
    });
    it("should work for <1GB", function(){
        let result = numberToMemoryUnits(units.GB-1);
        let expectation = "1023MB";
        assert.equal(expectation, result);
    });
    it("should work for >1GB", function(){
        let result = numberToMemoryUnits(units.GB+1);
        let expectation = "1GB";
        assert.equal(expectation, result);
    });
    it("should work for <1TB", function(){
        let result = numberToMemoryUnits(units.TB-1);
        let expectation = "1023GB";
        assert.equal(expectation, result);
    });
    it("should work for >1TB", function(){
        let result = numberToMemoryUnits(units.TB+1);
        let expectation = "1TB";
        assert.equal(expectation, result);
    });
    it("should work for <1PB", function(){
        let result = numberToMemoryUnits(units.PB-1);
        let expectation = "1023TB";
        assert.equal(expectation, result);
    });
    it("should work for >1PB", function(){
        let result = numberToMemoryUnits(units.PB+1);
        let expectation = "1PB";
        assert.equal(expectation, result);
    });
});

describe("Test boundaries", function(){
    it("should work for 0bytes", function(){
        let result = numberToMemoryUnits(0);
        let expectation = "0B";
        assert.equal(expectation, result);
    });
    it("should work for 1024PB", function(){
        let result = numberToMemoryUnits(units.PB*units.kB);
        let expectation = "1024PB";
        assert.equal(expectation, result);
    });
    it("should work for 1048576PB", function(){
        let result = numberToMemoryUnits(units.PB*units.MB);
        let expectation = "1048576PB";
        assert.equal(expectation, result);
    });
});


They're a bit long-winded in total, but the individual tests are simple enough. The key here is I test either side of each each unit, eg: 1023 is presented in bytes, 1024 is 1kB and so is 1025 etc. Also a test of zero for good measure, as well that it handles numbers beyond PB, and just uses PB thereafter (eg: 1025PB displays as such).

And they all pass:
C:\src\js\puzzle\20160903>mocha test\numberToMemoryUnitsTest.js


  Tests for each unit
    √ should work for bytes
    √ should work for kB
    √ should work for MB
    √ should work for GB
    √ should work for TB
    √ should work for PB

  Test exact units
    √ should work for 1kB
    √ should work for 1MB
    √ should work for 1GB
    √ should work for 1TB
    √ should work for 1PB

  Test off by one
    √ should work for <1kB
    √ should work for >1kB
    √ should work for <1MB
    √ should work for >1MB
    √ should work for <1GB
    √ should work for >1GB
    √ should work for <1TB
    √ should work for >1TB
    √ should work for <1PB
    √ should work for >1PB

  Test boundaries
    √ should work for 0bytes
    √ should work for 1024PB
    √ should work for 1048576PB


  24 passing (31ms)


C:\src\js\puzzle\20160903>


That's it. Nothing special today, but still required some thought, and also reminding myself how Mocha works.

Give it a go. Do it in some other language than CFML!

Righto.

--
Adam