Last active
July 17, 2023 20:11
-
-
Save HacKanCuBa/68d457f9ecdf8c89efcde555bf9f0731 to your computer and use it in GitHub Desktop.
Logo (programming language) turtle excercise
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
"""Logo (programming language) turtle exercise.""" | |
import typing | |
import unittest | |
from dataclasses import dataclass | |
from enum import Enum | |
from itertools import zip_longest | |
from unittest import TestCase | |
class OutOfBoundsError(ValueError): | |
"""A turtle is out of bounds of the map.""" | |
class CollisionError(ValueError): | |
"""A turtle collided against another.""" | |
class Direction(str, Enum): | |
N = "N" | |
S = "S" | |
E = "E" | |
W = "W" | |
@dataclass(frozen=True) | |
class Coordinate: | |
x: int | |
y: int | |
def __add__(self, other: "Coordinate") -> "Coordinate": | |
x = self.x + other.x | |
y = self.y + other.y | |
return Coordinate(x, y) | |
class Turn(str, Enum): | |
L = "L" | |
R = "R" | |
class Instruction(str, Enum): | |
L = "L" | |
R = "R" | |
M = "M" | |
@dataclass(frozen=True) | |
class Position: | |
coord: Coordinate | |
direction: Direction | |
class Map(typing.TypedDict): | |
N: int # + | |
S: int # - | |
E: int # + | |
W: int # - | |
class Turtle: | |
def __init__( | |
self, | |
initial_position: Position = Position(Coordinate(0, 0), Direction.N), | |
*, | |
map_: typing.Optional[Map] = None, | |
name: str = "", | |
) -> None: | |
self._initial = initial_position | |
self._map = map_ | |
self._name = name | |
self._coord = Coordinate(self._initial.coord.x, self._initial.coord.y) | |
self._direction = self._initial.direction | |
def __repr__(self) -> str: | |
return ( | |
f"{type(self).__name__}" | |
+ f"(name={self.name}, map={self._map}, position={self.position!r})" | |
) | |
def __str__(self): | |
return self.name or repr(self) | |
@property | |
def position(self) -> Position: | |
return Position(self._coord, self._direction) | |
@property | |
def location(self) -> Coordinate: | |
return self._coord | |
@property | |
def direction(self) -> Direction: | |
return self._direction | |
@property | |
def name(self) -> str: | |
return self._name | |
def _validate_position(self) -> None: | |
if self._map is None: | |
return | |
out_of_bounds = any( | |
( | |
self._coord.x > self._map["E"], | |
self._coord.x < self._map["W"], | |
self._coord.y > self._map["N"], | |
self._coord.y < self._map["S"], | |
) | |
) | |
if out_of_bounds: | |
raise OutOfBoundsError( | |
f"current position is out of bounds: {self.position} (bounds: {self._map})" | |
) | |
def move(self) -> None: | |
"""Move turtle forward.""" | |
direction_to_movement = { | |
Direction.N: Coordinate(0, 1), | |
Direction.S: Coordinate(0, -1), | |
Direction.E: Coordinate(1, 0), | |
Direction.W: Coordinate(-1, 0), | |
} | |
self._coord = self._coord + direction_to_movement[self._direction] | |
self._validate_position() | |
def turn(self, to: Turn) -> None: | |
"""Turn to given direction, without displacement.""" | |
turn_to_direction = { | |
Turn.L: { | |
Direction.N: Direction.W, | |
Direction.S: Direction.E, | |
Direction.E: Direction.N, | |
Direction.W: Direction.S, | |
}, | |
Turn.R: { | |
Direction.N: Direction.E, | |
Direction.S: Direction.W, | |
Direction.E: Direction.S, | |
Direction.W: Direction.N, | |
}, | |
} | |
self._direction = turn_to_direction[to][self._direction] | |
def do(self, instruction: Instruction) -> None: | |
"""Do a single action.""" | |
if instruction == Instruction.M: | |
self.move() | |
else: | |
self.turn(Turn(instruction)) | |
def execute(self, instructions: typing.Sequence[typing.Union[Instruction, str]]) -> None: | |
"""Execute given instructions, one after the other.""" | |
for instruction in instructions: | |
self.do(Instruction(instruction)) | |
def reset(self) -> None: | |
# Copy coords because it is mutable! | |
self._coord = Coordinate(self._initial.coord.x, self._initial.coord.y) | |
self._direction = self._initial.direction | |
@dataclass | |
class Turtles: | |
turtles: typing.Tuple[Turtle, ...] | |
@property | |
def positions(self) -> typing.Tuple[Position, ...]: | |
return tuple(turtle.position for turtle in self.turtles) | |
def check_collision(self) -> bool: | |
"""Return True if there's a collision, False otherwise.""" | |
locations: typing.Set[Coordinate] = {turtle.location for turtle in self.turtles} | |
return len(self.turtles) != len(locations) | |
def execute( | |
self, | |
instructions: typing.Sequence[typing.Sequence[typing.Union[Instruction, str]]], | |
) -> typing.Tuple[Position, ...]: | |
"""Execute instructions on every turtle, one after the other. | |
Note that collisions are not considered. | |
""" | |
for steps in zip_longest(*instructions): | |
for turtle, step in zip(self.turtles, steps): | |
if step is None: | |
continue | |
turtle.do(Instruction(step)) | |
if self.check_collision(): | |
raise CollisionError(f"the turtle {turtle} has collided") | |
return self.positions | |
class TurtleTests(TestCase): | |
def test_movement_works(self) -> None: | |
initial = Position(Coordinate(0, 0), Direction.N) | |
turtle = Turtle(initial) | |
turtle.move() | |
turtle.move() | |
self.assertEqual(Position(Coordinate(0, 2), Direction.N), turtle.position) | |
def test_execute_works(self) -> None: | |
initial = Position(Coordinate(0, 0), Direction.N) | |
turtle = Turtle(initial) | |
turtle.execute("MMLLM") | |
self.assertEqual(Position(Coordinate(0, 1), Direction.S), turtle.position) | |
def test_out_of_bounds(self) -> None: | |
initial = Position(Coordinate(0, 0), Direction.N) | |
map_ = Map( | |
N=2, | |
S=0, | |
E=2, | |
W=0, | |
) | |
turtle = Turtle(initial, map_=map_) | |
instructions_to_raise = { | |
"N": "MMM", | |
"S": "LLM", | |
"E": "RMMM", | |
"W": "LM", | |
} | |
for direction in map_: | |
with self.subTest("Out of bound raises exception", direction=direction): | |
turtle.reset() | |
with self.assertRaises(OutOfBoundsError): | |
turtle.execute(instructions_to_raise[direction]) | |
class TurtlesTests(TestCase): | |
def test_several_turtles_moving(self) -> None: | |
map_ = Map( | |
N=3, | |
S=-3, | |
E=3, | |
W=-3, | |
) | |
initial_positions = ( | |
Position(Coordinate(0, 0), Direction.N), | |
Position(Coordinate(-2, 1), Direction.E), | |
Position(Coordinate(1, 1), Direction.W), | |
Position(Coordinate(3, -1), Direction.S), | |
) | |
instructions = ( | |
"RMMR", | |
"LMML", | |
"MMLLM", | |
"RMLMLRL", | |
) | |
expected = ( | |
Position(Coordinate(2, 0), Direction.S), | |
Position(Coordinate(-2, 3), Direction.W), | |
Position(Coordinate(0, 1), Direction.E), | |
Position(Coordinate(2, -2), Direction.E), | |
) | |
turtles = Turtles( | |
tuple(Turtle(position, map_=map_) for position in initial_positions), | |
) | |
result = turtles.execute(instructions) | |
self.assertEqual(expected, result) | |
def test_several_turtles_out_of_bounds(self) -> None: | |
map_ = Map( | |
N=2, | |
S=-2, | |
E=2, | |
W=-2, | |
) | |
initial_positions = ( | |
Position(Coordinate(1, 1), Direction.S), # ends in 1,1,N | |
Position(Coordinate(1, 2), Direction.E), # ends in 3,2,E | |
) | |
instructions = ( | |
"LL", | |
"MM", | |
) | |
turtles = Turtles( | |
tuple(Turtle(position, map_=map_) for position in initial_positions), | |
) | |
with self.assertRaises(OutOfBoundsError): | |
turtles.execute(instructions) | |
def test_several_turtles_colliding(self) -> None: | |
map_ = Map( | |
N=3, | |
S=-3, | |
E=3, | |
W=-3, | |
) | |
initial_positions = ( | |
Position(Coordinate(0, 0), Direction.N), | |
Position(Coordinate(-2, 1), Direction.E), | |
) | |
instructions = ( | |
"M", | |
"MM", | |
) | |
turtles = Turtles( | |
tuple(Turtle(position, map_=map_) for position in initial_positions), | |
) | |
with self.assertRaises(CollisionError): | |
turtles.execute(instructions) | |
if __name__ == "__main__": | |
unittest.main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment