This article is part of my #100DaysOfCode and #100DaysOfBlogging challenge. R1D23


Instructions

Today’s challenge is to write a robot simulator. A robot is placed on a hypothetical infinite grid phasing either north, east, south or west. It has three possible movements:

  • turn right,
  • turn left and
  • advance.

A robot can also follow instructions on how to proceed.

  • The letter-string “RAALAL” means:
    • Turn right
    • Advance twice
    • Turn left
    • Advance once
    • Turn left yet again
  • Say a robot starts at {7, 3} facing north. Then running this stream of instructions should leave it at {9, 4} facing west.

Roll up my sleeves

First, I provide a structure that is syntactical correct.

<?php

declare(strict_types = 1);

class Robot
{
    public const DIRECTION_NORTH = 'NORTH';

    public const DIRECTION_EAST = 'EAST';

    public const DIRECTION_SOUTH = 'SOUTH';

    public const DIRECTION_WEST = 'WEST';

    public $position;

    public $direction;

    public function __construct(array $position, string $direction)
    {
        $this->position  = $position;
        $this->direction = $direction;
    }

    public function turnLeft() : Robot
    {
        return $this;
    }

    public function turnRight() : Robot
    {
        return $this;
    }

    public function advance() : Robot
    {
        return $this;
    }

    public function instructions(string $path) : void
    {
    }
}

Next, I implement the instructions method. It’s a command chain where each character is providing a movement instruction. Only three valid commands are available. Everything else will throw an InvalidArgumentException.

public function instructions(string $path) : void
{
    foreach (str_split($path) as $direction) {
        switch ($direction) {
            case 'A':
                $this->advance();
                break;

            case 'L':
                $this->turnLeft();
                break;


            case 'R':
                $this->turnRight();
                break;

            default:
                throw new InvalidArgumentException("Provided instruction '$direction' is not supported.'");
                break;
        }
    }
}

The turnLeft method will simply mutate the current direction. turnRight is the same logic in the other direction.

public function turnLeft() : Robot
{
    switch ($this->direction) {
        case self:: DIRECTION_NORTH:
            $this->direction = self::DIRECTION_WEST;
            break;

        case self:: DIRECTION_EAST:
            $this->direction = self::DIRECTION_NORTH;
            break;

        case self:: DIRECTION_SOUTH:
            $this->direction = self::DIRECTION_EAST;
            break;

        case self:: DIRECTION_WEST:
            $this->direction = self::DIRECTION_SOUTH;
            break;
    }

    return $this;
}

public function turnRight() : Robot
{
    switch ($this->direction) {
        case self:: DIRECTION_NORTH:
            $this->direction = self::DIRECTION_EAST;
            break;

        case self:: DIRECTION_EAST:
            $this->direction = self::DIRECTION_SOUTH;
            break;

        case self:: DIRECTION_SOUTH:
            $this->direction = self::DIRECTION_WEST;
            break;

        case self:: DIRECTION_WEST:
            $this->direction = self::DIRECTION_NORTH;
            break;
    }

    return $this;
}

Improve turning methods and direction constants

I’m not happy with the look of it. Since the instructions and tests are not specifying the value of the geographic direction, I simplify them as follows.

<?php

declare(strict_types = 1);

class Robot
{
    public const DIRECTION_NORTH = 1;

    public const DIRECTION_EAST = 2;

    public const DIRECTION_SOUTH = 3;

    public const DIRECTION_WEST = 4;

    public $position;

    public $direction;

    public function __construct(array $position, int $direction)
    {
        $this->position  = $position;
        $this->direction = $direction;
    }

    public function turnLeft() : Robot
    {
        --$this->direction;

        if (0 === $this->direction) {
            $this->direction = 4;
        }

        return $this;
    }

    public function turnRight() : Robot
    {
        ++$this->direction;

        if (5 === $this->direction) {
            $this->direction = 1;
        }

        return $this;
    }

    public function advance() : Robot
    {
        return $this;
    }

    public function instructions(string $path) : void
    {
        foreach (str_split($path) as $direction) {
            switch ($direction) {
                case 'A':
                    $this->advance();
                    break;

                case 'L':
                    $this->turnLeft();
                    break;


                case 'R':
                    $this->turnRight();
                    break;

                default:
                    throw new InvalidArgumentException("Provided instruction '$direction' is not supported.'");
                    break;
            }
        }
    }
}

Advance

Now to the last method.

public function advance() : Robot
{
    switch ($this->direction) {
        case self:: DIRECTION_NORTH:
            ++$this->position[1];
            break;

        case self:: DIRECTION_EAST:
            ++$this->position[0];
            break;

        case self:: DIRECTION_SOUTH:
            --$this->position[1];
            break;

        case self:: DIRECTION_WEST:
            --$this->position[0];
            break;
    }

    return $this;
}

Splitting up into classes

I am not happy with how everything is squeezed into the Robot class. I will separate Direction and Position from it.

Advance tests

First, I adjust the test suite.

<?php

declare(strict_types = 1);

include_once 'Direction.php';
include_once 'Position.php';
include_once 'Robot.php';

use Exercism\RobotSimulator\Direction;
use Exercism\RobotSimulator\Position;
use Exercism\RobotSimulator\Robot;

class RobotSimulatorTest extends PHPUnit\Framework\TestCase
{
    /**
     * A robot is created with a position and a direction
     */
    public function testCreate()
    {
        // Robots are created with a position and direction
        $robot = $this->initRobot([0, 0], Direction::DIRECTION_NORTH);
        $this->assertEquals([0, 0], $robot->position->toArray());
        $this->assertEquals(Direction::DIRECTION_NORTH, $robot->direction->getDirection());

        // Negative positions are allowed
        $robot = $this->initRobot([-1, -1], Direction::DIRECTION_SOUTH);
        $this->assertEquals([-1, -1], $robot->position->toArray());
        $this->assertEquals(Direction::DIRECTION_SOUTH, $robot->direction->getDirection());
    }

    /**
     * Rotate the robot's direction 90 degrees clockwise
     */
    public function testTurnRight()
    {
        $robot = $this->initRobot([0, 0], Direction::DIRECTION_NORTH);

        // Change the direction from north to east
        $robot->turnRight();
        $this->assertEquals([0, 0], $robot->position->toArray());
        $this->assertEquals(Direction::DIRECTION_EAST, $robot->direction->getDirection());

        // Change the direction from east to south
        $robot->turnRight();
        $this->assertEquals([0, 0], $robot->position->toArray());
        $this->assertEquals(Direction::DIRECTION_SOUTH, $robot->direction->getDirection());

        // Change the direction from south to west
        $robot->turnRight();
        $this->assertEquals([0, 0], $robot->position->toArray());
        $this->assertEquals(Direction::DIRECTION_WEST, $robot->direction->getDirection());

        // Change the direction from west to north
        $robot->turnRight();
        $this->assertEquals([0, 0], $robot->position->toArray());
        $this->assertEquals(Direction::DIRECTION_NORTH, $robot->direction->getDirection());
    }

    /*
     * Rotate the robot's direction 90 degrees counterclockwise
     */
    public function testTurnLeft()
    {
        $robot = $this->initRobot([0, 0], Direction::DIRECTION_NORTH);

        // Change the direction from north to west
        $robot->turnLeft();
        $this->assertEquals([0, 0], $robot->position->toArray());
        $this->assertEquals(Direction::DIRECTION_WEST, $robot->direction->getDirection());

        // Change the direction from west to south
        $robot->turnLeft();
        $this->assertEquals([0, 0], $robot->position->toArray());
        $this->assertEquals(Direction::DIRECTION_SOUTH, $robot->direction->getDirection());

        // Change the direction from south to east
        $robot->turnLeft();
        $this->assertEquals([0, 0], $robot->position->toArray());
        $this->assertEquals(Direction::DIRECTION_EAST, $robot->direction->getDirection());

        // Change the direction from east to north
        $robot->turnLeft();
        $this->assertEquals([0, 0], $robot->position->toArray());
        $this->assertEquals(Direction::DIRECTION_NORTH, $robot->direction->getDirection());
    }

    /**
     * Move the robot forward 1 space in the direction it is pointing
     */
    public function testAdvance()
    {
        // Increases the y coordinate by one when facing north
        $robot = $this->initRobot([0, 0], Direction::DIRECTION_NORTH);
        $robot->advance();
        $this->assertEquals([0, 1], $robot->position->toArray());
        $this->assertEquals(Direction::DIRECTION_NORTH, $robot->direction->getDirection());

        // Decreases the y coordinate by one when facing south
        $robot = $this->initRobot([0, 0], Direction::DIRECTION_SOUTH);
        $robot->advance();
        $this->assertEquals([0, -1], $robot->position->toArray());
        $this->assertEquals(Direction::DIRECTION_SOUTH, $robot->direction->getDirection());

        // Increases the x coordinate by one when facing east
        $robot = $this->initRobot([0, 0], Direction::DIRECTION_EAST);
        $robot->advance();
        $this->assertEquals([1, 0], $robot->position->toArray());
        $this->assertEquals(Direction::DIRECTION_EAST, $robot->direction->getDirection());

        // Decreases the x coordinate by one when facing west
        $robot = $this->initRobot([0, 0], Direction::DIRECTION_WEST);
        $robot->advance();
        $this->assertEquals([-1, 0], $robot->position->toArray());
        $this->assertEquals(Direction::DIRECTION_WEST, $robot->direction->getDirection());
    }

    /**
     * Where R = Turn Right, L = Turn Left and A = Advance,
     * the robot can follow a series of instructions
     * and end up with the correct position and direction
     */
    public function testInstructions()
    {
        // Instructions to move west and north
        $robot = $this->initRobot([0, 0], Direction::DIRECTION_NORTH);
        $robot->instructions('LAAARALA');
        $this->assertEquals([-4, 1], $robot->position->toArray());
        $this->assertEquals(Direction::DIRECTION_WEST, $robot->direction->getDirection());

        // Instructions to move west and south
        $robot = $this->initRobot([2, -7], Direction::DIRECTION_EAST);
        $robot->instructions('RRAAAAALA');
        $this->assertEquals([-3, -8], $robot->position->toArray());
        $this->assertEquals(Direction::DIRECTION_SOUTH, $robot->direction->getDirection());

        // Instructions to move east and north
        $robot = $this->initRobot([8, 4], Direction::DIRECTION_SOUTH);
        $robot->instructions('LAAARRRALLLL');
        $this->assertEquals([11, 5], $robot->position->toArray());
        $this->assertEquals(Direction::DIRECTION_NORTH, $robot->direction->getDirection());
    }

    public function testMalformedInstructions()
    {
        $this->expectException(InvalidArgumentException::class);

        $robot = $this->initRobot([0, 0], Direction::DIRECTION_NORTH);
        $robot->instructions('LARX');
    }

    /**
     * Optional instructions chaining
     */
    public function testInstructionsChaining()
    {
        $robot = $this->initRobot([0, 0], Direction::DIRECTION_NORTH);
        $robot->turnLeft()
            ->advance()
            ->advance()
            ->advance()
            ->turnRight()
            ->advance()
            ->turnLeft()
            ->advance();
        $this->assertEquals([-4, 1], $robot->position->toArray());
        $this->assertEquals(Direction::DIRECTION_WEST, $robot->direction->getDirection());
    }

    public function testMalformedDirectionMinusOne()
    {
        $this->expectException(InvalidArgumentException::class);

        $this->initRobot([0, 0], -1);
    }

