Mokona Guu Center

Test Driven Development : les joueurs

Publié le

C'est bien joli de déplacer des pièces sur un tableau de Shôgi. Mais dans le Shôgi, il y a deux joueurs qui s'affrontent, et pour le moment, mon programme ne gère pas du tout ce concept.

Et d'abord, par quel bord attaquer le problème ?

Puisqu'il y a deux joueurs qui doivent faire leur choix alternativement, je vais commencer par là, dans un nouveau fichier player_tests.py

Pièce de Shôgi en bois

    import unittest
    import mock


    class Player:
        pass


    class PlayerDispatcher:
        def __init__(self, player_1, player_2):
            pass


    class PlayerDispatcherTestCase(unittest.TestCase):
        def test_needs_two_players(self):
            player_1 = mock.Mock(Player)
            player_2 = mock.Mock(Player)
            PlayerDispatcher(player_1, player_2)

La création du dispatcher m'oblige à passer des Mock Objects et du coup à définir une classe Player. Les échecs lors de la construction de ce test étaient uniquement des échecs dus au manque de classe Player et PlayerDispatcher.

Le principe du dispatcher est qu'il appelle les joueurs alternativement pour demander à choisir le mouvement.

    def test_asks_first_player_for_move_first():
        player_1 = mock.Mock(Player)
        player_2 = mock.Mock(Player)
        dispatcher = PlayerDispatcher(player_1, player_2)
        dispatcher.ask_player_move()

        assert player_1.ask_move.called
        assert not player_2.ask_move.called

Qui passe avec :

    class Player:
        def ask_move(self):
            pass

    class PlayerDispatcher:
        def __init__(self, player_1, player_2):
            self.player_1 = player_1

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

Je factorise dans la foulée l'initialisation du dispatcher et des deux joueurs, ce qui a pour effet d'éliminer le premier test, qui devient vide.

Après le premier joueur, c'est au second joueur que le mouvement est demandé.

    def test_asks_second_player_for_move_after_first(self):
        self.dispatcher.ask_player_move()
        self.dispatcher.ask_player_move()

        self.assertTrue(self.player_2.ask_move.called)

Que je fais passer comme ceci :

    class PlayerDispatcher:
        def ask_player_move(self):
            self.current_player.ask_move()
            if self.current_player == self.player_1:
                self.current_player = self.player_2

Et puisque le dispatcher effectue une bascule, je continue :

    def test_asks_first_player_for_move_after_second(self):
        self.dispatcher.ask_player_move()
        self.dispatcher.ask_player_move()
        self.dispatcher.ask_player_move()

        self.assertEqual(2, self.player_1.ask_move.call_count)
        self.assertEqual(1, self.player_2.ask_move.call_count)

Ce qui m'oblige a une vraie implémentation de la bascule.

Que je fais passer avec une paire de tests :

    def ask_player_move(self):
        self.current_player.ask_move()
        if self.current_player == self.player_1:
            self.current_player = self.player_2
        elif self.current_player == self.player_2:
            self.current_player = self.player_1

Mais ce n'est pas très pythonesque, je commence un remaniement pour pythonifier la bascule. La pythonification va consister à utiliser un tuple et un swap de tuple. Je commence par introduire le tuple :

    class PlayerDispatcher:
        def __init__(self, player_1, player_2):
            self.players = player_1, player_2
            self.player_1 = player_1
            self.player_2 = player_2
            self.current_player = self.players[0]

Puis à l'utiliser :

    def ask_player_move(self):
        self.current_player.ask_move()
        if self.current_player == self.players[0]:
            self.current_player = self.players[1]
        elif self.current_player == self.players[1]:
            self.current_player = self.players[0]

Puisque self.player_1 et self.player_2 sont éliminées, je simplifie le constructeur :

    class PlayerDispatcher:
        def __init__(self, player_1, player_2):
            self.players = player_1, player_2
            self.current_player = self.players[0]

