I have had an attempt at the Mars Rover Kata and would very much appreciate a review and any suggestions to improve.
MessageParser to parse incoming messages and create the domain models:
public class MessageParser
{
private static readonly Regex GridDetailsRegex = new Regex(@"\d\s\d");
private static readonly Regex RoverDetailsRegex = new Regex(@"\d\s\d\s(N|E|S|W)");
private static readonly Regex MovementDetailsRegex = new Regex(@"(L|R|M)");
private static readonly string[] LineSeparators = new[] { "\r\n", "\r", "\n" };
private static readonly Dictionary<char, Movement> MovementsMap =
new Dictionary<char, Movement>()
{
{'L', Movement.TurnLeft},
{'R', Movement.TurnRight},
{'M', Movement.Move},
};
public string HandleMessage(string message)
{
var result = ParseMessage(message);
return result;
}
private Result<string[]> SplitLines(string message)
{
if (string.IsNullOrWhiteSpace(message))
{
return Result<string[]>.Failure(Error.MessageIsEmpty);
}
var lines = message.Split(
LineSeparators,
StringSplitOptions.None
);
if (lines.Length < 5)
{
return Result<string[]>.Failure(Error.MessageIsInvalid);
}
var gridDetailsIsValid = GridDetailsRegex.IsMatch(lines[0].Trim());
var roverParts = lines.Skip(1).ToArray();
var roverDetailsAreValid = roverParts.Where((rp, index) => index % 2 == 0).All(rd => RoverDetailsRegex.IsMatch(rd));
var roverMovementsAreValid = roverParts.Where((rp, index) => index % 2 > 0).All(rm=>MovementDetailsRegex.IsMatch(rm));
return gridDetailsIsValid && roverDetailsAreValid && roverMovementsAreValid ? Result<string[]>.Success(lines) : Result<string[]>.Failure(Error.MessageIsInvalid);
}
private string ParseMessage(string message)
{
var partsResult = SplitLines(message);
if (partsResult.IsSuccess == false)
{
return Lines(partsResult.Errors);
}
var lineParts = partsResult.Value;
var gridDetails = lineParts.First();
var grid = Grid.Create(gridDetails);
if (grid.IsSuccess == false)
{
return Lines(grid.Errors);
}
var roverDetails = lineParts.Skip(1).ToArray();
var result = AddRovers(grid.Value, roverDetails);
return result.IsSuccess ? Lines(grid.Value.GetRoverStatuses()) : Lines(result.Errors);
}
private Result AddRovers(Grid grid, string[] roverParts)
{
for (int i = 0; i < roverParts.Length - 1; i += 2)
{
var rover = Rover.Create(roverParts[i]);
var movements = roverParts[i + 1].ToArray().Select(m => MovementsMap[m]).ToArray();
var addResult = grid.AddRover(rover, movements);
if (addResult.IsSuccess == false)
{
return addResult;
}
}
return Result.Success();
}
private static string Lines(IEnumerable<Error> errors)
{
return Lines(errors.Select(e => e.Code));
}
private static string Lines(IEnumerable<string> lines)
{
return string.Join(Environment.NewLine, lines);
}
}
Coordinate:
public struct Coordinate
{
public int X { get; }
public int Y { get; }
public Coordinate(int x, int y)
{
X = x;
Y = y;
}
public bool IsWithin(Coordinate minimum, Coordinate maximum)
{
return maximum.X >= X && maximum.Y >= Y && minimum.X <= X && minimum.Y <= Y;
}
public Coordinate Move(Direction direction)
{
if (direction.Equals(Direction.North))
{
return new Coordinate(X, Y + 1);
}
if (direction.Equals(Direction.East))
{
return new Coordinate(X + 1, Y);
}
if (direction.Equals(Direction.South))
{
return new Coordinate(X, Y - 1);
}
return new Coordinate(X - 1, Y);
}
}
Direction:
public struct Direction
{
public static readonly Direction North = new Direction("N", "E", "W");
public static readonly Direction East = new Direction("E", "S", "N");
public static readonly Direction South = new Direction("S", "W", "E");
public static readonly Direction West = new Direction("W", "N", "S");
private static readonly List<Direction> ValidDirections = new List<Direction>()
{
North,East,South,West
};
public string Current { get; }
public string Right { get; }
public string Left { get; }
private Direction(string current, string right, string left)
{
Current = current;
Right = right;
Left = left;
}
public static Direction Create(string direction)
{
switch (direction)
{
case "N":
return North;
case "E":
return East;
case "S":
return South;
case "W":
return West;
default:
throw new ArgumentException($"Invalid direction {direction}", nameof(direction));
}
}
public Direction TurnLeft()
{
var left = Left;
return ValidDirections.Single(d => d.Current == left);
}
public Direction TurnRight()
{
var right = Right;
return ValidDirections.Single(d => d.Current == right);
}
public override string ToString() => Current;
}
Movement:
public enum Movement
{
TurnLeft,
TurnRight,
Move
}
Rover:
public class Rover
{
public string Id { get; }
public Coordinate Coordinate { get; }
public Direction Direction { get; }
private Rover(Coordinate coordinate, Direction direction)
{
Coordinate = coordinate;
Direction = direction;
Id = $"{coordinate.X}-{coordinate.Y}-{direction}";
}
public override string ToString() => $"{Coordinate.X} {Coordinate.Y} {Direction}";
public Result<Rover> Move(Movement[] movements, Coordinate[] obstacles, Coordinate minimum, Coordinate maximum)
{
var newCoordinate = new Coordinate(Coordinate.X, Coordinate.Y);
Direction newDirection = Direction;
foreach (var movement in movements)
{
if (movement == Movement.Move)
{
newCoordinate = newCoordinate.Move(newDirection);
if (newCoordinate.IsWithin(minimum, maximum) == false)
{
return Result<Rover>.Failure(Error.OutOfBoundRover(Id));
}
if (obstacles.Any(o => o.Equals(newCoordinate)))
{
return Result<Rover>.Failure(Error.Obstacle(Id, newCoordinate));
}
}
else
{
newDirection = movement == Movement.TurnLeft ? newDirection.TurnLeft() : newDirection.TurnRight();
}
}
return Result<Rover>.Success(new Rover(newCoordinate, newDirection));
}
public static Rover Create(string roverDetails)
{
var parts = roverDetails.Split(" ");
return new Rover(new Coordinate(int.Parse(parts[0]), int.Parse(parts[1])), Direction.Create(parts[2]));
}
}
Grid:
public class Grid
{
private readonly Coordinate _maximumBoundaries;
private readonly Coordinate _minimumBoundaries;
private readonly List<Rover> _rovers;
private Grid(Coordinate minimumBoundaries, Coordinate maximumBoundaries)
{
_maximumBoundaries = maximumBoundaries;
_minimumBoundaries = minimumBoundaries;
_rovers = new List<Rover>();
}
public static Result<Grid> Create(string gridDetails)
{
var parts = gridDetails.Split(" ");
var hasX = int.TryParse(parts.FirstOrDefault(), out var gridX);
var hasY = int.TryParse(parts.LastOrDefault(), out var gridY);
if (!hasX || !hasY || gridX == 0 && gridY == 0)
{
return Result<Grid>.Failure(Error.InvalidGridSize);
}
var minimumBoundaries = new Coordinate(0, 0);
var maximumBoundaries = new Coordinate(gridX, gridY);
return Result<Grid>.Success(new Grid(minimumBoundaries, maximumBoundaries));
}
public Result AddRover(Rover rover, Movement[] movements)
{
if (_rovers.Any(r => r.Coordinate.Equals(rover.Coordinate)))
{
return Result.Failure(Error.RoverExists(rover.Id));
}
if (rover.Coordinate.IsWithin(_minimumBoundaries, _maximumBoundaries) == false)
{
return Result.Failure(Error.OutOfBoundRover(rover.Id));
}
var obstacles = _rovers
.Where(r => r.Id != rover.Id)
.Select(r => r.Coordinate).ToArray();
var moveResult = rover.Move(movements, obstacles, _minimumBoundaries, _maximumBoundaries);
if (moveResult.IsSuccess == false)
{
return moveResult;
}
_rovers.Add(moveResult.Value);
return Result.Success();
}
public string[] GetRoverStatuses()
{
return _rovers.Select(r => r.ToString()).ToArray();
}
}
Tests:
public class MessageParserTests
{
private readonly MessageParser _sut;
public MessageParserTests()
{
_sut = new MessageParser();
}
[Fact]
public void WhenMessageIsValid_InitializesGrid()
{
var input = Lines(
"5 5",
"1 2 N",
"LMLMLMLMM",
"3 3 E",
"MMRMMRMRRM");
var output = _sut.HandleMessage(input);
output.Should().Be(Lines(
"1 3 N",
"5 1 E"));
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData(null)]
public void WhenMessageIsNullOrEmpty_ReturnsErrorCode(string input)
{
var output = _sut.HandleMessage(input);
output.Should().Be("error.message.missing");
}
[Fact]
public void WhenMessageHasMissingGridSize_ReturnsErrorCode()
{
var input = Lines(
"1 2 N",
"LMLMLMLMM",
"3 3 E",
"MMRMMRMRRM");
var output = _sut.HandleMessage(input);
output.Should().Be("error.message.invalid");
}
[Fact]
public void WhenMessageGridSizeTooSmall_ReturnsErrorCode()
{
var input = Lines(
"0 0",
"1 2 N",
"LMLMLMLMM",
"3 3 E",
"MMRMMRMRRM");
var output = _sut.HandleMessage(input);
output.Should().Be("error.grid.size.invalid");
}
[Fact]
public void WhenMessageHasInvalidXForGridSize_ReturnsErrorCode()
{
var input = Lines(
"x 5",
"1 2 N",
"LMLMLMLMM",
"3 3 E",
"MMRMMRMRRM");
var output = _sut.HandleMessage(input);
output.Should().Be("error.message.invalid");
}
[Fact]
public void WhenMessageHasInvalidYForGridSize_ReturnsErrorCode()
{
var input = Lines(
"x 5",
"1 2 N",
"LMLMLMLMM",
"3 3 E",
"MMRMMRMRRM");
var output = _sut.HandleMessage(input);
output.Should().Be("error.message.invalid");
}
[Fact]
public void WhenMessageHasNegativeGridSize_ReturnsErrorCode()
{
var input = Lines(
"5 -5",
"1 2 N",
"LMLMLMLMM",
"3 3 E",
"MMRMMRMRRM");
var output = _sut.HandleMessage(input);
output.Should().Be("error.message.invalid");
}
[Fact]
public void WhenMessageHasMissingFirstRoverCoordinates_ReturnsErrorCode()
{
var input = Lines(
"5 5",
"LMLMLMLMM",
"3 3 E",
"MMRMMRMRRM");
var output = _sut.HandleMessage(input);
output.Should().Be("error.message.invalid");
}
[Fact]
public void WhenMessageHasOutOfBoundFirstRoverCoordinates_ReturnsErrorCode()
{
var input = Lines(
"5 5",
"6 2 N",
"LMLMLMLMM",
"3 3 E",
"MMRMMRMRRM");
var output = _sut.HandleMessage(input);
output.Should().Be("error.rover.[6-2-N].out.of.bound");
}
[Fact]
public void WhenMessageHasMissingFirstRoverMovements_ReturnsErrorCode()
{
var input = Lines(
"5 5",
"1 2 N",
"3 3 E",
"MMRMMRMRRM");
var output = _sut.HandleMessage(input);
output.Should().Be("error.message.invalid");
}
[Fact]
public void WhenMessageHasInvalidXForFirstRoverCoordinates_ReturnsErrorCode()
{
var input = Lines(
"5 5",
"X 2 N",
"LMLMLMLMM",
"3 3 E",
"MMRMMRMRRM");
var output = _sut.HandleMessage(input);
output.Should().Be("error.message.invalid");
}
[Fact]
public void WhenMessageHasInvalidYForFirstRoverCoordinates_ReturnsErrorCode()
{
var input = Lines(
"5 5",
"1 Y N",
"LMLMLMLMM",
"3 3 E",
"MMRMMRMRRM");
var output = _sut.HandleMessage(input);
output.Should().Be("error.message.invalid");
}
[Fact]
public void WhenMessageHasInvalidDirectionForFirstRoverCoordinates_ReturnsErrorCode()
{
var input = Lines(
"5 5",
"1 2 9",
"LMLMLMLMM",
"3 3 E",
"MMRMMRMRRM");
var output = _sut.HandleMessage(input);
output.Should().Be("error.message.invalid");
}
[Fact]
public void WhenMessageHasMissingSecondRoverCoordinates_ReturnsErrorCode()
{
var input = Lines(
"5 5",
"1 2 N",
"LMLMLMLMM",
"MMRMMRMRRM");
var output = _sut.HandleMessage(input);
output.Should().Be("error.message.invalid");
}
[Fact]
public void WhenMessageHasOutOfBoundSecondRoverCoordinates_ReturnsErrorCode()
{
var input = Lines(
"5 5",
"1 2 N",
"LMLMLMLMM",
"3 6 E",
"MMRMMRMRRM");
var output = _sut.HandleMessage(input);
output.Should().Be("error.rover.[3-6-E].out.of.bound");
}
[Fact]
public void WhenMessageHasInvalidXForSecondRoverCoordinates_ReturnsErrorCode()
{
var input = Lines(
"5 5",
"1 2 N",
"LMLMLMLMM",
"X 3 E",
"MMRMMRMRRM");
var output = _sut.HandleMessage(input);
output.Should().Be("error.message.invalid");
}
[Fact]
public void WhenMessageHasInvalidYForSecondRoverCoordinates_ReturnsErrorCode()
{
var input = Lines(
"5 5",
"1 2 N",
"LMLMLMLMM",
"3 Y E",
"MMRMMRMRRM");
var output = _sut.HandleMessage(input);
output.Should().Be("error.message.invalid");
}
[Fact]
public void WhenMessageHasInvalidDirectionForSecondRoverCoordinates_ReturnsErrorCode()
{
var input = Lines(
"5 5",
"1 2 N",
"LMLMLMLMM",
"3 3 2",
"MMRMMRMRRM");
var output = _sut.HandleMessage(input);
output.Should().Be("error.message.invalid");
}
[Fact]
public void WhenMessageHasMissingSecondRoverMovements_ReturnsErrorCode()
{
var input = Lines(
"5 5",
"1 2 N",
"LMLMLMLMM",
"3 3 E");
var output = _sut.HandleMessage(input);
output.Should().Be("error.message.invalid");
}
[Fact]
public void WhenMessageHasOverlappingRovers_ReturnsErrorCode()
{
var input = Lines(
"5 5",
"1 2 N",
"L",
"1 2 N",
"R");
var output = _sut.HandleMessage(input);
output.Should().Be("error.rover.[1-2-N].exists");
}
[Fact]
public void WhenMessageCausesRoversToCrash_ReturnsErrorCode()
{
var input = Lines(
"5 5",
"1 2 N",
"L",
"1 1 N",
"M");
var output = _sut.HandleMessage(input);
output.Should().Be("error.rover.[1-1-N].met.obstacle.[1,2]");
}
[Fact]
public void WhenMessageCausesRoverToGoOffGrid_ReturnsErrorCode()
{
var input = Lines(
"5 5",
"1 2 N",
"MMMM",
"1 1 N",
"M");
var output = _sut.HandleMessage(input);
output.Should().Be("error.rover.[1-2-N].out.of.bound");
}
private static string Lines(params string[] parts)
{
return string.Join(Environment.NewLine, parts);
}
}