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