J'introduis la bascule avec un tuple contenant l'état courant de qui est le joueur actif et qui est son adversaire :

    class PlayerDispatcher:
        def __init__(self, player_1, player_2):
            self.players = player_1, player_2
            self.current_player = self.players[0]
            self.active_and_opponent = self.players

        def ask_player_move(self):
            self.current_player.ask_move()
            self.active_and_opponent = self.active_and_opponent[1], self.active_and_opponent[0]
            if self.current_player == self.players[0]:
                self.current_player = self.players[1]
            elif self.current_player == self.players[1]:
                self.current_player = self.players[0]

J'utilise ce tuple :

    def ask_player_move(self):
        self.active_and_opponent[0].ask_move()
        self.active_and_opponent = self.active_and_opponent[1], self.active_and_opponent[0]
        if self.current_player == self.players[0]:
            self.current_player = self.players[1]
        elif self.current_player == self.players[1]:
            self.current_player = self.players[0]

Ce qui me permet d'enlever tout ce qui ne sert plus :

    class PlayerDispatcher:
        def __init__(self, player_1, player_2):
            self.players = player_1, player_2
            self.active_and_opponent = self.players

        def ask_player_move(self):
            self.active_and_opponent[0].ask_move()
            self.active_and_opponent = self.active_and_opponent[1], self.active_and_opponent[0]

Entre chaque étape, j'ai lancé les tests pour vérifier que les tests restaient au vert, bien évidemment.

24 lignes de tests pour 12 lignes d'implémentation, le tout pour une simple bascule entre deux variables, est-ce vraiment sérieux ?

Oui, absolument. On pourrait se dire que ce genre de tests est superflu et que ça nous fait perdre du temps pour rien. Jusqu'au moment où la classe grandit, que l'implémentation change, qu'on appelle un autre service pour le même résultat. À ce moment là, et on l'a vu dans l'épisode précédent avec les pièces, la présence des pièces est une aide précieuse.

Travailler en TDD, c'est construire des choses simples à un moment simple pour nous aider lors des moments complexes où les modifications peuvent être dangereuses.

Autrement dit : c'est se faciliter la vie dans le futur pour un prix minime.

Ça joue

À présent, le joueur doit choisir son mouvement.

L'instance reçoit un ask_move().

Par quel moyen le joueur va-t-il répondre ? Que va-t-il répondre ? Un joueur doit pouvoir obtenir l'état du tablier puis répondre par un mouvement, une capture ou une promotion.

Tout à fait l'interface actuelle de ShogiGame.

Oui mais, ShogiGame accepte n'importe quel mouvement de n'importe que joueur. Or d'après la philosophie du système, si un joueur 1 bouge une pièce du joueur 2, le mouvement devrait être ignoré.

Toujours est-il que le joueur doit avoir un moyen d'action. Pour cela, je passerai un Mock Object calé sur l'interface de ShogiGame.

Puisque je vais avoir besoin de ShogiGame pour créer le Mock Object, le moment est venu de créer doubutsugame.py avec ShogiGame pour contenu.

Je choisi de passer un objet sur lequel le joueur pourra agir.

    class PlayerTestCase(unittest.TestCase):
        def test_receives_a_board_effector(self):
            effector = mock.Mock(ShogiGame)
            player = Player()
            player.ask_move(effector)

Je modifie en Player en fonction.

    class Player:
        def ask_move(self, effector):
            pass

Et maintenant, comment tester que Player appelle l'objet passé ? Je peux tester les résultats du Mock Object, mais si j'écris quelque chose comme ça :

    def test_can_answer_with_a_move(self):
        effector = mock.Mock(ShogiGame)
        player = Player()
        player.ask_move(effector)
        self.assertEqual(1, effector.move.call_count)

J'ai un problème. Je pourrais bien entendu faire passer le test comme ceci :

    class Player:
        def ask_move(self, effector):
            effector.move((0, 0), (1, 1))

Sauf que ça n'a aucun sens. Ça ne mène nulle part. Si j'écris un second test qui s'attend à une capture, je vais devoir écrire quelque chose de contradictoire.

Ou bien je dois programmer Player avec la réponse qu'il doit donner. Mais Player n'est pas un Mock Object, c'est la classe à implémenter... vraiment ?

Peut-être que je voudrai avoir différents styles de Player, différents types d'IA. Est-ce que ce sera par un système de composants, des spécialisations de Player ?

