diff --git a/FEEDBACK.md b/FEEDBACK.md index a0dfb71..049e1cd 100644 --- a/FEEDBACK.md +++ b/FEEDBACK.md @@ -1,34 +1,34 @@ -# Feedback - -Given 21/10/2023. - -## Original Feedback - -What was great: -1. A lot of comments, show excellent rigor. There are many people with many differing opinions on the value of comments, but I think in this instance it demonstrates a good professional practice. -1. It works. -1. Includes tests AND examples as well as a way to parse examples into the runtime of the robot. Nice! - - -What could have been better: -1. robot.set_limits doesn’t appear to be necessary, also doesn’t check that the mutation of the robot state does not invalidate the robot placement. -2. robot.get_limits doesn’t appear to be necessary for the purposes of the exercise, and is currently unused. -3. Movement vectors is a good design decision imo. I think I did the same thing, it converts to semantics of direction to board placement. -4. Good use of specifications to return from an invalid request on movement -5. Rotation functions seems sensible and use modulus function to cycle within the domain of directions. -6. The interpret_command function seems sensible, although the specifications do not require cases insensitivity. Another oddity is that it appears it is only when an invalid PLACE command it attempted, the user is presented with text for valid commands, rather than a default case for the command switch statement. The requirement don’t include informing users of valid commands. -7. The string representation of the robot state, use for describing its position should probably not return “uninitialised” if there is no board state, I believe that this is addressed in the requirements. -8. The test structure could use a little more rigor. BDD and AAA practices are pretty universal. One of the main problems of a test is communicating what functionality you are proving, and often we want to evaluate that against relevant requirement specifications. Naming which communicates which requirement it is proving is very helpful. -9. The examples and the tests execute as expected but there is not implementation of the main program loop. Part of the requirements is to take user input. - -## Initial Response/Notes - -1. Fair, and also a logical error/omission on my part. These functions were added with the mindset of more complete get/setter coverage, but I agree they're not needed for this problem. Removed. -2. As for (1) -3. N/A (but thanks) -4. N/A -5. N/A -6. Case sensitivity *was* implied by the examples, but it was not explicitly clarified one way or the other. Case insensivity was easy to implement and seemed to represent the most "open" interpretation of the requirements, or so I thought. -7. Agree that if one considers a "REPORT" command to be "invalid" if the board is uninitialized, then it should simply be discarded as per the requirements. I'm willing to be persuaded otherwise, but I believe my line of thinking at the time was that REPORT is not an invalid *command*, but called on an invalid *state*, so it should behave sensibly in that setting. -8. Totally fair, the test structure is definitely quite *ad hoc*. Honestly formalised testing is one of my weak areas, I suppose a consequence of most of my experience coming from a research context. I will look more closely at BDD and AAA, thank you. -9. It would be easy enough to implement this, although there is no interactive input loop mentioned in the requirements I was working off. The README shows the version of the requirements the solution is based off. +# Feedback + +Given 21/10/2023. + +## Original Feedback + +What was great: +1. A lot of comments, show excellent rigor. There are many people with many differing opinions on the value of comments, but I think in this instance it demonstrates a good professional practice. +1. It works. +1. Includes tests AND examples as well as a way to parse examples into the runtime of the robot. Nice! + + +What could have been better: +1. robot.set_limits doesn’t appear to be necessary, also doesn’t check that the mutation of the robot state does not invalidate the robot placement. +2. robot.get_limits doesn’t appear to be necessary for the purposes of the exercise, and is currently unused. +3. Movement vectors is a good design decision imo. I think I did the same thing, it converts to semantics of direction to board placement. +4. Good use of specifications to return from an invalid request on movement +5. Rotation functions seems sensible and use modulus function to cycle within the domain of directions. +6. The interpret_command function seems sensible, although the specifications do not require cases insensitivity. Another oddity is that it appears it is only when an invalid PLACE command it attempted, the user is presented with text for valid commands, rather than a default case for the command switch statement. The requirement don’t include informing users of valid commands. +7. The string representation of the robot state, use for describing its position should probably not return “uninitialised” if there is no board state, I believe that this is addressed in the requirements. +8. The test structure could use a little more rigor. BDD and AAA practices are pretty universal. One of the main problems of a test is communicating what functionality you are proving, and often we want to evaluate that against relevant requirement specifications. Naming which communicates which requirement it is proving is very helpful. +9. The examples and the tests execute as expected but there is not implementation of the main program loop. Part of the requirements is to take user input. + +## Initial Response/Notes + +1. Fair, and also a logical error/omission on my part. These functions were added with the mindset of more complete get/setter coverage, but I agree they're not needed for this problem. Removed. +2. As for (1) +3. N/A (but thanks) +4. N/A +5. N/A +6. Case sensitivity *was* implied by the examples, but it was not explicitly clarified one way or the other. Case insensivity was easy to implement and seemed to represent the most "open" interpretation of the requirements, or so I thought. +7. Agree that if one considers a "REPORT" command to be "invalid" if the board is uninitialized, then it should simply be discarded as per the requirements. I'm willing to be persuaded otherwise, but I believe my line of thinking at the time was that REPORT is not an invalid *command*, but called on an invalid *state*, so it should behave sensibly in that setting. +8. Totally fair, the test structure is definitely quite *ad hoc*. Honestly formalised testing is one of my weak areas, I suppose a consequence of most of my experience coming from a research context. I will look more closely at BDD and AAA, thank you. +9. It would be easy enough to implement this, although there is no interactive input loop mentioned in the requirements I was working off. The README shows the version of the requirements the solution is based off. diff --git a/README.md b/README.md index 919c0b7..c6c569e 100644 --- a/README.md +++ b/README.md @@ -1,111 +1,111 @@ -# Toy Robot Simulator - -## Problem Statement - -Initial problem description taken from https://github.com/xandeep/ToyRobot. - -Create a library that can read in commands of the following form: - -``` -PLACE X,Y,DIRECTION -MOVE -LEFT -RIGHT -REPORT -``` - -*The library allows for a simulation of a toy robot moving on a 6 x 6 square tabletop.* - -1. There are no obstructions on the table surface. -2. The robot is free to roam around the surface of the table, but must be prevented from falling to destruction. Any movement that would result in this must be prevented, however further valid movement commands must still be allowed. -3. PLACE will put the toy robot on the table in position X,Y and facing NORTH, SOUTH, EAST or WEST. -4. (0,0) can be considered as the SOUTH WEST corner and (5,5) as the NORTH EAST corner. -5. The first valid command to the robot is a PLACE command. After that, any sequence of commands may be issued, in any order, including another PLACE command. The library should discard all commands in the sequence until a valid PLACE command has been executed. -6. The PLACE command should be discarded if it places the robot outside of the table surface. -7. Once the robot is on the table, subsequent PLACE commands could leave out the direction and only provide the coordinates. When this happens, the robot moves to the new coordinates without changing the direction. -8. MOVE will move the toy robot one unit forward in the direction it is currently facing. -9. LEFT and RIGHT will rotate the robot 90 degrees in the specified direction without changing the position of the robot. -10. REPORT will announce the X,Y and orientation of the robot. -11. A robot that is not on the table can choose to ignore the MOVE, LEFT, RIGHT and REPORT commands. -12. The library should discard all invalid commands and parameters. - -### Example Input and Output: - -``` -a) -PLACE 0,0,NORTH -MOVE -REPORT -Output: 0,1,NORTH -``` - -``` -b) -PLACE 0,0,NORTH -LEFT -REPORT -Output: 0,0,WEST -``` - -``` -c) -PLACE 1,2,EAST -MOVE -MOVE -LEFT -MOVE -REPORT -Output: 3,3,NORTH -``` - -``` -d) -PLACE 1,2,EAST -MOVE -LEFT -MOVE -PLACE 3,1 -MOVE -REPORT -Output: 3,2,NORTH -``` - -### Design - -This is a relatively simple programming problem that one might expect to find as part of an introductory tertiary programming course. It focusses mainly on data representation, state tracking and modification, constraint checking, and some text processing in a text-based environment. Error handling is de-emphasised as most invalid inputs are discarded and one may assume that initial invalid states are not considered. File handling is not required, though it has been used in this implementation for static test data. - -Initial thoughts on approach included the use of a basic state machine, but movement and coordinate tracking don't lend themselves neatly to a state machine architecture, and so a more tradaitional encapsulated state was used instead. - -The following additional considerations were made: - -- Command-line Python was chosen as the language of choice simply due to my familiarity with it. This problem could likely be implemented in any language or environment that supports an equivalent concept of "library", including functional languages. -- The library (module in Python terminology) `toyrobot` contains just one class, `Robot`, that is aware of both its movement limits (configurable, but defaults to the 6x6 space in the original description) and can also interpret text commands. This monolithic design is appropriate given the simplicity of the problem as provided, but further separation of concerns may be desirable in a more complicated context. For instance, if the problem were to require tracking of more than one robot, it may make sense to abstract out the movement domain and command interpreter as independent entities. -- As per the spirit of the description, almost all methods are designed to "fail silently" (i.e. leave the Robot state unchanged) if they are invalid or are given invalid inputs. The one exception to this is the ``__init__`` constructor - if numerically invalid maximums are provided for movement limits then a `ValueError` exception is thrown. This ensures that a Robot instance cannot exist with an invalid initial state or configuration. - -## Installation and Pre-requisites - -Python version 3.10 or above is required due to the use of the `match/case` construct. - -No external libraries are used, and a Python virtual environment is not necessary. - -## Usage - -To run the examples listed in the problem description: - -``` -python trexamples.py -``` - -## Unit Tests - -A number of basic unit tests are included using the internal Python library `unittest`. These are not exhaustive, but do provide testing of the expected behaviour of the essential methods of the `Robot` class. For ease of reference these are all included in a single class, although it is worth noting that as per the `unittest` [documentation](https://docs.python.org/3/library/unittest.html) many of these should technically be in separate classes with individually specified "setup" and "takedown" code. - -To run the tests, use the command: - -``` -python -m unittest -v trtests.py -``` - -### Author - -Chris Davoren, 2023, cdavoren@gmail.com +# Toy Robot Simulator + +## Problem Statement + +Initial problem description taken from https://github.com/xandeep/ToyRobot. + +Create a library that can read in commands of the following form: + +``` +PLACE X,Y,DIRECTION +MOVE +LEFT +RIGHT +REPORT +``` + +*The library allows for a simulation of a toy robot moving on a 6 x 6 square tabletop.* + +1. There are no obstructions on the table surface. +2. The robot is free to roam around the surface of the table, but must be prevented from falling to destruction. Any movement that would result in this must be prevented, however further valid movement commands must still be allowed. +3. PLACE will put the toy robot on the table in position X,Y and facing NORTH, SOUTH, EAST or WEST. +4. (0,0) can be considered as the SOUTH WEST corner and (5,5) as the NORTH EAST corner. +5. The first valid command to the robot is a PLACE command. After that, any sequence of commands may be issued, in any order, including another PLACE command. The library should discard all commands in the sequence until a valid PLACE command has been executed. +6. The PLACE command should be discarded if it places the robot outside of the table surface. +7. Once the robot is on the table, subsequent PLACE commands could leave out the direction and only provide the coordinates. When this happens, the robot moves to the new coordinates without changing the direction. +8. MOVE will move the toy robot one unit forward in the direction it is currently facing. +9. LEFT and RIGHT will rotate the robot 90 degrees in the specified direction without changing the position of the robot. +10. REPORT will announce the X,Y and orientation of the robot. +11. A robot that is not on the table can choose to ignore the MOVE, LEFT, RIGHT and REPORT commands. +12. The library should discard all invalid commands and parameters. + +### Example Input and Output: + +``` +a) +PLACE 0,0,NORTH +MOVE +REPORT +Output: 0,1,NORTH +``` + +``` +b) +PLACE 0,0,NORTH +LEFT +REPORT +Output: 0,0,WEST +``` + +``` +c) +PLACE 1,2,EAST +MOVE +MOVE +LEFT +MOVE +REPORT +Output: 3,3,NORTH +``` + +``` +d) +PLACE 1,2,EAST +MOVE +LEFT +MOVE +PLACE 3,1 +MOVE +REPORT +Output: 3,2,NORTH +``` + +### Design + +This is a relatively simple programming problem that one might expect to find as part of an introductory tertiary programming course. It focusses mainly on data representation, state tracking and modification, constraint checking, and some text processing in a text-based environment. Error handling is de-emphasised as most invalid inputs are discarded and one may assume that initial invalid states are not considered. File handling is not required, though it has been used in this implementation for static test data. + +Initial thoughts on approach included the use of a basic state machine, but movement and coordinate tracking don't lend themselves neatly to a state machine architecture, and so a more tradaitional encapsulated state was used instead. + +The following additional considerations were made: + +- Command-line Python was chosen as the language of choice simply due to my familiarity with it. This problem could likely be implemented in any language or environment that supports an equivalent concept of "library", including functional languages. +- The library (module in Python terminology) `toyrobot` contains just one class, `Robot`, that is aware of both its movement limits (configurable, but defaults to the 6x6 space in the original description) and can also interpret text commands. This monolithic design is appropriate given the simplicity of the problem as provided, but further separation of concerns may be desirable in a more complicated context. For instance, if the problem were to require tracking of more than one robot, it may make sense to abstract out the movement domain and command interpreter as independent entities. +- As per the spirit of the description, almost all methods are designed to "fail silently" (i.e. leave the Robot state unchanged) if they are invalid or are given invalid inputs. The one exception to this is the ``__init__`` constructor - if numerically invalid maximums are provided for movement limits then a `ValueError` exception is thrown. This ensures that a Robot instance cannot exist with an invalid initial state or configuration. + +## Installation and Pre-requisites + +Python version 3.10 or above is required due to the use of the `match/case` construct. + +No external libraries are used, and a Python virtual environment is not necessary. + +## Usage + +To run the examples listed in the problem description: + +``` +python trexamples.py +``` + +## Unit Tests + +A number of basic unit tests are included using the internal Python library `unittest`. These are not exhaustive, but do provide testing of the expected behaviour of the essential methods of the `Robot` class. For ease of reference these are all included in a single class, although it is worth noting that as per the `unittest` [documentation](https://docs.python.org/3/library/unittest.html) many of these should technically be in separate classes with individually specified "setup" and "takedown" code. + +To run the tests, use the command: + +``` +python -m unittest -v trtests.py +``` + +### Author + +Chris Davoren, 2023, cdavoren@gmail.com diff --git a/toyrobot/__init__.py b/toyrobot/__init__.py index 3a54583..2a60596 100644 --- a/toyrobot/__init__.py +++ b/toyrobot/__init__.py @@ -1,3 +1,3 @@ -# __init__.py - -from .robot import Robot +# __init__.py + +from .robot import Robot diff --git a/toyrobot/robot.py b/toyrobot/robot.py index 952f13d..3827d32 100644 --- a/toyrobot/robot.py +++ b/toyrobot/robot.py @@ -1,349 +1,349 @@ -class Robot: - """ - Class representing a single "Toy Robot". Robots instances have knowledge - of their position, direction, and movement limits (i.e. "board" size). - - Note: Direction is specified via compass points (north, west, etc). As per - the original problem description, the coordinates (0, 0) represent the - SOUTHWEST corner of the board. - - Attributes: - DEFAULT_MAX_X (int): The default maximum allowable horizontal - coordinate (inclusive). - DEFAULT_MAX_Y (int): The default maximum allowable vertical coordinate - (inclusive). - DIRECTIONS (dict): A dictionary (str: int) of direction names (e.g. - NORTH, EAST etc) and their numerical encoding. Only the keys are - intended for use externally as a list of valid directions. - VALID_COMMANDS (list): A list of valid commands that can be interpreted - by the robot. The PLACE command requires at least two additional - parameters. See the complete problem description for more details. - """ - - DEFAULT_MAX_X = 5 - DEFAULT_MAX_Y = 5 - - # Directions ordered such that adding 1 corresponds to RIGHT turn - DIRECTIONS = { - "NORTH": 0, - "NORTHEAST": 1, - "EAST": 2, - "SOUTHEAST": 3, - "SOUTH": 4, - "SOUTHWEST": 5, - "WEST": 6, - "NORTHWEST": 7, - } - - VALID_COMMANDS = ["BLOCK", "PLACE", "MOVE", "LEFT", "RIGHT", "REPORT"] - - # Private internals - # Key corresponds to numerical direction defined in Robot.directions - # Value is an offset vector in with x and y keys (positive x is NORTH, - # positive y is EAST) - _MOVEMENT_VECTORS = { - 0: {"x": 0, "y": 1}, - 1: {"x": 1, "y": 1}, - 2: {"x": 1, "y": 0}, - 3: {"x": 1, "y": -1}, - 4: {"x": 0, "y": -1}, - 5: {"x": -1, "y": -1}, - 6: {"x": -1, "y": 0}, - 7: {"x": -1, "y": 1}, - } - - def __init__(self, max_x: int = DEFAULT_MAX_X, max_y: int = DEFAULT_MAX_Y): - """ - Creates a new Robot instance with the specified position limits - (optional). Implicitly, the minimum limit on coordinates is 0 both - horizontally and vertically. - - See the class attributes `DEFAULT_MAX_X` and `DEFAULT_MAX_Y` for the - respective default numerical values. - - Args: - max_x (int): The maximum allowable horizontal coordinate - (inclusive, positive is EAST). - max_y (int): The maximum allowable vertical coordinate - (inclusive, positive is NORTH). - - Raises: - ValueError: If max_x or max_y are less than 0. - """ - - if max_x < 0 or max_y < 0: - raise ValueError("Cannot specify negative limits") - - self._max_x = max_x - self._max_y = max_y - - self._position_x = None - self._position_y = None - self._direction = None - - self._blocks = [] - - def valid_position(self, x: int, y: int) -> bool: - """ - Calculates whether the given coordinates are valid for the limits set - on this Robot instance. - - This function is used by the `move()` function to verify that a move - action would be successful. - - Args: - x: Proposed horizontal coordinate. - y: Proposed vertical coordinate. - - Returns: - bool: True if given coordinates are within limits, otherwise False. - """ - - within_limits = x >= 0 and y >= 0 and x <= self._max_x and y <= self._max_y - on_block = (x, y) in self._blocks - # print(self._blocks) - # print(within_limits) - # print(on_block) - return within_limits and not on_block - - def add_block(self, x: int, y: int): - if x == self._position_x and y == self._position_y: - return - - self._blocks.append((x, y)) - - def is_initialized(self): - """ - Returns whether this Robot instance has been initialized (i.e. whether - a valid `place()` command has been executed). - - This function is used by the `move()` function to verify that the - instance has been correctly placed before movement. - - Returns: - bool: True if correctly initialized, otherwise false. - """ - return ( - self._position_x is not None - and self._position_y is not None - and self._direction is not None - ) - - def move(self): - """ - Moves the robot one space in the direction it is currently facing, - provided that said movement would be within limits. - - Will do nothing (fail silently) if this Robot instance has not been - initialized successfully with `place()` or the specified movement would - be out of bounds. - """ - - if not self.is_initialized(): - return - - vector = Robot._MOVEMENT_VECTORS[self._direction] - new_position_x = self._position_x + vector["x"] - new_position_y = self._position_y + vector["y"] - - if not self.valid_position(new_position_x, new_position_y): - return - - self._position_x = new_position_x - self._position_y = new_position_y - - def place(self, position_x: int, position_y: int, direction_name: str = None): - """ - Places the Robot instance at the specified coordinates with the - specified direction. - - Will do nothing if the given coordinates are out of bounds, or the - direction is not one of the keys in `Robot.DIRECTIONS`. - - See `__init__()` for detailed coordinate information. - - Args: - position_x (int): Horizontal coordinate for placement. - position_y (int): Vertical coordinate for placement. - direction_name (str): Direction of placement; must be one of the - string keys in `Robot.DIRECTIONS`. Must be specified on first - placement (or this call will fail silently), but is optional - thereafter. - """ - - # Must be careful not to make any state changes until all inputs - # have been validated - # print('Checking position...') - if not self.valid_position(position_x, position_y): - return - # print('Position check passed.') - - if direction_name is not None: - direction_name = direction_name.upper() - if direction_name not in Robot.DIRECTIONS.keys(): - return - elif not self.is_initialized(): - # Direction MUST be specified on first placement - return - - if direction_name is not None: - self._direction = Robot.DIRECTIONS[direction_name] - self._position_x = position_x - self._position_y = position_y - - def get_position(self) -> (int, int): - """ - Returns the current position of this Robot instance. - - Returns: - (int, int): A tuple with this instance's current coordinates in the - order (position_x, position_y), or (None, None) if this - instance has not yet been initialized. - """ - - return ( - (self._position_x, self._position_y) - if self.is_initialized() - else (None, None) - ) - - def get_direction(self) -> str: - """ - Returns the direction in which this instance is currently facing in - string form. - - Returns: - str: The current direction of this instance; will be one of the - keys of `Robot.DIRECTION`. Will return None if this instance - has not yet been initialized. - """ - - return ( - {v: k for k, v in Robot.DIRECTIONS.items()}[self._direction] - if self.is_initialized() - else None - ) - - def rotate_left(self): - """ - Rotates this Robot instance's direction to the LEFT. For example, if - currently facing NORTH, the new direction will be WEST. - - Does nothing if this instance has not yet been correctly placed - (initialized). - """ - - if not self.is_initialized(): - return - - self._direction = (self._direction - 1) % len(Robot.DIRECTIONS) - - def rotate_right(self): - """ - Rotates this Robot instance's direction to the RIGHT. For example, if - currently facing NORTH, the new direction will be EAST. - - Does nothing if this instance has not yet been correctly placed - (initialized). - """ - - if not self.is_initialized(): - return - - self._direction = (self._direction + 1) % len(Robot.DIRECTIONS) - - def interpret_command(self, command: str): - """ - Interprets a given string command and applies the appropriate - transformation to this Robot instance. Fails silently if the command - is unrecognized or invalid. - - Refer to the full problem description for a list and examples of valid - commands. - - Args: - command (str): The command to be interpreted. - """ - - command = command.upper() - command_tokens = [x.strip() for x in command.split(" ") if len(x) > 0] - - # This is not strictly necessary as case - if len(command_tokens) == 0 or not command_tokens[0] in Robot.VALID_COMMANDS: - return - - match command_tokens[0]: - case "BLOCK": - # print('Found block...') - try: - if len(command_tokens) < 2: - # print('Insufficient tokens...') - return - - parameter_tokens = [x.strip() for x in command_tokens[1].split(",")] - - if len(parameter_tokens) < 2: - # print('Insufficient parameters...') - return - block_x = int(parameter_tokens[0]) - block_y = int(parameter_tokens[1]) - - # print("Adding block: {} {}".format(block_x, block_y)) - self.add_block(block_x, block_y) - except ValueError: - # print('Integer parsing error...') - return - case "PLACE": - try: - # Must have parameters - if len(command_tokens) < 2: - return - parameter_tokens = [x.strip() for x in command_tokens[1].split(",")] - - # Must have at least X, Y - if len(parameter_tokens) < 2: - return - - # Throws ValueError if invalid input - place_x = int(parameter_tokens[0]) - place_y = int(parameter_tokens[1]) - - # Direction parameter is optional on second and subsequent - # placements. The place() method accounts for an absent - # direction on first call and fails silently. - # print("PLACE command parsed: {}, {}".format(place_x, place_y)) - if len(parameter_tokens) > 2: - place_direction = parameter_tokens[2] - self.place(place_x, place_y, place_direction) - else: - self.place(place_x, place_y) - except ValueError: - # Unable to convert x or y token to int - return - case "MOVE": - self.move() - case "LEFT": - self.rotate_left() - case "RIGHT": - self.rotate_right() - case "REPORT": - print("Output: {}".format(str(self))) - - def __str__(self) -> str: - """ - Returns a string representation of the instance including position and - direction. - - Returns: - str: This instance's string representation. - """ - - if not self.is_initialized(): - return "Uninitialized" - - return "{},{},{} | BLOCKS : {}".format( - self._position_x, - self._position_y, - self.get_direction(), - ":".join([str(x) for x in self._blocks]), - ) +class Robot: + """ + Class representing a single "Toy Robot". Robots instances have knowledge + of their position, direction, and movement limits (i.e. "board" size). + + Note: Direction is specified via compass points (north, west, etc). As per + the original problem description, the coordinates (0, 0) represent the + SOUTHWEST corner of the board. + + Attributes: + DEFAULT_MAX_X (int): The default maximum allowable horizontal + coordinate (inclusive). + DEFAULT_MAX_Y (int): The default maximum allowable vertical coordinate + (inclusive). + DIRECTIONS (dict): A dictionary (str: int) of direction names (e.g. + NORTH, EAST etc) and their numerical encoding. Only the keys are + intended for use externally as a list of valid directions. + VALID_COMMANDS (list): A list of valid commands that can be interpreted + by the robot. The PLACE command requires at least two additional + parameters. See the complete problem description for more details. + """ + + DEFAULT_MAX_X = 5 + DEFAULT_MAX_Y = 5 + + # Directions ordered such that adding 1 corresponds to RIGHT turn + DIRECTIONS = { + "NORTH": 0, + "NORTHEAST": 1, + "EAST": 2, + "SOUTHEAST": 3, + "SOUTH": 4, + "SOUTHWEST": 5, + "WEST": 6, + "NORTHWEST": 7, + } + + VALID_COMMANDS = ["BLOCK", "PLACE", "MOVE", "LEFT", "RIGHT", "REPORT"] + + # Private internals + # Key corresponds to numerical direction defined in Robot.directions + # Value is an offset vector in with x and y keys (positive x is NORTH, + # positive y is EAST) + _MOVEMENT_VECTORS = { + 0: {"x": 0, "y": 1}, + 1: {"x": 1, "y": 1}, + 2: {"x": 1, "y": 0}, + 3: {"x": 1, "y": -1}, + 4: {"x": 0, "y": -1}, + 5: {"x": -1, "y": -1}, + 6: {"x": -1, "y": 0}, + 7: {"x": -1, "y": 1}, + } + + def __init__(self, max_x: int = DEFAULT_MAX_X, max_y: int = DEFAULT_MAX_Y): + """ + Creates a new Robot instance with the specified position limits + (optional). Implicitly, the minimum limit on coordinates is 0 both + horizontally and vertically. + + See the class attributes `DEFAULT_MAX_X` and `DEFAULT_MAX_Y` for the + respective default numerical values. + + Args: + max_x (int): The maximum allowable horizontal coordinate + (inclusive, positive is EAST). + max_y (int): The maximum allowable vertical coordinate + (inclusive, positive is NORTH). + + Raises: + ValueError: If max_x or max_y are less than 0. + """ + + if max_x < 0 or max_y < 0: + raise ValueError("Cannot specify negative limits") + + self._max_x = max_x + self._max_y = max_y + + self._position_x = None + self._position_y = None + self._direction = None + + self._blocks = [] + + def valid_position(self, x: int, y: int) -> bool: + """ + Calculates whether the given coordinates are valid for the limits set + on this Robot instance. + + This function is used by the `move()` function to verify that a move + action would be successful. + + Args: + x: Proposed horizontal coordinate. + y: Proposed vertical coordinate. + + Returns: + bool: True if given coordinates are within limits, otherwise False. + """ + + within_limits = x >= 0 and y >= 0 and x <= self._max_x and y <= self._max_y + on_block = (x, y) in self._blocks + # print(self._blocks) + # print(within_limits) + # print(on_block) + return within_limits and not on_block + + def add_block(self, x: int, y: int): + if x == self._position_x and y == self._position_y: + return + + self._blocks.append((x, y)) + + def is_initialized(self): + """ + Returns whether this Robot instance has been initialized (i.e. whether + a valid `place()` command has been executed). + + This function is used by the `move()` function to verify that the + instance has been correctly placed before movement. + + Returns: + bool: True if correctly initialized, otherwise false. + """ + return ( + self._position_x is not None + and self._position_y is not None + and self._direction is not None + ) + + def move(self): + """ + Moves the robot one space in the direction it is currently facing, + provided that said movement would be within limits. + + Will do nothing (fail silently) if this Robot instance has not been + initialized successfully with `place()` or the specified movement would + be out of bounds. + """ + + if not self.is_initialized(): + return + + vector = Robot._MOVEMENT_VECTORS[self._direction] + new_position_x = self._position_x + vector["x"] + new_position_y = self._position_y + vector["y"] + + if not self.valid_position(new_position_x, new_position_y): + return + + self._position_x = new_position_x + self._position_y = new_position_y + + def place(self, position_x: int, position_y: int, direction_name: str = None): + """ + Places the Robot instance at the specified coordinates with the + specified direction. + + Will do nothing if the given coordinates are out of bounds, or the + direction is not one of the keys in `Robot.DIRECTIONS`. + + See `__init__()` for detailed coordinate information. + + Args: + position_x (int): Horizontal coordinate for placement. + position_y (int): Vertical coordinate for placement. + direction_name (str): Direction of placement; must be one of the + string keys in `Robot.DIRECTIONS`. Must be specified on first + placement (or this call will fail silently), but is optional + thereafter. + """ + + # Must be careful not to make any state changes until all inputs + # have been validated + # print('Checking position...') + if not self.valid_position(position_x, position_y): + return + # print('Position check passed.') + + if direction_name is not None: + direction_name = direction_name.upper() + if direction_name not in Robot.DIRECTIONS.keys(): + return + elif not self.is_initialized(): + # Direction MUST be specified on first placement + return + + if direction_name is not None: + self._direction = Robot.DIRECTIONS[direction_name] + self._position_x = position_x + self._position_y = position_y + + def get_position(self) -> (int, int): + """ + Returns the current position of this Robot instance. + + Returns: + (int, int): A tuple with this instance's current coordinates in the + order (position_x, position_y), or (None, None) if this + instance has not yet been initialized. + """ + + return ( + (self._position_x, self._position_y) + if self.is_initialized() + else (None, None) + ) + + def get_direction(self) -> str: + """ + Returns the direction in which this instance is currently facing in + string form. + + Returns: + str: The current direction of this instance; will be one of the + keys of `Robot.DIRECTION`. Will return None if this instance + has not yet been initialized. + """ + + return ( + {v: k for k, v in Robot.DIRECTIONS.items()}[self._direction] + if self.is_initialized() + else None + ) + + def rotate_left(self): + """ + Rotates this Robot instance's direction to the LEFT. For example, if + currently facing NORTH, the new direction will be WEST. + + Does nothing if this instance has not yet been correctly placed + (initialized). + """ + + if not self.is_initialized(): + return + + self._direction = (self._direction - 1) % len(Robot.DIRECTIONS) + + def rotate_right(self): + """ + Rotates this Robot instance's direction to the RIGHT. For example, if + currently facing NORTH, the new direction will be EAST. + + Does nothing if this instance has not yet been correctly placed + (initialized). + """ + + if not self.is_initialized(): + return + + self._direction = (self._direction + 1) % len(Robot.DIRECTIONS) + + def interpret_command(self, command: str): + """ + Interprets a given string command and applies the appropriate + transformation to this Robot instance. Fails silently if the command + is unrecognized or invalid. + + Refer to the full problem description for a list and examples of valid + commands. + + Args: + command (str): The command to be interpreted. + """ + + command = command.upper() + command_tokens = [x.strip() for x in command.split(" ") if len(x) > 0] + + # This is not strictly necessary as case + if len(command_tokens) == 0 or not command_tokens[0] in Robot.VALID_COMMANDS: + return + + match command_tokens[0]: + case "BLOCK": + # print('Found block...') + try: + if len(command_tokens) < 2: + # print('Insufficient tokens...') + return + + parameter_tokens = [x.strip() for x in command_tokens[1].split(",")] + + if len(parameter_tokens) < 2: + # print('Insufficient parameters...') + return + block_x = int(parameter_tokens[0]) + block_y = int(parameter_tokens[1]) + + # print("Adding block: {} {}".format(block_x, block_y)) + self.add_block(block_x, block_y) + except ValueError: + # print('Integer parsing error...') + return + case "PLACE": + try: + # Must have parameters + if len(command_tokens) < 2: + return + parameter_tokens = [x.strip() for x in command_tokens[1].split(",")] + + # Must have at least X, Y + if len(parameter_tokens) < 2: + return + + # Throws ValueError if invalid input + place_x = int(parameter_tokens[0]) + place_y = int(parameter_tokens[1]) + + # Direction parameter is optional on second and subsequent + # placements. The place() method accounts for an absent + # direction on first call and fails silently. + # print("PLACE command parsed: {}, {}".format(place_x, place_y)) + if len(parameter_tokens) > 2: + place_direction = parameter_tokens[2] + self.place(place_x, place_y, place_direction) + else: + self.place(place_x, place_y) + except ValueError: + # Unable to convert x or y token to int + return + case "MOVE": + self.move() + case "LEFT": + self.rotate_left() + case "RIGHT": + self.rotate_right() + case "REPORT": + print("Output: {}".format(str(self))) + + def __str__(self) -> str: + """ + Returns a string representation of the instance including position and + direction. + + Returns: + str: This instance's string representation. + """ + + if not self.is_initialized(): + return "Uninitialized" + + return "{},{},{} | BLOCKS : {}".format( + self._position_x, + self._position_y, + self.get_direction(), + ":".join([str(x) for x in self._blocks]), + ) diff --git a/trexamples.py b/trexamples.py index bdf591d..f40b9b7 100644 --- a/trexamples.py +++ b/trexamples.py @@ -1,41 +1,41 @@ -#!/usr/bin/env python3 - -import toyrobot - - -def feed_file(filename: str, robot: toyrobot.Robot): - with open(filename) as f: - for line in f: - line = line.strip() - print(line) - robot.interpret_command(line) - - -def main(): - print("a)") - feed_file("example_a.txt", toyrobot.Robot()) - print() - - print("b)") - feed_file("example_b.txt", toyrobot.Robot()) - print() - - print("c)") - feed_file("example_c.txt", toyrobot.Robot()) - print() - - print("d)") - feed_file("example_d.txt", toyrobot.Robot()) - print() - - print("e)") - feed_file("example_e.txt", toyrobot.Robot()) - print() - - print("f)") - feed_file("example_f.txt", toyrobot.Robot()) - print() - - -if __name__ == "__main__": - main() +#!/usr/bin/env python3 + +import toyrobot + + +def feed_file(filename: str, robot: toyrobot.Robot): + with open(filename) as f: + for line in f: + line = line.strip() + print(line) + robot.interpret_command(line) + + +def main(): + print("a)") + feed_file("example_a.txt", toyrobot.Robot()) + print() + + print("b)") + feed_file("example_b.txt", toyrobot.Robot()) + print() + + print("c)") + feed_file("example_c.txt", toyrobot.Robot()) + print() + + print("d)") + feed_file("example_d.txt", toyrobot.Robot()) + print() + + print("e)") + feed_file("example_e.txt", toyrobot.Robot()) + print() + + print("f)") + feed_file("example_f.txt", toyrobot.Robot()) + print() + + +if __name__ == "__main__": + main() diff --git a/trtests.py b/trtests.py index 1a117dc..bf0c584 100644 --- a/trtests.py +++ b/trtests.py @@ -1,177 +1,177 @@ -#!/usr/bin/env python3 - -import unittest -import toyrobot - - -class ToyRobotTests(unittest.TestCase): - def test_creation(self): - robot = toyrobot.Robot() - self.assertFalse(robot.is_initialized()) - - def test_initialization(self): - robot = toyrobot.Robot() - robot.place(0, 0, "NORTH") - self.assertTrue(robot.is_initialized()) - - def test_invalid_max_x(self): - exception_thrown = False - try: - toyrobot.Robot(-1, 5) - except ValueError: - exception_thrown = True - self.assertTrue(exception_thrown) - - def test_invalid_max_y(self): - exception_thrown = False - try: - toyrobot.Robot(5, -1) - except ValueError: - exception_thrown = True - self.assertTrue(exception_thrown) - - def test_valid_place(self): - robot = toyrobot.Robot() - robot.place(1, 1, "NORTH") - self.assertTrue( - robot.is_initialized() - and robot.get_position() == (1, 1) - and robot.get_direction() == "NORTH" - ) - - def test_invalid_place_position_x(self): - robot = toyrobot.Robot() - robot.place(6, 1, "NORTH") - self.assertFalse(robot.is_initialized()) - - def test_invalid_place_position_y(self): - robot = toyrobot.Robot() - robot.place(1, 6, "NORTH") - self.assertFalse(robot.is_initialized()) - - def test_invalid_place_position_both(self): - robot = toyrobot.Robot() - robot.place(6, 6, "NORTH") - self.assertFalse(robot.is_initialized()) - - def test_invalid_place_direction(self): - robot = toyrobot.Robot() - robot.place(1, 1, "INVALID") - self.assertFalse(robot.is_initialized()) - - def test_missing_place_direction(self): - robot = toyrobot.Robot() - robot.place(1, 1) - self.assertFalse(robot.is_initialized()) - - def test_double_place(self): - robot = toyrobot.Robot() - robot.place(5, 5, "NORTH") - robot.place(2, 2) - self.assertTrue( - robot.is_initialized() - and robot.get_position() == (2, 2) - and robot.get_direction() == "NORTH" - ) - - def test_double_place_2(self): - robot = toyrobot.Robot() - robot.place(5, 5, "NORTH") - robot.place(2, 2, "SOUTH") - self.assertTrue( - robot.is_initialized() - and robot.get_position() == (2, 2) - and robot.get_direction() == "SOUTH" - ) - - def test_valid_movement_x(self): - robot = toyrobot.Robot() - robot.place(2, 2, "EAST") - robot.move() - self.assertTrue(robot.get_position() == (3, 2)) - - def test_valid_movement_y(self): - robot = toyrobot.Robot() - robot.place(2, 2, "NORTH") - robot.move() - self.assertTrue(robot.get_position() == (2, 3)) - - def test_invalid_movement_north(self): - robot = toyrobot.Robot() - robot.place(1, 5, "NORTH") - robot.move() - self.assertTrue(robot.get_position() == (1, 5)) - - def test_invalid_movement_east(self): - robot = toyrobot.Robot() - robot.place(5, 1, "EAST") - robot.move() - self.assertTrue(robot.get_position() == (5, 1)) - - def test_invalid_movement_south(self): - robot = toyrobot.Robot() - robot.place(1, 0, "SOUTH") - robot.move() - self.assertTrue(robot.get_position() == (1, 0)) - - def test_invalid_movement_west(self): - robot = toyrobot.Robot() - robot.place(0, 1, "WEST") - robot.move() - self.assertTrue(robot.get_position() == (0, 1)) - - def test_rotate_left(self): - robot = toyrobot.Robot() - robot.place(0, 0, "SOUTH") - robot.rotate_left() - self.assertTrue(robot.get_direction() == "EAST") - - def test_rotate_left_2(self): - # Blackbox test - ensures internal numerical direction using modulus - # correctly on decrement. - robot = toyrobot.Robot() - robot.place(0, 0, "NORTH") - robot.rotate_left() - self.assertTrue(robot.get_direction() == "WEST") - - def test_rotate_right(self): - robot = toyrobot.Robot() - robot.place(0, 0, "NORTH") - robot.rotate_right() - self.assertTrue(robot.get_direction() == "EAST") - - def test_rotate_right_2(self): - # Blackbox test - ensures internal numerical direction using modulus - # correctly on increment. - robot = toyrobot.Robot() - robot.place(0, 0, "WEST") - robot.rotate_right() - self.assertTrue(robot.get_direction() == "NORTH") - - def test_example_d_calls(self): - robot = toyrobot.Robot() - robot.place(1, 2, "EAST") - robot.move() - robot.rotate_left() - robot.move() - robot.place(3, 1) - robot.move() - self.assertTrue( - robot.is_initialized() - and robot.get_position() == (3, 2) - and robot.get_direction() == "NORTH" - ) - - def test_example_d_commands(self): - robot = toyrobot.Robot() - robot.interpret_command("PLACE 1,2,EAST") - robot.interpret_command("MOVE") - robot.interpret_command("LEFT") - robot.interpret_command("MOVE") - robot.interpret_command("PLACE 3,1") - robot.interpret_command("MOVE") - self.assertTrue(str(robot) == "3,2,NORTH") - - -if __name__ == "__main__": - unittest.main() +#!/usr/bin/env python3 + +import unittest +import toyrobot + + +class ToyRobotTests(unittest.TestCase): + def test_creation(self): + robot = toyrobot.Robot() + self.assertFalse(robot.is_initialized()) + + def test_initialization(self): + robot = toyrobot.Robot() + robot.place(0, 0, "NORTH") + self.assertTrue(robot.is_initialized()) + + def test_invalid_max_x(self): + exception_thrown = False + try: + toyrobot.Robot(-1, 5) + except ValueError: + exception_thrown = True + self.assertTrue(exception_thrown) + + def test_invalid_max_y(self): + exception_thrown = False + try: + toyrobot.Robot(5, -1) + except ValueError: + exception_thrown = True + self.assertTrue(exception_thrown) + + def test_valid_place(self): + robot = toyrobot.Robot() + robot.place(1, 1, "NORTH") + self.assertTrue( + robot.is_initialized() + and robot.get_position() == (1, 1) + and robot.get_direction() == "NORTH" + ) + + def test_invalid_place_position_x(self): + robot = toyrobot.Robot() + robot.place(6, 1, "NORTH") + self.assertFalse(robot.is_initialized()) + + def test_invalid_place_position_y(self): + robot = toyrobot.Robot() + robot.place(1, 6, "NORTH") + self.assertFalse(robot.is_initialized()) + + def test_invalid_place_position_both(self): + robot = toyrobot.Robot() + robot.place(6, 6, "NORTH") + self.assertFalse(robot.is_initialized()) + + def test_invalid_place_direction(self): + robot = toyrobot.Robot() + robot.place(1, 1, "INVALID") + self.assertFalse(robot.is_initialized()) + + def test_missing_place_direction(self): + robot = toyrobot.Robot() + robot.place(1, 1) + self.assertFalse(robot.is_initialized()) + + def test_double_place(self): + robot = toyrobot.Robot() + robot.place(5, 5, "NORTH") + robot.place(2, 2) + self.assertTrue( + robot.is_initialized() + and robot.get_position() == (2, 2) + and robot.get_direction() == "NORTH" + ) + + def test_double_place_2(self): + robot = toyrobot.Robot() + robot.place(5, 5, "NORTH") + robot.place(2, 2, "SOUTH") + self.assertTrue( + robot.is_initialized() + and robot.get_position() == (2, 2) + and robot.get_direction() == "SOUTH" + ) + + def test_valid_movement_x(self): + robot = toyrobot.Robot() + robot.place(2, 2, "EAST") + robot.move() + self.assertTrue(robot.get_position() == (3, 2)) + + def test_valid_movement_y(self): + robot = toyrobot.Robot() + robot.place(2, 2, "NORTH") + robot.move() + self.assertTrue(robot.get_position() == (2, 3)) + + def test_invalid_movement_north(self): + robot = toyrobot.Robot() + robot.place(1, 5, "NORTH") + robot.move() + self.assertTrue(robot.get_position() == (1, 5)) + + def test_invalid_movement_east(self): + robot = toyrobot.Robot() + robot.place(5, 1, "EAST") + robot.move() + self.assertTrue(robot.get_position() == (5, 1)) + + def test_invalid_movement_south(self): + robot = toyrobot.Robot() + robot.place(1, 0, "SOUTH") + robot.move() + self.assertTrue(robot.get_position() == (1, 0)) + + def test_invalid_movement_west(self): + robot = toyrobot.Robot() + robot.place(0, 1, "WEST") + robot.move() + self.assertTrue(robot.get_position() == (0, 1)) + + def test_rotate_left(self): + robot = toyrobot.Robot() + robot.place(0, 0, "SOUTH") + robot.rotate_left() + self.assertTrue(robot.get_direction() == "EAST") + + def test_rotate_left_2(self): + # Blackbox test - ensures internal numerical direction using modulus + # correctly on decrement. + robot = toyrobot.Robot() + robot.place(0, 0, "NORTH") + robot.rotate_left() + self.assertTrue(robot.get_direction() == "WEST") + + def test_rotate_right(self): + robot = toyrobot.Robot() + robot.place(0, 0, "NORTH") + robot.rotate_right() + self.assertTrue(robot.get_direction() == "EAST") + + def test_rotate_right_2(self): + # Blackbox test - ensures internal numerical direction using modulus + # correctly on increment. + robot = toyrobot.Robot() + robot.place(0, 0, "WEST") + robot.rotate_right() + self.assertTrue(robot.get_direction() == "NORTH") + + def test_example_d_calls(self): + robot = toyrobot.Robot() + robot.place(1, 2, "EAST") + robot.move() + robot.rotate_left() + robot.move() + robot.place(3, 1) + robot.move() + self.assertTrue( + robot.is_initialized() + and robot.get_position() == (3, 2) + and robot.get_direction() == "NORTH" + ) + + def test_example_d_commands(self): + robot = toyrobot.Robot() + robot.interpret_command("PLACE 1,2,EAST") + robot.interpret_command("MOVE") + robot.interpret_command("LEFT") + robot.interpret_command("MOVE") + robot.interpret_command("PLACE 3,1") + robot.interpret_command("MOVE") + self.assertTrue(str(robot) == "3,2,NORTH") + + +if __name__ == "__main__": + unittest.main()