    public function testMalformedDirectionFive()
    {
        $this->expectException(InvalidArgumentException::class);

        $this->initRobot([0, 0], 5);
    }

    private function initRobot(array $position, int $direction) : Robot
    {
        return new Robot(new Position($position[0], $position[1]), new Direction($direction));
    }
}

Split up classes

…and then separate direction and position from Robot.

<?php

declare(strict_types = 1);

namespace Exercism\RobotSimulator;

use InvalidArgumentException;

class Direction
{
    public const DIRECTION_NORTH = 1;

    public const DIRECTION_EAST = 2;

    public const DIRECTION_SOUTH = 3;

    public const DIRECTION_WEST = 4;

    private static $allowableDirections = [
        self::DIRECTION_NORTH,
        self::DIRECTION_EAST,
        self::DIRECTION_SOUTH,
        self::DIRECTION_WEST,
    ];

    private $direction;

    public function __construct(int $direction)
    {
        $this->setDirection($direction);
    }

    public function turnLeft() : Direction
    {
        --$this->direction;

        if (0 === $this->direction) {
            $this->direction = 4;
        }

        return $this;
    }

    public function turnRight() : Direction
    {
        ++$this->direction;

        if (5 === $this->direction) {
            $this->direction = 1;
        }

        return $this;
    }

    public function getDirection() : int
    {
        return $this->direction;
    }

    public function setDirection(int $direction) : Direction
    {
        if (! in_array($direction, static::$allowableDirections, true)) {
            throw new InvalidArgumentException('Given direction is not valid.');
        }

        $this->direction = $direction;

        return $this;
    }
}
<?php

declare(strict_types = 1);

namespace Exercism\RobotSimulator;

class Position
{
    private $x;

    private $y;

    public function __construct(int $x, int $y)
    {
        $this->x = $x;
        $this->y = $y;
    }

    public function toArray() : array
    {
        return [
            $this->x,
            $this->y,
        ];
    }

    public function advanceX() : Position
    {
        ++$this->x;
        return $this;
    }

    public function revertX() : Position
    {
        --$this->x;
        return $this;
    }

    public function advanceY() : Position
    {
        ++$this->y;
        return $this;
    }

    public function revertY() : Position
    {
        --$this->y;
        return $this;
    }
}
<?php

declare(strict_types = 1);

namespace Exercism\RobotSimulator;

use InvalidArgumentException;

class Robot
{
    public $position;

    public $direction;

    public function __construct(Position $position, Direction $direction)
    {
        $this->position  = $position;
        $this->direction = $direction;
    }

    public function turnLeft() : Robot
    {
        $this->direction->turnLeft();
        return $this;
    }

    public function turnRight() : Robot
    {
        $this->direction->turnRight();
        return $this;
    }

    public function advance() : Robot
    {
        switch ($this->direction->getDirection()) {
            case Direction::DIRECTION_NORTH:
                $this->position->advanceY();
                break;

            case Direction::DIRECTION_EAST:
                $this->position->advanceX();
                break;

            case Direction::DIRECTION_SOUTH:
                $this->position->revertY();
                break;

            case Direction::DIRECTION_WEST:
                $this->position->revertX();
                break;
        }

        return $this;
    }

    public function instructions(string $path) : void
    {
        foreach (str_split($path) as $direction) {
            switch ($direction) {
                case 'A':
                    $this->advance();
                    break;

                case 'L':
                    $this->turnLeft();
                    break;


                case 'R':
                    $this->turnRight();
                    break;

                default:
                    throw new InvalidArgumentException("Provided instruction '$direction' is not supported.'");
                    break;
            }
        }
    }
}

Conclusion

Tests are passing and the exercise is complete. This looks much cleaner now. There might still be some more improvements to make, but I am ready for a break for now. Looking forward to the mentor’s feedback.