Si je spécialise Player, par exemple avec un AlwaysMovePlayer, je vais tester le fonctionnement de AlwaysMovePlayer, ce qui ne va pas bien m'avancer non plus.

Autrement dit, je pourrai tester Player quand j'aurai un scénario de jeu.

J'efface ce test, il ne m'intéresse pas.

Du coup, par où aller ? Travailler sur les joueurs plus que ça semble un peu prématuré.

Je relis les règles du jeu :

  • Le jeu se joue sur un tablier de 3x4 : ok
  • Il y a 8 pions...

Ah ben voilà !

Construisons le plateau

Car jusqu'à maintenant, j'ai des règles du jeu sur des états d'un tablier mais rien pour démarrer la partie.

J'ai besoin de créer l'état du jeu initial, et placer les pièces lors d'un début de session de jeu.

On peut se demander pourquoi je n'ai pas commencé par là dès le début. Bonne question. Au tout début, je n'avais aucun concept de tablier, aucun concept de pièces. Donc placer des pièces sur un tablier, appartenant à deux joueurs différents, ça faisait beaucoup de concepts d'un coup.

Je créé un fichier session_tests.py dans lequel je vais créer une Fabrique (Factory) pour initialiser une session de jeu.

    class SessionTestCase(unittest.TestCase):
        def test_creates_a_board(self):
            session = Session()
            self.assertIsNotNone(session.board)

Que je résous comme ceci :

    from board import Board

    class Session:
        def __init__(self):
            self.board = Board()

La session place les pièces :

    def test_places_the_initial_pieces_on_board(self):
        session = Session()
        board = session.board

        self.assertEqual(Piece(GIRAFFE, P1), board.get_piece_at((0, 0)))
        self.assertEqual(Piece(LION, P1), board.get_piece_at((1, 0)))
        self.assertEqual(Piece(ELEPHANT, P1), board.get_piece_at((2, 0)))
        self.assertEqual(Piece(PAWN, P1), board.get_piece_at((1, 1)))

        self.assertEqual(Piece(ELEPHANT, P2), board.get_piece_at((0, 3)))
        self.assertEqual(Piece(LION, P2), board.get_piece_at((1, 3)))
        self.assertEqual(Piece(GIRAFFE, P2), board.get_piece_at((2, 3)))
        self.assertEqual(Piece(PAWN, P2), board.get_piece_at((1, 2)))

Qui ne passe pas, ne serait-ce que par-ce que GIRAFFE n'existe pas. Ce que je corrige.

Et qui ne passe pas ensuite car forcément, les pièces ne sont pas placées.

Au passage, l'erreur sortie n'est pas terrible :

    Traceback (most recent call last):
        File "session_tests.py", line 20, in test_places_the_initial_pieces_on_board
            self.assertEqual(Piece(GIRAFFE, P1), board.get_piece_at((0, 0)))
    AssertionError: <pieces.Piece instance at 0x940852c> != <pieces.Piece instance at 0x940820c>

C'est aussi un des avantages du TDD : en démarrant par un test qui est un échec, on peut contrôler la manière dont l’échec est affiché.

J'aimerais bien entendu afficher quelque chose de plus clair pour le lecteur de l'erreur. Pour cela, je vais changer le fonctionnement par défaut de la fonction de comparaison utilisée par assertEqual() lorsque j'ai affaire à des objets.

J'utilise la fonction addTypeEqualityFunc(). Due à la nature assez peu typée de Python, un objet en Python est une instance... et c'est tout. Il est possible d'avoir plus de renseignements, mais le système utilisé par unittest se base sur type() qui renvoie instance pour n'importe quel type d'objets.

J'écris ceci :

    class SessionTestCase(unittest.TestCase):
        def __init__(self, methodName='runTest'):
            unittest.TestCase.__init__(self, methodName)
            self.addTypeEqualityFunc(type(Piece(GIRAFFE, P1)), 'assertEqualObject')

        def assertEqualObject(self, first, second, msg=None):
            if not first == second:
                message = "%s != %s" % (str(first), str(second))
                raise self.failureException(message)

Note : ici, je découvre une partie que je ne connaissais pas. La bonne manière d'utiliser addTypeEqualityFunc est assez peu documentée et les exemples sur Internet assez peu nombreux. Je verrai dans le futur si mon utilisation est faite de la manière dont c'est prévu. Si vous connaissez le sujet, n'hésitez pas à commenter.

