Mokona Guu Center

Test Driven Development : le premier joueur

Publié le

Rappel de l'épisode précédent là où je l'avais laissé. J'avais sélectionné deux nouvelles règles du jeu à implémenter. Le fait que le premier joueur était déterminé au hasard et le fait que chaque pièce était soumise à des déplacements contraints.

Piece de Shôgi en bois

Du hasard

Je commence donc pas la sélection du premier joueur. Le concept de premier joueur se situe au niveau d'une session de jeu, c'est donc vers session_tests.py que je me dirige.

Mais comment vérifier qu'un résultat est aléatoire ?

On pourrait imaginer un test qui lance plusieurs session et vérifie que le joueur choisi est parfois l'un, parfois l'autre.

On ne le fait pas...

Pour deux raisons. Même si les générateurs pseudos aléatoire ont tendance à ne pas les sortir, un tirage \"0, 0, 0, 0, 0, 0, 0\" est un tirage parfaitement valable d'une séquence aléatoire. Alors, combien d'itération devrais-je choisir ?

La seconde raison, c'est qu'on n'est pas là pour vérifier la distribution du générateur utilisé.

Alors comment fait-on ?

Lorsque l'on ne veut pas être dépendant d'un résultat, il y a un moyen simple de le contrôler : il suffit de le fournir.

    def test_is_started_with_a_designated_first_player(self):
        self.session.start_game(first_player=P1)
        self.assertEqual(P1, self.session.get_active_player())

J'utilise un paramètre nommé pour un appel de fonction qui lancera la session. Je vérifie ensuite que la session a le bon joueur actif.

Je n'aime pas trop cet accesseur get_active_player() car pour le moment, il ne me sert que dans le test. Mais il va me permettre de progresser.

La résolution est ultra simple :

    def start_game(self, first_player):
        pass

    def get_active_player(self):
        return P1

Mais cette histoire de get_active_player() ne me plait vraiment pas. Actuellement, je n'ai à aucun moment besoin de savoir quel est le joueur actif. L'idée est plutôt que lorsqu'un tour de jeu est déroulé, l'objet joueur actif soit appelé pour lui demander quel est son choix.

C'est la philosophie derrière Player et PlayerDispatcher.

Mais pour cela, la session de jeu a besoin de connaître les joueurs.

Voici une proposition :

    def test_starts_a_session_with_two_players(self):
        player_1 = mock.Mock(Player)
        player_2 = mock.Mock(Player)
        self.session.start_game(players=[player_1, player_2], first_player=P1)

Pour que cela fonctionne, je dois séparer Player (et j’entraîne avec PlayerDispatcher) dans un fichier player.py, je mets les bons import dans les bons fichiers.

Je peux ensuite modifier la fonction start_game().

Mais si j'écris ceci :

    def start_game(self, players, first_player):
        pass

Alors le test précédent sur la décision du premier joueur ne passe plus, car l'argument players n'est pas spécifié. Je pourrais mettre une valeur par défaut au paramètre comme liste vide, ce qui m'obligerait aussi à mettre une valeur par défaut à first_player.

    def start_game(self, players=[], first_player=P1):
        pass

Ça fait beaucoup de choses faites juste pour qu'un test passe. Et tout cela ne semble pas aller dans la bonne direction. Cela signifierait que je peux démarrer le système sans joueur, mais avec un premier joueur ? Ça n'a pas de sens.

Je récapitule : il faut donner à la session deux joueurs et un premier joueur.

Et c'est exactement ce que fait PlayerDispatcher. La classe reçoit deux objets joueurs, dont un est actif et l'autre non. Parfait, pourquoi refaire le boulot ?

J'efface mes deux tests, start_game() et get_active_player() et je recommence :

    def test_starts_with_a_player_dispatcher(self):
        player_1 = mock.Mock(Player)
        player_2 = mock.Mock(Player)
        dispatcher = PlayerDispatcher(player_1, player_2)
        self.session.start_game(dispatcher)

D'accord, mais alors cette histoire d'aléatoire, comment est-ce que je la passe ?

Ok, pause !

Il y a un truc qui ne passe pas, c'est évident.

Dans l'épisode précédent, j'avais dit que Session était une fabrique. Une fabrique prend quelques paramètre pour produire un objet. Mais là, il semblerait que je doive passer un certain nombre d'objets que je doive configurer au préalable.

Ça ne colle pas.

Session n'est pas une fabrique. Je créé une vrai fabrique :

    class SessionFactoryTestCase(unittest.TestCase):
        def test_creates_a_session(self):
            session = SessionFactory().create()
            self.assertIsNotNone(session)


    class SessionFactory:
        def create(self):
            return Session()

Je vérifie ensuite que cette session créée l'a été avec deux joueurs :

    def test_creates_a_session_with_two_players(self):
        self.assertEqual(2, len(self.session.get_players()))

Qui passe avec une méthode toute simple dans Session :

    def get_players(self):
        return [None, None]

Puis que ces deux joueurs sont différents :

    def test_creates_a_session_with_two_different_players(self):
        players = self.session.get_players()
        self.assertEqual(2, len(players))
        self.assertTrue(players[0] != players[1])

Remarque : pourquoi pas self.assertNotEqual(players0, players1) ? Sémantiquement, assertNotEqual() a en premier paramètre une valeur connue attendue. Or ici, c'est une comparaison de deux valeurs inconnues.

Ça passe avec, par exemple :

    def get_players(self):
        return [None, 1]

Et finalement, le joueur active doit être l'un des deux joueurs :

    def test_creates_a_session_with_one_of_the_player_active(self):
        players = self.session.get_players()
        active_player = self.session.get_active_player()

        self.assertTrue(active_player == players[0] or active_player == players[1])

Ce genre de tests est un peu ennuyeux car il se peut qu'il soit faux... de temps en temps, puisqu'il fait intervenir l'aléatoire. Il peut être très pénible de voir un test échouer en intégration continue puis le lancer soi même et le voir passer.

Il faut garder ces tests simples pour pouvoir les analyser simplement.

Pour le moment, le test peut passer avec ceci :

    def get_active_player(self):
        return None

Bien, à présent que ces tests sont mis en place, je peux continuer au point où j'en étais avec Session.

J'en étais à ce test :

    def test_starts_with_a_player_dispatcher(self):
        player_1 = mock.Mock(Player)
        player_2 = mock.Mock(Player)
        dispatcher = PlayerDispatcher(player_1, player_2)
        self.session.start_game(dispatcher)

J'étais resté sur l'idée d'utiliser un PlayerDispatcher pour donner à la Session la notion de joueurs, dont celui actif.

Ça me semble correct, mais d'où viennent les joueurs ? De la fabrique. Mais la fabrique doit-elle créer les joueurs ? Probablement pas, vu que ceux-ci peuvent être de types différents (des IAs différentes par exemple) et que de toute façon, actuellement, il n'y a pas d'implémentation concrète d'un joueur.

Donc en fait, test_starts_with_a_player_dispatcher n'est pas bon, je peux effacer le test.

Par contre, la Session doit être créée avec un ensemble de joueurs.

Je change donc la création d'une session :

    def setUp(self):
        self.player_1 = mock.Mock(Player)
        self.player_2 = mock.Mock(Player)
        self.session = Session([self.player_1, self.player_2])

Que je fais passer comme ceci, afin de ne briser aucun test :

    def __init__(self, players=[]):
       ...

Puisque la fabrique doit transmettre les joueurs à la session, je change aussi le test de création de la fabrique :

    class SessionFactoryTestCase(unittest.TestCase):
        def setUp(self):
            self.player_1 = mock.Mock(Player)
            self.player_2 = mock.Mock(Player)
            self.session = SessionFactory().create([self.player_1, self.player_2])

Que je fais passer comme ceci :

    class SessionFactory:
        def create(self, players):
            return Session()

À présent, le test test_creates_a_session_with_two_different_players() n'a plus tout à fait le même sens, car on connaît les joueurs passés.

Je change le test comme ceci :

    def test_creates_a_session_with_the_given_players(self):
        players = self.session.get_players()
        self.assertEqual(2, len(players))
        self.assertIn(self.player_1, players)
        self.assertIn(self.player_2, players)

Ce qui m'oblige afin de faire passer le test à créer la tuyauterie de transmission des joueurs en paramètre de la fabrique jusqu'à la session :

    class SessionFactory:
        def create(self, players):
            return Session(players)


    class Session:
        def __init__(self, players=[]):
            # ... Board initialization
            self._players = players

        def _place_row(self, pieces, player, row):
            for x, piece in enumerate(pieces):
                if piece is not None:
                    self.board.place_piece(Piece(piece, player), (x, row))

        def get_players(self):
            return self._players

        def get_active_player(self):
            return self._players[0]

start_game() disparaît car il n'y a plus de tests pour cette fonction de toute façon vide.

Tout passe ?

Ok, je peux introduire mon dispatcher.

    def test_ask_move_to_active_player(self):
        self.session.ask_player_move()
        self.player_1.ask_move.assert_called_once_with()

Que je fais passer avec :

    def ask_player_move(self):
        self.get_active_player().ask_move()

J'étends ensuite le test :

    def test_ask_move_to_active_player(self):
        self.session.ask_player_move()
        self.player_1.ask_move.assert_called_once_with()
        self.session.ask_player_move()
        self.player_2.ask_move.assert_called_once_with()

Et voilà le dispatcher, ouf !

    class Session:
        def __init__(self, players=[]):
            # ... Board initialization

            self._dispatcher = PlayerDispatcher(players[0], players[1])

        def _place_row(self, pieces, player, row):
            for x, piece in enumerate(pieces):
                if piece is not None:
                    self.board.place_piece(Piece(piece, player), (x, row))

        def get_players(self):
            return self._dispatcher.players

        def get_active_player(self):
            return self._dispatcher.active_and_opponent[0]

        def ask_player_move(self):
            self._dispatcher.ask_player_move()

Et l'aléatoire ?

Comme je l'ai dit plus haut, je ne teste pas l'aléatoire. Je dois me contenter de l'ajouter sans test. C'est une entorse au principe du TDD.

Note : si vous connaissez ou trouvez un moyen d'écrire un test qui entraînerait l'écriture d'un choix aléatoire, je suis intéressé.

L'ensemble des joueurs que je passe à SessionFactory est mélangé via la fonction sample() et envoyé à la construction de Session.

    class SessionFactory:
        def create(self, players):
            return Session(random.sample(players, len(players)))

Les tests passent. Je les lance quelques fois, histoire d'être certain... tout en sachant qu'il n'y a pas de certitude.

Le mouvement des pièces

Bien, la session est créée avec un tablier en position de départ, un premier joueur s’apprête à jouer. Il est donc temps d'implémenter la restriction des mouvements des pièces.

Dans la philosophie du système, un mouvement sera ignoré si le déplacement n'est pas valable. Il faut donc le valider lorsqu'il est demandé, c'est à dire dans ShogiGame.

Une première idée serait de tester des déplacements valides et invalides dans ShogiGameTestCase. Cependant, cela alourdirait ces tests là, cela provoquerait probablement l'implémentation de la validation dans ShogiGame qui a la responsabilité de faire respecter les règles, mais plutôt en utilisant des classes pour l'aider.

Ce que je vais vérifier plutôt, c'est que les déplacements et captures font appel à une aide extérieur. Ensuite, je pourrai tester cette classe extérieure.

Pour ce premier test, peu importe que le mouvement soit valide ou pas, ce qui est obligatoire, c'est l'appel du validateur de mouvement. Incidemment, cela évite de faire échouer un certain nombre de tests déjà écrit qui auraient des déplacements invalides d'un point de vue des restrictions de mouvements (les tests de capture ou encore les tests de déplacement hors du tablier).

    def test_calls_a_movement_validator_when_moving_a_piece(self):
        validator = mock.Mock()

        self.game.drop(PIECE_PAWN_P1, (1, 1))
        self.game.move((1, 1), (1, 2))

        validator.is_movement_valid.assert_called_once_with(PIECE_PAWN_P1, (1, 1), (1, 2))

Ici, j'y vais doucement avec une résolution locale.

    def test_calls_a_movement_validator_when_moving_a_piece(self):
        validator = mock.Mock()

        piece = PIECE_PAWN_P1
        from_location = (1, 1)
        to_location = (1, 2)

        self.game.drop(piece, from_location)
        validator.is_movement_valid(piece, from_location, to_location)
        self.game.move(from_location, to_location)

        validator.is_movement_valid.assert_called_once_with(piece, (1, 1), (1, 2))

