december 2, 2023

{ advent of code 2023 in javascript with test driven development: day 1 }


tags: advent of code, algorithms, javascript

’Tis the season for some coding. I’m giving Advent of Code a try this year and solving coding challenges in JavaScript. I will also be using test driven development with Jest.

There are no guarantees how far I will get (last year I think I made it to day 2), but let’s give it a go!

What is Advent of Code?

Advent of Code is a series of daily coding challenges that can be solved in any coding language. Much like the traditional advent calendar, there is one a day for the 25 days leading up to Christmas. Unlike a traditional advent calendar, you get the thrill of proving your coding prowess instead of a piece of chocolate.

If you buy a real advent calendar and do Advent of Code, you get to feel smart while eating chocolate (or, you know, feel dumb while eating chocolate if that’s how it’s going). Win win.

The problems become, on average, harder throughout the month as we follow the story of some cute, but unfortunately incompetent, elves. This year, there are two parts to each problem: a basic part and that a second part that builds on the first. Complete both to get your gold stars for the day.

My Approach to Advent of Code

You know what I haven’t done in a while? Data structures and algorithms.

You know what else I haven’t done in a while? Test driven development.

Obviously the right move is to do both of these things daily throughout the month of December. I will be blogging about these as I go, but I warn you now, I may not go far. Especially if it finally starts snowing and the ski lifts open.

My goals for this year of Advent of Code are to:

  • Follow a process of TDD, good documentation, and thoughtful code
  • Put my brain back in the DSA world
  • Improve my JS knowledge

My goals are not:

  • Code with good runtimes
  • Clever code

That said, I am always open to feedback! Notice something I could improve? Have a cool method I should try? Let me know!

Day 1 Challenge: Trebuchet

You can find the day’s challenge, examples, and sample code input here.

In Advent of Code fashion, I am apparently going to be launched from a trebuchet by some elves who have jumbled up the calibration numbers.

We now have to translate jumbled strings that look like “nnta3taw9ta1” to two-digit numbers with the first and last numbers of the string. So we have:

1abc2 → 12
pqr3stu8vwx → 38
a1b2c3d4e5f → 15
treb7uchet → 77

Then we add all of the numbers together to get the sum for our answer. The answer to the above is 142.

Tests for Part 1 in Jest

const { sumCalibrationValues, extractNum } = require("./aoc");
const test_file = "test-file.txt";

describe("sumCalibrationValues", function() {
    test("returns the correct sum", async function() {
        expect(await sumCalibrationValues(test_file)).toEqual(314);
    })
})

describe("extractNum", function() {
    test("returns correct 2 digit number for integers", function() {
        expect(extractNum("1abc2")).toEqual(12);
        expect(extractNum("pqr3stu8vwx")).toEqual(38);
        expect(extractNum("a1b2c3d4e5f")).toEqual(15);
        expect(extractNum("treb7uchet")).toEqual(77);
    })
})

Solution for Part 1

My solution uses three functions, one conductor function that handles opening the input file and the summing, one function to convert the file to an array, and one to find the numbers in the string.

I did not test the function that converts the file to an array because it’s one I use elsewhere and already have tests for.

const os = require("os");
const fs = require('fs/promises');

/** sumCalibrationValues: given a filepath containing lines of strings, interate
 * through the lines and return the sum of the two digit numbers represented by 
 * the first digit from the left and last digit from the left in each line. 
 */
async function sumCalibrationValues(filepath) {
    const file = await fs.readFile(filepath, "utf-8");
    const fileLines = getLinesFromString(file);
    const calibrationValues = fileLines.map(line => {
        const trimmed = line.trim();
        return extractNum(trimmed)});
    return calibrationValues.reduce(
        (accumulator, currentValue) => accumulator + currentValue, 0,
    );
}

/** getLinesFromString: Takes string input.
 * Returns an array containing each non-empty line of file as an element.
 */
function getLinesFromString(string) {
    // Using the EOL constant from the node os module // to identify the
    // correct end-of-line (EOL) character // that is specified by the
    // operating system it's running on // this ensures we always know how to
    // identify the end of a line // \n on Linux and macOS, \r\n on Windows.
    return (
      string
        .split(os.EOL)
        .filter(u => u !== "")
    );
  }

/** extractNum: in the inputted string, find the first occuring number and last
 * occuring number. These may be the same. Return an integer with the first
 * number in the tens place and the last number in the ones place:
 * "1abc2" --> 12
 * "a1b2c3d4e5f" --> 15
 * "treb7uchet" --> 77
*/
function extractNum(string) {
    let first;
    let last;

    // find first number from left
    let idx = 0;
    while (first === undefined) {
        if (!isNaN(Number(string[idx])) ) {
            first = string[idx];
        }
        idx++;
    }

    // find last number from left
    idx = string.length - 1;
    while (last === undefined) {
        if (!isNaN(Number(string[idx]))) {
            last = string[idx];
        }
        idx--;
    }
    
    const num = Number(`${first}${last}`);
    return num;
}

Part 2 Tests and Solution

The second part adds the additional challenge that strings can contain numbers as numbers (2) or numbers as strings (two). For example:

two1nine → 29
abcone2threexyz → 13
4nineeightseven2 → 42
zoneight234 → 14
7pqrstsixteen → 76

I thought about a few different strategies here, flipping back and forth between changing my existing extractNum function to parse number strings as well or creating a function to perform a conversion and the using extractNum as was.

My decision was made when I came across this line in the given input code: “8fourfouroneightr”. I assumed this needed to be translated to 88. Since I was already looping from left and right in extractNum, I decided to just alter the function.

Tests added to the extractNum unit tests:

test("returns correct 2 digits for spelled out number strings", function() {
        expect(extractNum("two1nine")).toEqual(29);
        expect(extractNum("eightwothree")).toEqual(83);
        expect(extractNum("abcone2threexyz")).toEqual(13);
        expect(extractNum("xtwone3four")).toEqual(24);
        expect(extractNum("4nineeightseven2")).toEqual(42);
        expect(extractNum("zoneight234")).toEqual(14);
        expect(extractNum("8fourfouroneightr")).toEqual(88);
    })

The updated extractNum, used alongside an object with the number and string key value pairs:

/** extractNum: in the inputted string, find the first occurring number and last
 * occurring number in in integer or sting format. These may be the same. Return
 * an integer with the first number in the tens place and the last number in the
 * ones place:
 * "1abc2" --> 12
 * "a1b2c3d4e5f" --> 15
 * "two1nine" --> 29
 * "abcone2threexyz" -> 13
 */
function extractNum(string) {
    let first;
    let last;

    // find first number from left
    firstNum:
    for (let i = 0; i < string.length; i++) {
        // check for number in number format
        if (!isNaN(Number(string[i])) ) {
            first = string[i];
            break firstNum;
        }

        // check for number in string format
        let currSubstring = string[i];

        for (let j = i + 1; j < string.length; j++) {
            if (currSubstring in DIGIT_STRINGS) {
                first = DIGIT_STRINGS[currSubstring];
                break firstNum;
            } else {
                currSubstring += string[j];
            }
        }
    }

    // find last number from left
    secondNum:
    for (let i = string.length - 1; i >= 0; i--) {
        // check for number in number format
        if (!isNaN(Number(string[i]))) {
            last = string[i];
            break secondNum;
        }

        // check for number in string format
        let currSubstring = string[i];

        for (let j = i - 1; j >= 0; j--) {
            if (currSubstring in DIGIT_STRINGS) {
                last = DIGIT_STRINGS[currSubstring];
                break secondNum;
            } else {
                currSubstring = string[j] + currSubstring;
            }
        }
    }

    return Number(`${first}${last}`);
}

Today’s Takeaways

I recently stumbled upon JavaScript labels the other day when helping some students with a fun bug, and this was the first time I got to use them!

I also had a fun bug of my own here. My unit tests for extractNum passing, but when I ran my integration test for sumCalibrationValues, extractNum kept returning only the first digit. I finally discovered that the string I was passing to extractNum had an extra space at the end that threw off my indexing. Unfortunately, an extra space is not really something I can spot in a console log.

Next year, I am going to prep everything a few days before. I spent most of today setting up my files and refreshing my Node and Jest knowledge since it has been at least 5 months since I worked in either.