Mokona Guu Center

Test Driven Development : la suite de la partie

Publié le

Au dernier épisode, j'avais commencé à implémenter un joueur afin de simuler le début d'une partie et vérifier que j'avais tous les outils pour une implémentation de partie complète jouée automatiquement.

Le code qui me permettait de bouger la giraffe était cependant redondant avec ce que j'avais déjà implémenté au tout début dans la classe ShogiGame.

J'en avais conclus l'étape suivante, qui était de passer à Effector non pas le Board mais une instance de ShogiGame configurée par la session.

Je suis dans une phase où les tests passent. Je peux donc modifier le code. ShogiGame a besoin d'un Board, d'un Tray et d'un MovementValidator. Actuellement, j'ai le Board. Pour Tray, je dois extraire tout d'abord le code qui est toujours dans sa classe de tests, et je créé donc un fichier tray.py.

Les modifications sont simples et réduisent le code de l'Effector a une simple redirection vers ShogiGame.

    class Effector:
        def __init__(self, game):
            self._game = game

        def move(self, source, destination):
            self._game.move(source, destination)


    class Session:
        def __init__(self, players=[]):
            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)

            self._dispatcher = PlayerDispatcher(players[0], players[1])
            self._tray = Tray()
            self._validator = MovementValidator()

            self._game = ShogiGame(self.board, self._tray, self._validator)

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

Scénario 2

Dans le scénario 2, le joueur va avancer son poussin afin de faire une prise.

    def test_scenario_2(self):
        player_1 = Scenario2_Player1()
        player_2 = mock.Mock(Player)

        session = Session([player_1, player_2])
        session.ask_player_move()

        where_the_pawn_was = (1, 1)
        where_the_pawn_goes = (1, 2)
        self.assertEqual(PIECE_NONE, session.board.get_piece_at(where_the_pawn_was))
        self.assertEqual(Piece(PAWN, P1), session.board.get_piece_at(where_the_pawn_goes))

Ce que je résous par :

    class Scenario2_Player1(Player):
        def ask_move(self, effector):
            effector.capture((1, 1), (1, 2))

Et

    class Effector:
        def capture(self, source, destination):
            self._game.capture(source, destination)

Une partie complète

Pour le troisième scénario, je vais dérouler une partie complète. Pas très futée de la part des joueurs, mais qui va me faire voir quelques cas de mouvements et une détection de fin de partie.

Voici la partie. Chaque mouvement est numéroté dans l'ordre, la première colonne représentant les mouvements du premier joueur et la deuxième colonne les mouvements du second joueur.