Ça passe. Même chose pour la capture.

    def test_calls_a_movement_validator_when_capturing_a_piece(self):
        validator = mock.Mock()

        piece = PIECE_PAWN_P1
        from_location = (1, 1)
        to_location = (1, 2)

        self.game.drop(piece, from_location)
        self.game.drop(PIECE_PAWN_P2, to_location)
        validator.is_movement_valid(piece, from_location, to_location)
        self.game.capture(from_location, to_location)

        validator.is_movement_valid.assert_called_once_with(piece, from_location, to_location)

Je peux en extraire l’existence d'une interface de validation de mouvement.

    class MovementValidator:
        def is_movement_valid(self, piece, source, destination):
            pass

Je change les validator = mock.Mock() en validator = mock.Mock(MovementValidator), puis je factorise.

J'amène d'abord validator dans setUp() (avec transformation en self.validator du coup). Puis j'ajoute un argument validator à l'init() de ShogiGame.

    class ShogiGameTestCase(unittest.TestCase):
        def setUp(self):
            self.tray = mock.Mock(tray.Tray)
            self.tray.pop_piece.side_effect = lambda x: x
            self.board = Board()
            self.validator = mock.Mock(MovementValidator)

            self.game = ShogiGame(self.board, self.tray, self.validator)

Tout passe toujours.

Je modifie alors _move_piece() avec un appel à is_movement_valid().

    def _move_piece(self, source, destination, piece_change_function):
        two_pieces = self._get_piece_couple(source, destination)
        if not self._can_move_piece_piece(two_pieces):
            return

        source_piece = two_pieces[0]
        if not self._validator.is_movement_valid(source_piece, source, destination):
            return

        self._teleport_piece(source, (destination, piece_change_function(source_piece)))
        self._check_lions_position_after_move_piece()

test_calls_a_movement_validator_when_moving_a_piece échoue car la fonction de validation est appelée deux fois. Normal, je l'enlève du test, et ça passe.

Je fais de même avec la capture :

    def _piece_capture_piece(self, source, destination, piece_change_function):
        two_pieces = self._get_piece_couple(source, destination)
        if not self._piece_can_capture_piece(two_pieces):
            return

        self.board.remove_piece(destination)

        source_piece, destination_piece = two_pieces
        if not self._validator.is_movement_valid(source_piece, source, destination):
            return

        self._teleport_piece(source, (destination, piece_change_function(source_piece)))

        if destination_piece.is_a_lion():
            self.win_condition_happened = True

        self.tray.push_piece(destination_piece.get_piece_controlled_by_opponent())

Histoire d'être certain de la valeur de retour du mon Mock Object de validation, j'ajoute self.validator.is_movement_valid.return_value = True au setUp() de mes tests. Ça passe toujours.

Bug !

Avez-vous repéré le bug que j'ai introduit ? À vrai dire, comme il n'est pas testé, il n'apparaît pas, mais il apparaîtra un jour où l'autre.

Voici le test qui le révèle :

    def test_denies_capture_if_validator_invalid_movement(self):
        piece = PIECE_PAWN_P1
        from_location = (1, 1)
        to_location = (1, 2)

        self.validator.is_movement_valid.return_value = False

        self.game.drop(piece, from_location)
        self.game.drop(PIECE_PAWN_P2, to_location)
        self.game.capture(from_location, to_location)

        self.assertEqual(piece, self.game.get_piece_at(from_location), msg="Source piece was removed")
        self.assertEqual(PIECE_PAWN_P2, self.game.get_piece_at(to_location), msg="Destination piece was removed")

Si la validation de mouvement renvoie que le mouvement est invalide, alors le mouvement entier doit être ignoré. Cela se passe bien dans le cas de move() (vous pouvez écrire le test si vous le voulez) mais pas dans le cas d'une capture(). Je garde donc le test qui échoue et je corrige le bug en déplaçant self.board.remove_piece(destination) après le test de validation.

Duplication

Tout cela amène de la duplication dans _move_piece() et _piece_capture_piece() au niveau de la validation du mouvement. Cependant, pour le moment, enlever la duplication amène à un code plus complexe ou moins performant.

Alors je passe. Je verrai plus tard.

Dans le prochain épisode, je pourrai implémenter les contraintes de mouvements réelles en testant MovementValidator.