Solving Advent of Code 2020 day 17 by not solving it

January 2021 ∙ 16 minute read ∙

... in which we take a look at the day 17 problem from Advent of Code 2020, Conway Cubes. We will end up solving it eventually, but in a very roundabout way; you will see why shortly.

Along the way, we will:

  • test and refactor,
  • avoid hard problems, and
  • write some idiomatic Python, from scratch.

There are both shorter and faster ways of solving this, but we'll take the scenic route, in a way that helps you translate those skills to your regular, non-puzzle coding.

You can read this without having solved the problem, but to get the most from it, I recommend you do it on your own first, and then follow along as we do it again here.

Contents

The problem #

The problem might look familiar (if you didn't read it yet, go do that first):

  • we have a grid of cells that can be either active or inactive
  • cells change state at each step in time depending on their neighbors

These things are called cellular automata, the most famous of which is probably Conway's Game of Life. Our problem seems to have the same rules as Life:

  • any active cell with 2 or 3 active neighbors remains active
  • any inactive cell with 3 active neighbors becomes active
  • all other cells become/remain inactive

... except the grid is 3-dimensional.

Minor spoiler alert:

In part two of the problem, the grid is 4-dimensional.

You might notice that this is relatively hard to visualize: the example output has 5 layers after 3 cycles in 3 dimensions, and 25 layers after only 2 cycles in 4 dimensions! Understanding what's happening in 4D seems difficult even assuming the number of planes remains 25 for future iterations.1

The normal flow of solving the problem would be something like this:

  • implement the 3D version; then,
  • after seeing the second part, either
    • do 4D in a similar fashion, or
    • generalize 3D to an arbitrary number of dimensions.

We'll do the opposite: implement 2D, which is trivial to check by hand, and once we have some tests, generalize that.

Parsing the input #

First, let's parse the input.

We'll transform the input string into a list of lists of integers, where 0 and 1 represent inactive and active cubes, respectively. For the test input, it should look like this:

[[0, 1, 0], [0, 0, 1], [1, 1, 1]]

Using numbers separates concerns, but also allows for nicer code: we can write if active instead of if active == '#', and sum(cubes) instead of sum(active == '#' for active in cubes).

1
2
3
4
5
6
7
8
CHARACTERS = '.#'
CHR_TO_NUM = {c: i for i, c in enumerate(CHARACTERS)}

def parse_input(input):
    return [
        [CHR_TO_NUM[c] for c in line]
        for line in input.splitlines()
    ]

Later on, we'll need to turn that back into a string; let's take care of it now:

10
11
12
13
14
15
16
NUM_TO_CHR = dict(enumerate(CHARACTERS))

def format_2d(plane):
    return ''.join(
        ''.join(NUM_TO_CHR[n] for n in line) + '\n'
        for line in plane
    )

We're not bothering to minimize the output like the example does (hide empty rows/columns), since we won't be looking at more than one layer per cycle anyway.

Having both functions, we can write a basic round-trip test:

18
19
20
21
22
23
24
TEST_INPUT = """\
.#.
..#
###
"""

assert format_2d(parse_input(TEST_INPUT)) == TEST_INPUT

The world #

There's more than one way to represent the world, but most straightforward should be the same we did the input, as a nested list of integers.

The grid is supposed to be infinite; there are no infinite lists in Python, so we have to cheat: we'll make a big enough world, and check if we've reached the edges.

For now, 8 cubes ought to be enough for anyone:

27
28
29
x_size = y_size = 8

world = [[0] * x_size for _ in range(y_size)]

Note

[[0] * x_size] * y_size won't work, because the outer list will contain the (same) inner list y_size times:

>>> l = [[0] * 3] * 3
>>> l
[[0, 0, 0], [0, 0, 0], [0, 0, 0]]
>>> l[0][0] = 1
>>> l
[[1, 0, 0], [1, 0, 0], [1, 0, 0]]

We'll place the input in the middle, to give it room to grow in all directions:

32
33
34
35
36
37
38
39
40
input = parse_input(TEST_INPUT)
x_offset = (x_size - len(input[0])) // 2
y_offset = (y_size - len(input)) // 2

for y, line in enumerate(input):
    for x, active in enumerate(line):
        world[y + y_offset][x + x_offset] = active

print(format_2d(world))

At this point, the script should output this:

........
........
...#....
....#...
..###...
........
........
........

Simulation #

Let's simulate one cycle.

There are many optimizations to this, but again, we'll do the straightforward thing: go through each cell, count its neighbors, and change its state according to the rules.

The cube state changes simultaneously for all cubes, so we cannot update the world in-place: given two neighboring cubes updated in order, the first cube's old state will be gone when we get to the second one. To avoid this, we will use two worlds, always reading from the old world and changing the new one.

We'll put the logic in a function that takes the two worlds as arguments:

27
def simulate(old, new):

Going through each cell:

28
29
    for y, line in enumerate(old):
        for x, active in enumerate(line):

The cell's neighbors are all the cells in a 3x3 square centered on it, except the cell itself:

31
32
33
34
35
36
37
38
39
            active_neighbors = 0
            for i in -1, 0, 1:
                for j in -1, 0, 1:

                    if i == j == 0:
                        continue

                    nx = x + i
                    ny = y + j

old[ny][nx] should now get us the neighbor's state; however, there are a couple of (literal) edge cases:

  • The current cell has one of the coordinates 0 (is on the top or left edge). Due to how list indexing works, the neighbors to the top/left (coordinate -1) will be cells from the opposite edge of the world. We don't want this: the world is supposed to be infinite, not wrap around.
  • The current cell has one of the coordinates size - 1 (is on the bottom or right edge). We'll get an IndexError when trying to get the bottom/right neighbor.

In both cases, we want to know when the current cube is active, since it's possible one of its out of bounds neighbors should become active:

41
42
43
44
45
46
47
48
49
50
51
                    if nx < 0 or ny < 0:
                        if active:
                            raise RuntimeError(f"active on edge: {(x, y)}")
                        continue

                    try:
                        active_neighbors += old[ny][nx]
                    except IndexError:
                        if active:
                            raise RuntimeError(f"active on edge: {(x, y)}")
                        continue

Note

For now, the exceptions will remain unhandled, and we'll make the world bigger by hand when required. We could do something else, like catch the exception, scale the world by some constant factor, and continue simulating.

Having that, we set the cell's new state according to the rules:

53
54
55
56
57
58
            if active:
                new_active = int(2 <= active_neighbors <= 3)
            else:
                new_active = int(active_neighbors == 3)

            new[y][x] = new_active

Let's call that 6 times. Instead instead of creating a new world on each iteration, we can reuse the old one from the previous iteration, since we'd be throwing it away anyway.

77
78
79
80
81
82
83
84
old = world
new = [[0] * x_size for _ in range(y_size)]

for cycle in range(6):
    simulate(old, new)
    new, old = old, new
    print(f"after cycle #{cycle + 1}")
    print(format_2d(old))
Running the script should output this:
........
........
...#....
....#...
..###...
........
........
........

after cycle #1
........
........
........
..#.#...
...##...
...#....
........
........

after cycle #2
........
........
........
....#...
..#.#...
...##...
........
........

after cycle #3
........
........
........
...#....
....##..
...##...
........
........

after cycle #4
........
........
........
....#...
.....#..
...###..
........
........

after cycle #5
........
........
........
........
...#.#..
....##..
....#...
........

after cycle #6
........
........
........
........
.....#..
...#.#..
....##..
........

A quick visual inspection confirms our simulation is correct.

Note

You may notice that after 4 cycles, we have the initial pattern, but moved one cell to the bottom and right; this pattern is called a glider.

To wrap up, we'll calculate the puzzle answer (the number of cubes left in the active state after the sixth cycle), and add a test to make sure we won't break anything later on:

 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
world = old

active_cubes = sum(active for line in world for active in line)
print("the result is", active_cubes)

assert active_cubes == 5
assert world == parse_input("""\
........
........
........
........
.....#..
...#.#..
....##..
........
""")

The script up to this point.

Cleaning up #

Now that our 2D implementation is correct, we can make it more general.

We won't do that straight away, though; to make it possible to call in multiple ways and generalize incrementally, we'll split the script into functions.

Tip

Throughout these changes, our test suite should keep passing.

While working on the script, I used entr to re-run it automatically on every save:

echo conway_cubes.py | entr -rc python3 conway_cubes.py

Your editor may have have a "save and run" function; if it doesn't, entr is a nifty editor-independent way of achieving the same thing.

First, let's extract the neighbor-counting logic:

27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
def get_active_neighbors(world, active, x, y):
    active_neighbors = 0
    for i in -1, 0, 1:
        for j in -1, 0, 1:

            if i == j == 0:
                continue

            nx = x + i
            ny = y + j

            if nx < 0 or ny < 0:
                if active:
                    raise RuntimeError(f"active on edge: {(x, y)}")
                continue

            try:
                active_neighbors += world[ny][nx]
            except IndexError:
                if active:
                    raise RuntimeError(f"active on edge: {(x, y)}")
                continue

    return active_neighbors
53
54
55
56
57
def simulate(old, new):
    for y, line in enumerate(old):
        for x, active in enumerate(line):
            active_neighbors = get_active_neighbors(old, active, x, y)
            # ...

Then, the construction of worlds:

66
67
def make_world(x, y):
    return [[0] * x for _ in range(y)]
72
world = make_world(x_size, y_size)
87
new = make_world(x_size, y_size)

Then, the input copying:

70
71
72
73
74
75
def copy_centered_2d(src, dst):
    y_offset = (len(dst) - len(src)) // 2
    x_offset = (len(dst[0]) - len(src[0])) // 2
    for y, line in enumerate(src):
        for x, point in enumerate(line):
            dst[y + y_offset][x + x_offset] = point
84
copy_centered_2d(input, world)

Finally, let's wrap the whole solution in a function.

Again, to separate logic from presentation, we'll split this into a generator that yields new states, forever; it yields the initial state first for convenience:

78
79
80
81
82
83
84
85
86
87
88
def simulate_forever(size, input):
    old = make_world(size, size)
    new = make_world(size, size)

    copy_centered_2d(input, old)

    yield old
    while True:
        simulate(old, new)
        old, new = new, old
        yield old

... and a function that drives it for 6 cycles, printing the resulting worlds.

Since we want to use the function for more dimensions, but don't want to implement formatting for anything except 2D, we'll make printing optional through the magic of dependency injection.

Also, to keep the tests working, we'll return the final state and the number of active cubes.

 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
def run(size, input, format_world=None):
    worlds = simulate_forever(size, input)

    for cycle, world in zip(range(6 + 1), worlds):
        print(f"after cycle #{cycle}: ", end='')
        if format_world:
            print()
            print(format_world(world))
        else:
            print('...')

    active_cubes = sum(active for line in world for active in line)
    print("the result is", active_cubes)
    print()

    return world, active_cubes


world, active_cubes = run(8, parse_input(TEST_INPUT), format_world=format_2d)

The script up to this point.

Going multidimensional #

OK, we're ready now: let's follow simulate_forever and make it work with N dimensions.

First, we'll fix make_world.

Instead of one size argument per dimension, it will take a single argument, a tuple of sizes. Using size or sizes as the name might be confusing (since they are different kinds of sizes); how about we call it shape?

To handle the arbitrary number of dimensions, make_world will be recursive:

66
67
68
69
70
def make_world(shape):
    head, *rest = shape
    if not rest:
        return [0] * head
    return [make_world(rest) for _ in range(head)]

We now need to bubble this change up to run. For convenience, it will keep its size, but get a new dimensions argument, so we can have an n-dimensional "square" world.

81
82
83
84
def simulate_forever(shape, input):
    old = make_world(shape)
    new = make_world(shape)
    # ...
94
95
96
def run(size, dimensions, input, format_world=None):
    worlds = simulate_forever((size,) * dimensions, input)
    # ...
112
world, active_cubes = run(8, 2, parse_input(TEST_INPUT), format_world=format_2d)

Next, input copying. For 2D, the destination was the world itself; for higher dimensions, the destination is the (2D) plane in the middle of each higher dimension:

81
82
83
84
85
86
87
88
89
90
def simulate_forever(shape, input):
    old = make_world(shape)
    new = make_world(shape)

    copy_dst = old
    for n in shape[:-2]:
        copy_dst = copy_dst[(n - 1) // 2]
    copy_centered_2d(input, copy_dst)

    # ...

Continuing with simulate. We need a generic version of the nested loop that goes over all the cells. We'll write a version of enumerate that works with nested lists, and instead of i, value pairs, yields (..., k, j, i), value pairs:

53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
def ndenumerate(world, dimensions=None):
    if dimensions is None:
        dimensions = 0
        temp = world
        while isinstance(temp, list):
            dimensions += 1
            temp = temp[0]

    if dimensions == 1:
        for i, value in enumerate(world):
            yield (i,), value
        return

    for i, part in enumerate(world):
        for t, value in ndenumerate(part, dimensions - 1):
            yield (i,) + t, value

Note

You may notice we stole the function name from numpy. In the first draft of this article it was called coord_enumerate and it generated ..., k, j, i, value tuples, but I changed it to make things easier for people already familiar with numpy.

Unlike with numpy arrays, we have to infer the number of dimensions, since our world doesn't have an explicit shape.

Also unlike the numpy version, ours only works with lists; to make it work with other sequences, we could use something like:

isinstance(temp, collections.abc.Sequence)

We'll leave get_active_neighbors last, and finish simulate first:

71
72
73
74
75
76
77
78
79
80
81
82
83
def simulate(old, new):
    for coords, active in ndenumerate(old):
        active_neighbors = get_active_neighbors(old, active, *reversed(coords))

        if active:
            new_active = int(2 <= active_neighbors <= 3)
        else:
            new_active = int(active_neighbors == 3)

        target = new
        for coord in coords[:-1]:
            target = target[coord]
        target[coords[-1]] = new_active

run also goes through all the cubes to count the active ones, so we'll change it to use ndenumerate as well:

128
    active_cubes = sum(active for _, active in ndenumerate(world))

Finally, let's do get_active_neighbors.

First, we need to flatten the loop that generates neighbor coordinates.

We could use recursion, like we did with make_world and ndenumerate, but the standard library already has a tool for this: itertools.product generates the cartesian product of some iterables; even better, it can generate the product of an iterable with itself.

1
2
3
4
from itertools import product


# ...
30
31
32
def make_directions(dimensions):
    ts = product((-1, 0, 1), repeat=dimensions)
    return [t for t in ts if t != (0,) * dimensions]

Tip

Even if itertools.product did not have a repeat argument, we could still emulate it with argument unpacking:

product(* [(-1, 0, 1)] * dimensions)

After using make_directions, and making the neighbor counting parts generic as well, get_active_neighbors becomes:

35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
def get_active_neighbors(world, active, coords):
    active_neighbors = 0
    for offsets in make_directions(len(coords)):

        neighbor_coords = [
            coord + offset
            for coord, offset in zip(coords, offsets)
        ]

        if any(coord < 0 for coord in neighbor_coords):
            if active:
                raise RuntimeError(f"active on edge: {coords}")
            continue

        try:
            neighbor = world
            for coord in neighbor_coords:
                neighbor = neighbor[coord]
        except IndexError:
            if active:
                raise RuntimeError(f"active on edge: {coords}")
            continue

        active_neighbors += neighbor

    return active_neighbors

The last remaining thing is to fix its call site in simulate:

83
        active_neighbors = get_active_neighbors(old, active, coords)

We're done!

3D #

After 6 cycles with the test input, there should be 112 active cubes:

159
160
_, active_cubes = run(8, 3, parse_input(TEST_INPUT))
assert active_cubes == 112

It turns out 8 cubes is not enough:

after cycle #0: ...
after cycle #1: ...
after cycle #2: ...
after cycle #3: ...
Traceback (most recent call last):
  File "conway_cubes.py", line 161, in <module>
    _, active_cubes = run(8, 3, parse_input(TEST_INPUT))
  File "conway_cubes.py", line 131, in run
    for cycle, world in zip(range(6 + 1), worlds):
  File "conway_cubes.py", line 123, in simulate_forever
    simulate(old, new)
  File "conway_cubes.py", line 84, in simulate
    active_neighbors = get_active_neighbors(old, active, coords)
  File "conway_cubes.py", line 47, in get_active_neighbors
    raise RuntimeError(f"active on edge: {coords}")
RuntimeError: active on edge: (2, 3, 0)

Good thing we're checking for that. Bumping the world size to 16 fixes it:

after cycle #0: ...
after cycle #1: ...
after cycle #2: ...
after cycle #3: ...
after cycle #4: ...
after cycle #5: ...
after cycle #6: ...
the result is 112

Time to plug the puzzle input; I assume they are different for everyone, so feel free to use yours instead.

At first I got another active on edge error; since running with the test input already takes about a second, I increased the world size by smaller increments instead of doubling it; 20 is enough for my input:

162
163
164
165
166
167
168
169
170
171
172
173
INPUT = """\
.##...#.
.#.###..
..##.#.#
##...#.#
#..#...#
#..###..
.##.####
..#####.
"""

_, active_cubes = run(20, 3, parse_input(INPUT))

the result is 386, which unlocks the second part of the problem.

4D #

Moving on to even higher dimensions:

The 4D cube count for the test input should be 848:

175
176
177
178
_, active_cubes = run(16, 4, parse_input(TEST_INPUT))
assert active_cubes == 848

_, active_cubes = run(20, 4, parse_input(INPUT))

... and after 10 seconds the first cycle hasn't ended yet.

Let's add some timings in run before we let this run any further.

2
import time
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
def run(size, dimensions, input, format_world=None):
    worlds = simulate_forever((size,) * dimensions, input)

    total_time = 0
    start = time.perf_counter()
    for cycle, world in zip(range(6 + 1), worlds):
        end = time.perf_counter()

        print(f"after cycle #{cycle} ({end - start :.2f}s): ", end='')
        if format_world:
            print()
            print(format_world(world))
        else:
            print('...')

        total_time += end - start
        start = time.perf_counter()

    active_cubes = sum(active for _, active in ndenumerate(world))
    print(f"the result is {active_cubes} ({total_time:.2f}s)")
    print()

    return world, active_cubes

Note

We use time.perf_counter because it is the "clock with the highest available resolution to measure a short duration"; since the timescales we're dealing with don't probably need it, we could have used time.monotonic (on Unix, they're actually the same).

In general, you should avoid time.time for measuring durations, because it can skip backward or forward (e.g. daylight saving time), or move slower or faster than real time (e.g. leap smearing).

After about 1 minute, I get the (correct) result for the test input.

After 2 more minutes, the result is 2276; this is also correct.

The script up to this point.

Conclusion #

What have we learned?

Sometimes, it's easier to start with a simpler problem, and iterate towards the real one: we did most of the coding for 2D, which was easier to understand and debug; once we got that general enough, 3D and 4D just worked.

Tests make refactoring quicker. Even for "exploratory" coding like this, when you don't know exactly what the final code will look like, or even what you're trying to achieve, some parts "settle" earlier than others; once that happens, a simple high-level test allows you to focus on the next thing without having to constantly check that stuff that was working is still working.

The 4D simulation is really slow ... exponentially slow. We'll see at least one way of improving that in a future post.

Bonus: CLI and tests #

When run, the script goes through all the tests and then prints the results for the two parts of the problem (3 and 4 dimensions).

Let's make it a bit easier to run a single simulation with a specific world size, dimension, and number of cycles; this will be useful when profiling.

First, we'll move the formatting test (line 22) and the other tests to a new file, test_conway_cubes.py, and into test functions; we leave TEST_INPUT in the module since we'll use it from the CLI as well.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
from textwrap import dedent

from conway_cubes import TEST_INPUT, format_2d, parse_input, run


def test_parse_roundtrip():
    assert format_2d(parse_input(TEST_INPUT)) == TEST_INPUT

def test_2():
    world, active_cubes = run(8, 2, parse_input(TEST_INPUT), format_world=format_2d)
    assert active_cubes == 5
    assert world == parse_input(dedent("""\
        ........
        ........
        ........
        ........
        .....#..
        ...#.#..
        ....##..
        ........
    """))

def test_3():
    _, active_cubes = run(16, 3, parse_input(TEST_INPUT))
    assert active_cubes == 112

def test_4():
    _, active_cubes = run(16, 4, parse_input(TEST_INPUT))
    assert active_cubes == 848

We want to be able to run fewer cycles, so we add a new argument to run:

120
def run(size, dimensions, input, format_world=None, cycles=6):
125
    for cycle, world in zip(range(cycles + 1), worlds):

And then remove the rest of the top level code in favor of a rudimentary CLI:

145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
TEST_INPUT = """\
.#.
..#
###
"""

INPUT = """\
.##...#.
.#.###..
..##.#.#
##...#.#
#..#...#
#..###..
.##.####
..#####.
"""

def main(args):
    input = {'test': TEST_INPUT, 'real': INPUT}[args[0]]
    size, dimensions, cycles = map(int, args[1:4])
    run(
        size,
        dimensions,
        parse_input(input),
        format_world=format_2d if dimensions == 2 else None,
        cycles=cycles,
    )

if __name__ == '__main__':
    import sys
    main(sys.argv[1:])

We now can run the tests using pytest. The 4D one takes a while; we use -k to skip it:

$ pytest test_conway_cubes.py -q -k 'not 4'
...                                                                      [100%]
3 passed, 1 deselected in 1.14s

The script is invoked like this:

$ python3 conway_cubes.py real 20 4 6
after cycle #0 (0.01s): ...
after cycle #1 (24.15s): ...
after cycle #2 (23.45s): ...
after cycle #3 (23.79s): ...
after cycle #4 (24.31s): ...
after cycle #5 (24.15s): ...
after cycle #6 (24.07s): ...
the result is 2276 (143.94s)

Bonus: more tests #

Our tests cover the most common cases, but there's something we neglected entirely: edge cases.

At the moment, that's not necessarily an issue: the code dealing with it is straightforward, and the puzzle itself is testing it for us – if we go over the edge and the script doesn't fail, we won't get the right result.

However, the code may become less straightforward after optimizing it, and we don't want to rely on the website for validation every time we make a change.

Let's fix that.

The first test checks we get errors when there are active cells on the edges:

3
import pytest
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
@pytest.mark.parametrize('input',
    [
        """\
        #..
        ...
        ...
        """,
        """\
        .#.
        ...
        ...
        """,
        """\
        ...
        ..#
        ...
        """,
        """\
        ...
        ...
        .#.
        """,
    ]
)
@pytest.mark.parametrize('dimensions', [2, 3, 4])
def test_edge_errors(input, dimensions):
    with pytest.raises(RuntimeError):
        run(3, dimensions, parse_input(dedent(input)), cycles=1)

Here, the parametrize decorator defines 4 different input and 3 different dimensions arguments, so that the test_edge_errors function will run using them in turn, in all possible combinations.

The second test checks that the new world can have active cells on the edges, as long as the old one didn't:

 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
@pytest.mark.parametrize('input, expected_output',
    [
        (
            """\
            .....
            .#...
            .#...
            .#...
            .....
            """,
            """\
            .....
            .....
            ###..
            .....
            .....
            """
        ),
        (
            """\
            .....
            .....
            .....
            .###.
            .....
            """,
            """\
            .....
            .....
            ..#..
            ..#..
            ..#..
            """
        ),

    ]
)
def test_edge_ok(input, expected_output):
    world, _ = run(5, 2, parse_input(dedent(input)), cycles=1)
    assert format_2d(world) == dedent(expected_output)

Because of parametrization, we get 14 actual tests: 12 for test_edge_errors, and 2 for test_edge_ok:

$ pytest test_conway_cubes.py -q -k edge
..............                                                           [100%]
14 passed, 4 deselected in 0.06s

The final version of the script and test file.


Learned something new today? Share this with others, it really helps!

  1. If you think that's a very conservative assumption, trust your instincts. [return]


This is part of a series: