From 23670cabee587ded66da73aba548c168f233b209 Mon Sep 17 00:00:00 2001 From: yzamir Date: Tue, 12 Sep 2023 00:48:13 +0300 Subject: [PATCH] Add tests Signed-off-by: yzamir --- Makefile | 6 +- rose/client/.coveragerc | 2 +- rose/client/conftest.py | 4 + rose/client/game/test_actions.py | 14 ++ rose/client/game/test_car.py | 21 ++ rose/client/game/test_obstacles.py | 31 +++ rose/client/game/test_server.py | 57 +++++ rose/client/game/test_track.py | 46 ++++ rose/client/requirements-dev.txt | 4 + rose/server/.coveragerc | 2 +- rose/server/conftest.py | 4 + rose/server/game/player.py | 2 +- rose/server/game/test_player.py | 74 ++++++ rose/server/game/test_score.py | 363 +++++++++++++++++++++++++++++ rose/server/requirements-dev.txt | 6 +- 15 files changed, 630 insertions(+), 6 deletions(-) create mode 100644 rose/client/conftest.py create mode 100644 rose/client/game/test_actions.py create mode 100644 rose/client/game/test_car.py create mode 100644 rose/client/game/test_obstacles.py create mode 100644 rose/client/game/test_server.py create mode 100644 rose/client/game/test_track.py create mode 100644 rose/server/conftest.py create mode 100644 rose/server/game/test_player.py create mode 100644 rose/server/game/test_score.py diff --git a/Makefile b/Makefile index 274d9428..d3e2dcf6 100644 --- a/Makefile +++ b/Makefile @@ -15,11 +15,13 @@ lint-fix: make -C rose/server lint-fix test: - make -C rose/client test-coverage - make -C rose/server test-coverage + make -C rose/client test + make -C rose/server test clean: -find . -name '.coverage' -exec rm {} \; -find . -name 'htmlcov' -exec rmdir {} \; -find . -name '*.pyc' -exec rm {} \; -find . -name '__pycache__' -exec rmdir {} \; + -find . -name '.pytest_cache' -exec rmdir {} \; + diff --git a/rose/client/.coveragerc b/rose/client/.coveragerc index 9b6154a0..75ee2926 100644 --- a/rose/client/.coveragerc +++ b/rose/client/.coveragerc @@ -1,2 +1,2 @@ [run] -omit = *_test.py +omit = test_*.py diff --git a/rose/client/conftest.py b/rose/client/conftest.py new file mode 100644 index 00000000..89b54641 --- /dev/null +++ b/rose/client/conftest.py @@ -0,0 +1,4 @@ +# conftest.py +import sys + +sys.path.append(".") diff --git a/rose/client/game/test_actions.py b/rose/client/game/test_actions.py new file mode 100644 index 00000000..57ba7dee --- /dev/null +++ b/rose/client/game/test_actions.py @@ -0,0 +1,14 @@ +from game.actions import NONE, RIGHT, LEFT, PICKUP, JUMP, BRAKE, ALL + + +def test_constants(): + assert NONE == "none" + assert RIGHT == "right" + assert LEFT == "left" + assert PICKUP == "pickup" + assert JUMP == "jump" + assert BRAKE == "brake" + + +def test_all_constant(): + assert ALL == (NONE, RIGHT, LEFT, PICKUP, JUMP, BRAKE) diff --git a/rose/client/game/test_car.py b/rose/client/game/test_car.py new file mode 100644 index 00000000..dff7f14b --- /dev/null +++ b/rose/client/game/test_car.py @@ -0,0 +1,21 @@ +import pytest +from game.car import Car + + +def test_car_initialization(): + info = {"x": 5, "y": 10} + car = Car(info) + + assert car.x == 5 + assert car.y == 10 + + +def test_car_initialization_missing_key(): + info_missing_x = {"y": 10} + info_missing_y = {"x": 5} + + with pytest.raises(KeyError): + Car(info_missing_x) + + with pytest.raises(KeyError): + Car(info_missing_y) diff --git a/rose/client/game/test_obstacles.py b/rose/client/game/test_obstacles.py new file mode 100644 index 00000000..ef42b942 --- /dev/null +++ b/rose/client/game/test_obstacles.py @@ -0,0 +1,31 @@ +from game.obstacles import ( + NONE, + CRACK, + TRASH, + PENGUIN, + BIKE, + WATER, + BARRIER, + ALL, + get_random_obstacle, +) + + +def test_constants(): + assert NONE == "" + assert CRACK == "crack" + assert TRASH == "trash" + assert PENGUIN == "penguin" + assert BIKE == "bike" + assert WATER == "water" + assert BARRIER == "barrier" + + +def test_all_constant(): + assert ALL == (NONE, CRACK, TRASH, PENGUIN, BIKE, WATER, BARRIER) + + +def test_get_random_obstacle(): + # This test checks if the function returns a valid obstacle + obstacle = get_random_obstacle() + assert obstacle in ALL diff --git a/rose/client/game/test_server.py b/rose/client/game/test_server.py new file mode 100644 index 00000000..09aee498 --- /dev/null +++ b/rose/client/game/test_server.py @@ -0,0 +1,57 @@ +import pytest +import requests +import threading +from game.server import MyTCPServer, MyHTTPRequestHandler + + +def drive(world): + return "" + + +# Start the server in a separate thread for testing +@pytest.fixture(scope="module") +def start_server(): + server_address = ("", 8081) + MyHTTPRequestHandler.drive = drive + httpd = MyTCPServer(server_address, MyHTTPRequestHandler) + thread = threading.Thread(target=httpd.serve_forever) + thread.start() + yield + httpd.shutdown() + thread.join() + + +def test_get_driver_name(start_server): + response = requests.get("http://localhost:8081/") + data = response.json() + assert data["info"]["name"] == "Unknown" # Default driver name + + +def test_post_valid_data(start_server): + payload = { + "info": {"car": {"x": 3, "y": 8}}, + "track": [ + ["", "", "bike"], + ["", "", ""], + ["", "", ""], + ["", "", ""], + ["", "", ""], + ["", "", ""], + ["", "", ""], + ["", "", ""], + ], + } + response = requests.post("http://localhost:8081/", json=payload) + data = response.json() + assert "action" in data["info"] + + +def test_post_invalid_json(start_server): + response = requests.post("http://localhost:8081/", data="not a valid json") + assert response.status_code == 400 + + +def test_post_unexpected_data_structure(start_server): + payload = {"unexpected": "data"} + response = requests.post("http://localhost:8081/", json=payload) + assert response.status_code == 500 diff --git a/rose/client/game/test_track.py b/rose/client/game/test_track.py new file mode 100644 index 00000000..c12084da --- /dev/null +++ b/rose/client/game/test_track.py @@ -0,0 +1,46 @@ +import pytest +from game.track import Track + + +def test_track_initialization(): + t = Track() + assert t.max_x == 0 + assert t.max_y == 0 + + t2 = Track([["a", "b"], ["c", "d"]]) + assert t2.max_x == 2 + assert t2.max_y == 2 + + +def test_track_get(): + t = Track([["a", "b"], ["c", "d"]]) + assert t.get(0, 0) == "a" + assert t.get(1, 0) == "b" + assert t.get(0, 1) == "c" + assert t.get(1, 1) == "d" + + +def test_track_get_out_of_bounds(): + t = Track([["a", "b"], ["c", "d"]]) + + with pytest.raises(IndexError, match="x out of range: 0-1"): + t.get(2, 0) + + with pytest.raises(IndexError, match="y out of range: 0-1"): + t.get(0, 2) + + +def test_track_validate_pos(): + t = Track([["a", "b"], ["c", "d"]]) + + # These should not raise any errors + t._validate_pos(0, 0) + t._validate_pos(1, 0) + t._validate_pos(0, 1) + t._validate_pos(1, 1) + + with pytest.raises(IndexError, match="x out of range: 0-1"): + t._validate_pos(2, 0) + + with pytest.raises(IndexError, match="y out of range: 0-1"): + t._validate_pos(0, 2) diff --git a/rose/client/requirements-dev.txt b/rose/client/requirements-dev.txt index 3a35e792..df92181f 100644 --- a/rose/client/requirements-dev.txt +++ b/rose/client/requirements-dev.txt @@ -4,3 +4,7 @@ flake8>=3.9.0 coverage>=7.3.0 radon>=6.0.0 black>=23.7.0 +pytest +pytest-check-links +pytest-coverage +pytest-timeout \ No newline at end of file diff --git a/rose/server/.coveragerc b/rose/server/.coveragerc index c712d259..75ee2926 100644 --- a/rose/server/.coveragerc +++ b/rose/server/.coveragerc @@ -1,2 +1,2 @@ [run] -omit = tests/* +omit = test_*.py diff --git a/rose/server/conftest.py b/rose/server/conftest.py new file mode 100644 index 00000000..89b54641 --- /dev/null +++ b/rose/server/conftest.py @@ -0,0 +1,4 @@ +# conftest.py +import sys + +sys.path.append(".") diff --git a/rose/server/game/player.py b/rose/server/game/player.py index 9c205452..48a4c28d 100644 --- a/rose/server/game/player.py +++ b/rose/server/game/player.py @@ -44,7 +44,7 @@ def reset(self): self.x = self.lane * config.cells_per_player + 1 # | |0| | |1 | | self.y = config.matrix_height // 3 * 2 # 1/3 of track self.action = actions.NONE - self.response_time = 0 + self.response_time = None self.score = 0 self.pickups = 0 self.misses = 0 diff --git a/rose/server/game/test_player.py b/rose/server/game/test_player.py new file mode 100644 index 00000000..bdcc0ff3 --- /dev/null +++ b/rose/server/game/test_player.py @@ -0,0 +1,74 @@ +from common import actions, config +from game import player + + +def test_player_initialization(): + player1 = player.Player("John", 1, 1) + + assert player1.name == "John" + assert player1.car == 1 + assert player1.lane == 1 + # player.Player x value should be middle of it's lane + assert player1.x == player1.lane * config.cells_per_player + 1 + assert player1.y == config.matrix_height // 3 * 2 + assert player1.action == actions.NONE + assert player1.response_time is None + assert player1.score == 0 + + +def test_player_reset(): + player1 = player.Player("John", 1, 1) + + player1.score = 50 # Modify player to make sure reset works + player1.reset() + assert player1.x == player1.lane * config.cells_per_player + 1 + assert player1.y == config.matrix_height // 3 * 2 + assert player1.action == actions.NONE + assert player1.response_time is None + assert player1.score == 0 + + +def test_player_in_lane(): + player1 = player.Player("John", 1, 1) + + lane_start = player1.lane * config.cells_per_player + + for offset in range(config.cells_per_player): + player1.x = lane_start + offset + assert player1.in_lane() + + +def test_player_not_in_lane(): + player1 = player.Player("John", 1, 1) + + # Modify player's position to be out of their lane + other_lane = (player1.lane + 1) % config.max_players + other_lane_start = other_lane * config.cells_per_player + + for offset in range(config.cells_per_player): + player1.x = other_lane_start + offset + assert not player1.in_lane() + + +def test_player_state(): + player1 = player.Player("John", 2, 1) + + expected_state = { + "name": "John", + "car": 2, + "x": 4, # 1 * config.cells_per_player + 1 + "y": config.matrix_height // 3 * 2, + "action": actions.NONE, + "response_time": None, + "error": None, + "lane": 1, + "score": 0, + "pickups": 0, + "misses": 0, + "hits": 0, + "breaks": 0, + "jumps": 0, + "collisions": 0, + } + + assert player1.state() == expected_state diff --git a/rose/server/game/test_score.py b/rose/server/game/test_score.py new file mode 100644 index 00000000..f49576e6 --- /dev/null +++ b/rose/server/game/test_score.py @@ -0,0 +1,363 @@ +from common import actions, config, obstacles +from game import track +from game import player +from game import score +import pytest + + +FORWARD_ACTIONS = [a for a in actions.ALL if a not in (actions.RIGHT, actions.LEFT)] + + +class SinglePlayerTest(object): + # Must be defined in subclass + obstacle = None + + def setup_method(self, m): + self.track = track.Track(False) + self.player = player.Player("A", car=0, lane=0) + self.x = self.player.x + self.y = self.player.y + self.score = self.player.score + self.track.set(self.x, self.y, self.obstacle) + + def process(self): + score.process([self.player], self.track) + + def assert_score(self, score): + assert self.player.x == self.x + assert self.player.y == self.y + assert self.player.score == score + config.score_move_forward + + def assert_move_right(self): + assert self.player.x == self.x + 1 + assert self.player.y == self.y + assert self.player.score == self.score + config.score_move_forward + + def assert_move_left(self): + assert self.player.x == self.x - 1 + assert self.player.y == self.y + assert self.player.score == self.score + config.score_move_forward + + def assert_move_back(self): + assert self.player.x == self.x + assert self.player.y == self.y + 1 + assert self.player.score == self.score - config.score_move_forward + + def assert_move_back_no_punish(self): + assert self.player.x == self.x + assert self.player.y == self.y + 1 + assert self.player.score == self.score - config.score_move_forward + + def assert_keep_obstacle(self): + assert self.track.get(self.x, self.y) == self.obstacle + + def assert_remove_obstacle(self): + assert self.track.get(self.x, self.y) == obstacles.NONE + + +class TestNoObstacle(SinglePlayerTest): + obstacle = obstacles.NONE + + def test_right(self): + self.player.action = actions.RIGHT + self.process() + self.assert_move_right() + self.assert_keep_obstacle() + + def test_left(self): + self.player.action = actions.LEFT + self.process() + self.assert_move_left() + self.assert_keep_obstacle() + + @pytest.mark.parametrize("action", FORWARD_ACTIONS) + def test_forward(self, action): + self.player.action = action + self.process() + self.assert_score(self.score) + self.assert_keep_obstacle() + + +class TestPenguin(SinglePlayerTest): + """ + Handling penguins + + If player pick the penguin, it move forward and get more score. Otherwise + the penguin is skipped and can be picked by other players. + """ + + obstacle = obstacles.PENGUIN + + def test_pickup(self): + self.player.action = actions.PICKUP + self.process() + # Player move up and get more score + assert self.player.x == self.x + assert self.player.y == self.y + assert self.player.score == self.score + config.score_move_forward * 2 + self.assert_remove_obstacle() + + def test_right(self): + self.player.action = actions.RIGHT + self.process() + self.assert_move_right() + self.assert_keep_obstacle() + + def test_left(self): + self.player.action = actions.LEFT + self.process() + self.assert_move_left() + self.assert_keep_obstacle() + + @pytest.mark.parametrize( + "action", [a for a in FORWARD_ACTIONS if a != actions.PICKUP] + ) + def test_other(self, action): + self.player.action = action + self.process() + self.assert_score(self.score) + self.assert_keep_obstacle() + + +class MagicActionTest(SinglePlayerTest): + """ + Handling obstacles with magic action + + If player choose the magic action the obstale is skipped. If player does + not turn right or left, it moves back and the obstacle is consumed. + """ + + # Must be defined in subclass + action = None + magic_score = None + + @pytest.mark.parametrize("action", FORWARD_ACTIONS) + def test_forward(self, action): + self.player.action = action + self.process() + if action == self.action: + self.assert_score(self.score + self.magic_score) + self.assert_keep_obstacle() + else: + self.assert_move_back() + self.assert_remove_obstacle() + + def test_right(self): + self.player.action = actions.RIGHT + self.process() + self.assert_move_right() + self.assert_keep_obstacle() + + def test_left(self): + self.player.action = actions.LEFT + self.process() + self.assert_move_left() + self.assert_keep_obstacle() + + +class TestCrack(MagicActionTest): + magic_score = config.score_jump + obstacle = obstacles.CRACK + action = actions.JUMP + + +class TestWater(MagicActionTest): + magic_score = config.score_brake + obstacle = obstacles.WATER + action = actions.BRAKE + + +class TurnTest(SinglePlayerTest): + """ + Handling obstacles that have no magic action + + Player must turn right or left, or it will move back. + """ + + def test_right(self): + self.player.action = actions.RIGHT + self.process() + self.assert_move_right() + self.assert_keep_obstacle() + + def test_left(self): + self.player.action = actions.LEFT + self.process() + self.assert_move_left() + self.assert_keep_obstacle() + + @pytest.mark.parametrize("action", FORWARD_ACTIONS) + def test_other(self, action): + self.player.action = action + self.process() + # TODO: decrease points on redundant action? + self.assert_move_back_no_punish() + self.assert_remove_obstacle() + + +class TestTrash(TurnTest): + obstacle = obstacles.TRASH + + +class TestBike(TurnTest): + obstacle = obstacles.BIKE + + +class TestBarrier(TurnTest): + obstacle = obstacles.BARRIER + + +class TestLimits(SinglePlayerTest): + """ + Handling movement out of the track + """ + + obstacle = obstacles.NONE + + def test_left(self): + # TODO: decrease score? move back? + self.x = self.player.x = 0 + self.player.action = actions.LEFT + self.process() + self.assert_score(self.score) + self.assert_keep_obstacle() + + def test_right(self): + # TODO: decrease score? move back? + self.x = self.player.x = config.matrix_width - 1 + self.player.action = actions.RIGHT + self.process() + self.assert_score(self.score) + self.assert_keep_obstacle() + + def test_forward(self): + self.y = self.player.y = 0 + self.player.action = actions.PICKUP + self.obstacle = obstacles.PENGUIN + self.track.set(self.x, self.y, self.obstacle) + self.process() + # Player keep position but get more score + assert self.player.x == self.x + assert self.player.y == self.y + 2 + assert self.player.score == self.score + config.score_move_forward * 2 + self.assert_remove_obstacle() + + def test_back(self): + # TODO: always decrease score + self.y = self.player.y = config.matrix_height - 1 + self.player.action = actions.NONE + self.player.score = 0 + self.obstacle = obstacles.TRASH + self.track.set(self.x, self.y, self.obstacle) + self.process() + self.assert_score(config.score_move_backward * 2) + self.assert_remove_obstacle() + + +class TestCollisions(object): + """ + Handling case where two players try to move to the same cell. + + Current behavior is to prefer the players with smaller y value and smaller + response_time. + + TODO: change behavior to prefer the player in its lane, so driving in other + player lane is more risky. + """ + + def setup_method(self, m): + self.track = track.Track(False) + self.player1 = player.Player("A", car=0, lane=0) + self.player2 = player.Player("B", car=0, lane=1) + + def process(self): + players = [self.player1, self.player2] + score.process(players, self.track) + + def test_player_in_lane_wins(self): + self.track.set(1, 6, obstacles.PENGUIN) + # Player 1 in its lane at 1,5, missed the penguin. + self.player1.x = 1 + self.player1.y = 5 + self.player1.score = 0 + self.player1.action = actions.NONE + # Player 2 is not in its lane, trying to pick up the penguin. + self.player2.x = 1 + self.player2.y = 6 + self.player2.score = 0 + self.player2.action = actions.PICKUP + self.process() + # Player got the normal score for this step. + assert self.player1.x == 1 + assert self.player1.y == 5 + assert self.player1.score == config.score_move_forward + # Player 2 picked up the penging, got extra score. + assert self.player2.x == 1 + assert self.player2.y == 6 + assert self.player2.score == config.score_move_forward * 2 + + def test_after_turn_to_not_in_lane(self): + # Player 1 in its lane at 2,5 + self.player1.x = 2 + self.player1.y = 5 + self.player1.score = 0 + self.player1.action = actions.NONE + # Player 2 in its lane, but after turning left will not be in his lane. + self.player2.x = 3 + self.player2.y = 5 + self.player2.score = 0 + self.player2.action = actions.LEFT + self.process() + # Player 1 win because it is in lane + assert self.player1.x == 2 + assert self.player1.y == 5 + assert self.player1.score == self.player1.score + # Player 2 got more score but move back + assert self.player2.x == 2 + assert self.player2.y == 6 + # TODO - decrease score when out of lane? + assert self.player2.score == 0 + + def test_move_left_out_of_world(self): + # Player 1 in its lane at 1,8 + self.player1.x = 1 + self.player1.y = 8 + self.player1.score = 0 + self.player1.action = actions.NONE + # Player 2 trying to move to 1,8 + self.player2.x = 0 + self.player2.y = 8 + self.player2.score = 0 + self.player2.action = actions.RIGHT + self.process() + # Player 1 win because it is in own lane + assert self.player1.x == 1 + assert self.player1.y == 8 + assert self.player1.score == 0 + config.score_move_forward + # Player 2 moved left, first free cell + assert self.player2.x == 0 + assert self.player2.y == 8 + # TODO: decrease score? + assert self.player2.score == 0 + + def test_move_right_out_of_world(self): + # Player 1 in its lane at 0,8 + self.player1.x = 0 + self.player1.y = 8 + self.player1.score = 0 + self.player1.action = actions.NONE + # Player 2 move from 1,8 to 0,8 + self.player2.x = 1 + self.player2.y = 8 + self.player2.score = 0 + self.player2.action = actions.LEFT + self.process() + # Player 1 win because it is in own lane + assert self.player1.x == 0 + assert self.player1.y == 8 + assert self.player1.score == 0 + config.score_move_forward + # Player 2 moved right, no other possible cell + assert self.player2.x == 1 + assert self.player2.y == 8 + # TODO: decrease score? + assert self.player2.score == 0 diff --git a/rose/server/requirements-dev.txt b/rose/server/requirements-dev.txt index 8357cf2f..f4aec775 100644 --- a/rose/server/requirements-dev.txt +++ b/rose/server/requirements-dev.txt @@ -6,4 +6,8 @@ radon>=6.0.0 black>=23.7.0 requests>=2.28.0 websockets>=11.0.0 -aiohttp>=3.8.0 \ No newline at end of file +aiohttp>=3.8.0 +pytest +pytest-check-links +pytest-coverage +pytest-timeout \ No newline at end of file