Les pièces sont représentées par leur initiale (Pawn, Lion, Giraffe, Elephant... je ne sais pas pourquoi depuis le début, j'ai toujours appelé le poussin 'pawn'). Ensuite vient le type de mouvement ; normal (-), prise (x) ou parachutage (*). Dans cette partie, je n'ai pas mis de promotion.

Vient enfin les coordonnées d'arrivées, en commençant par la colonne en chiffre et terminant par la ligne en lettre. Le lion du joueur 1 est en '2a' initialement.

    1. Px2c
    2. Lx2c
    3. P*3c
    4. Gx3c
    5. E-2b
    6. G-3b
    7. G-1b
    8. Lx2b
    9. Lx2b

Je peux implémenter le test de cette manière :

    def test_scenario_3(self):
        player_1 = Scenario3_Player1()
        player_2 = Scenario3_Player2()

        session = Session([player_1, player_2])

        for x in range(9):
            session.ask_player_move()

        self.assertTrue(session.is_finished())

J'y ajouterai ensuite de la vérification d'emplacement des pièces.

Mais le test échoue directement car :

    NotImplementedError: Session.is_finished() is not yet implemented

En effet, j'avais eu besoin de la méthode lors de l'implémentation de SessionRunner, mais j'étais passé par des Mock Objects. Il me faudrait donc implémenter is_finished.

C'est gênant. Je dois croire aveuglément que les joueurs terminent la partie pour être certain que mon implémentation de is_finished est correcte.

Je change donc de tactique et je vérifie tout d'abord l'état du tableau à la fin des mouvements.

Initialement, le tableau contient les pièces comme ceci :

      1  2  3
    a G1 L1 E1
    b    P1
    c    P2
    d E2 L2 G2

À la fin, le tableau est comme ceci

    Réserve J1: -
        1  2  3
    a   G1 L1 G2
    b
    c
    d   E2

    Réserve J2: P,P,E

Je remplace donc mon test par :

    self.assertEqual(Piece(GIRAFFE, P1), session.board.get_piece_at((0, 1)))

Qui forcément, ne fonctionne pas. Mais faire passer le test est facile.

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

En effet, le design de ShogiBoard fait qu'un mouvement invalide est ignoré silencieusement. Le joueur 1 fait donc son premier mouvement au premier appel, ce qui bouge réellement la pièce. Puis refait le mouvement lors des appels suivants, sans succès. Au final, la girafe est au bon endroit.

Au passage, je factorise le code d'initialisation de la session dans une fonction commune à mes trois tests :

    def _initialize_session(self, class_p1, class_p2):
        player_1 = class_p1()
        player_2 = class_p2()
        self.session = Session([player_1, player_2])

Et j'écris une seconde vérification d'emplacement :

    self.assertEqual(Piece(GIRAFFE, P2), self.session.board.get_piece_at((2, 2)))

Qui passe symétriquement avec :

    class Scenario3_Player2(Player):
        def ask_move(self, effector):
            effector.move((2, 3), (2, 2))

Je sais donc maintenant que les deux joueurs sont appelés et que le scénario commence à se dérouler. Il est temps d'éliminer un peu de duplication en rendant plus facile l'ajout des tests de position suivants.

Ce qui donne ceci :

    def test_scenario_3(self):
        self._initialize_session(Scenario3_Player1, Scenario3_Player2)

        for x in range(9):
            self.session.ask_player_move()

        pieces_to_verify = [
            (GIRAFFE, P1, (0, 1)),
            (GIRAFFE, P2, (2, 2))
        ]

        board = self.session.board
        for piece in pieces_to_verify:
            game_piece = Piece(piece[0], piece[1])
            position = piece[2]
            self.assertEqual(game_piece, board.get_piece_at(position))

Un autre style de test pour vérifier le tablier est l'emplacement des cases vides.

    self.assertEqual(PIECE_NONE, self.session.board.get_piece_at((1, 0)))

Le Lion du joueur 1 n'ayant pas encore bougé, le test échoue. Je vais le pousser sur une case libre.

    class Scenario3_Player1(Player):
        def __init__(self):
            self.move_count = 0

        def ask_move(self, effector):
            if self.move_count == 0:
                effector.move((0, 0), (0, 1))
            if self.move_count == 1:
                effector.move((1, 0), (2, 1))

            self.move_count += 1

Ce n'est pas l'emplacement où il sera à la fin, mais au moins, il fait passer le test. J'introduis au passage un compteur de mouvement pour le joueur 1. Effectuer les deux mouvements dans la même fonction aurait fonctionné, mais ce n'est pas la façon dont ask_move est censé fonctionner.

L'arrivée du compteur, en faisant passer le test, me permet aussi d'introduire une généralisation sous forme d'une espèce de 'script' des mouvements à effectuer pour le joueur.

    class Scenario3_Player1(Player):
        def __init__(self):
            self.move_count = 0
            self.moves = []
            self.moves.append(("move", (0, 0), (0, 1)))
            self.moves.append(("move", (1, 0), (2, 1)))

        def ask_move(self, effector):
            if self.move_count < len(self.moves):
                move = self.moves[self.move_count]
                getattr(effector, move[0])(move[1], move[2])

            self.move_count += 1

J'utilise la possibilité d'appeler dynamiquement une méthode sur un objet à partir d'une chaîne de caractères (getattr).

J'ajoute un second test de case vide :

    self.assertEqual(PIECE_NONE, self.session.board.get_piece_at((1, 3)))

Ce qui m'oblige à déplacer le lion du joueur 2. Encore une fois, je le déplace à un endroit qui ne gêne pas, juste pour faire passer le test.

J'en profite pour utiliser le même système que pour le premier joueur :

    class Scenario3_Player2(Player):
        def __init__(self):
            self.move_count = 0
            self.moves = []
            self.moves.append(("move", (2, 3), (2, 2)))
            self.moves.append(("move", (1, 3), (0, 2)))

        def ask_move(self, effector):
            if self.move_count < len(self.moves):
                move = self.moves[self.move_count]
                getattr(effector, move[0])(move[1], move[2])

            self.move_count += 1

Puis, le test passant, j'en profite pour factoriser le système du script des joueurs.

    class Scenario3_Player:
        def __init__(self):
            self.move_count = 0
            self.moves = []

        def ask_move(self, effector):
            if self.move_count < len(self.moves):
                move = self.moves[self.move_count]
                getattr(effector, move[0])(move[1], move[2])

            self.move_count += 1


    class Scenario3_Player1(Scenario3_Player):
        def __init__(self):
            super().__init__()
            self.moves.append(("move", (0, 0), (0, 1)))
            self.moves.append(("move", (1, 0), (2, 1)))


    class Scenario3_Player2(Scenario3_Player):
        def __init__(self):
            super().__init__()
            self.moves.append(("move", (2, 3), (2, 2)))
            self.moves.append(("move", (1, 3), (0, 2)))

Ça passe toujours. Côté test, je peux généraliser les tests de cases vides.

    places_without_piece = [(1, 0), (1, 3)]

    for position in places_without_piece:
        self.assertEqual(PIECE_NONE, self.session.board.get_piece_at(position))

Bon, maintenant que j'ai tout, je me lance avec la vérification complète du tableau à la fin de la partie. Je me rends compte à ce moment là que ma girafe du joueur 2 n'est pas à sa place exacte.

Pas très grave, de toute façon, le test va échouer vu que certaines pièces ne sont pas aux endroits attendus.

    pieces_to_verify = [
        (GIRAFFE, P1, (0, 1)),
        (LION, P1, (1, 1)),
        (GIRAFFE, P2, (2, 1)),
        (ELEPHANT, P2, (0, 3))
    ]

    places_without_piece = [
        (0, 0), (1, 0), (2, 0),
        (0, 2), (1, 2), (2, 2),
        (1, 3), (2, 3)
    ]

Ça ne passe pas. Je programme alors les deux joueurs.

Le deuxième mouvement du joueur 1 est un parachutage.

    self.moves.append(("drop", "P", (2, 2)))

Cependant, l'effector ne sait pas encore faire de parachutage. Étant donné que sa fonction est uniquement de renvoyer les commandes vers ShogiGame, je l'implémente dès maintenant.

    class Scenario3_Player1(Scenario3_Player):
        def __init__(self):
            Scenario3_Player.__init__(self)
            self.moves.append(("capture", (1, 1), (1, 2)))
            self.moves.append(("drop", Piece(PAWN, P1), (2, 2)))
            self.moves.append(("move", (2, 0), (1, 1)))
            self.moves.append(("move", (0, 0), (0, 1)))
            self.moves.append(("capture", (1, 0), (1, 1)))


    class Scenario3_Player2(Scenario3_Player):
        def __init__(self):
            Scenario3_Player.__init__(self)
            self.moves.append(("capture", (1, 3), (1, 2)))
            self.moves.append(("capture", (2, 3), (2, 2)))
            self.moves.append(("move", (2, 2), (2, 1)))
            self.moves.append(("capture", (1, 2), (1, 1)))

Le scénario implémenté, les tests de position et de cases vides passent. Ouf.

Afin de suivre la progression de la programmation, je me suis aidé de deux outils un peu rudimentaires que je ne laisserai pas dans le code final.

Tout d'abord, l'affichage du tablier via la transformation de Board.

    class Board:
        def __str__(self):
            b = ""
            for y in range(BOARD_MAXIMAL_Y + 1):
                for x in range(BOARD_MAXIMAL_X + 1):
                    b += str(self.get_piece_at((x, y)))
                b += "\"\n"
            return b

Puis ensuite dans le test fonctionnel :

    turn = 1
    for x in range(9):
        self.session.ask_player_move()
        print("Turn %i" % turn)
        print(str(self.session.board))
        turn += 1

Une fois que les tests passent, j'enlève toute cette partie. Elle n'est pas assez bien faite, ni testée, pour être gardée.

La partie est-elle terminée ?

Bien. La partie est terminée, je peux remettre le test qui le vérifie au niveau de la session.

    self.assertTrue(self.session.is_finished())

Bien entendu, comme la fonction n'est toujours pas implémentée, l'exception NotImplementedError est lancée.

L'implémentation n'est pas bien compliquée :

    class Session:
        def is_finished(self):
            return self._game.has_winner()

L'implémentation n'est toujours pas complètement correcte car je n'ai toujours pas implémentée la détection des 'boucles' de jeu : le jeu s'arrête lorsque les deux joueurs effectuent trois fois un mouvement qui amènent à la même situation de jeu, et il n'y a pas de gagnant.

Je laisse ça à plus tard, si nécessaire.