Ce que je dis ici est, que pour un type identique à celui d'un objet créé, l'égalité fera appel à une fonction membre nommée assertEqualObject. Celle si utilise str() pour afficher le message d'erreur, plutôt que repr() dans la version du framework.

La fonction n'est pas blindée, elle fonctionne dans mon cas. Je considère que tous les objets ont une représentation sous forme de chaîne, qu'ils se comparent tous, qu'il n'y a aucun cas spécial. Et c'est le cas dans mon environnement de test pour le moment. Je conserve le principe d'écrire du code minimal.

À présent, le message d'erreur du test test_places_the_initial_pieces_on_board est bien plus clair :

    Traceback (most recent call last):
        File "session_tests.py", line 29, in test_places_the_initial_pieces_on_board
            self.assertEqual(Piece(GIRAFFE, P1), board.get_piece_at((0, 0)))
    AssertionError: Piece('G', 1) != Piece('', 0)

J'attendais un Piece(GIRAFFE, P1) mais j'obtiens PIECE_NONE.

Je peux donc revenir sur l'implémentation du placement initial du tablier.

    class Session:
        def __init__(self):
            self.board = Board()
            self.board.place_piece(Piece(GIRAFFE, P1), (0, 0))
            self.board.place_piece(Piece(LION, P1), (1, 0))
            self.board.place_piece(Piece(ELEPHANT, P1), (2, 0))
            self.board.place_piece(Piece(PAWN, P1), (1, 1))

            self.board.place_piece(Piece(ELEPHANT, P2), (0, 3))
            self.board.place_piece(Piece(LION, P2), (1, 3))
            self.board.place_piece(Piece(GIRAFFE, P2), (2, 3))
            self.board.place_piece(Piece(PAWN, P2), (1, 2))

Trop simple ! Il a suffit de recopier globalement ce que vérifiait le test pour placer les bonnes pièces au bons endroits. Croyez-le ou non, mais dans cette étape, je me suis trompé et j'ai inversé deux pièces !

Je peux ensuite simplifier le code en toute sécurité, protégé de mes erreurs par les tests qui me diront si j'ai modifieé l'emplacement des pièces.

J'écris donc une version grâce à une fonction intermédiaire qui me permet de décrire le tablier initial de manière plus naturelle (la succession des listes dessine le tablier, avec un peu d'imagination) et plus flexible.

    class Session:
        def __init__(self):
            self.board = Board()

            self._place_row([GIRAFFE, LION, ELEPHANT], P1, 0)
            self._place_row([None, PAWN, None], P1, 1)

            self._place_row([None, PAWN, None], P2, 2)
            self._place_row([ELEPHANT, LION, GIRAFFE], P2, 3)

        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))

Quand je dis « en toute sécurité », ce n'est pas tout à fait vrai. Mes tests ne protègent pas du placement d'une pièce à un endroit où il n'aurait pas du y en avoir.

Je trouve que cela serait tout de même un peu forcer le trait d'ajouter le test, juste au cas où. Les tests qui vérifient la complémentarité de ce que l'on veut tester directement ne sont pas toujours bon, car ils alourdissent la batterie pour une efficacité par toujours évidente.

Cependant, le jour où je créé un bug de ce style, alors oui, j'ajouterai le test.

Conclusion

Donc :

  • Il y a 8 pions positionnés sur le tablier : ok !

Dans les règles suivantes, il y a :

  • Le gagnant est celui qui capture le Lion adverse : ok
  • ou qui amène son Lion sur la dernière ligne du tablier : ok
  • Il y a 4 types de pièces : ok
  • Le pion peut se promouvoir en coq : ok
  • Le premier joueur est déterminé au hasard : ah !
  • Chaque joueur effectue son mouvement à tour de rôle : ok
  • Une pièce peut se déplacer sur une case libre, capturer ou parachuter : ok
  • Chaque pièce à des règles de déplacement propres : ah !

Voilà, il y a deux règles qui pourront m'occuper lors du prochain épisode.

Et je vous laisse sur la même problématique.