Python, POO, Tests Unitaires et Documentation

TP 2
Author

Rémi Pépin, Ludovic Deneuville

Avant de commencer

Ce TP mêle explications et phases de code.

Les explications de ce TP ne doivent pas prendre le pas sur celles de votre intervenant. Prenez les comme une base de connaissance pour plus tard, mais préférez toujours les explications orales.

Notions abordées
  • Rappels sur la POO
  • Tests Unitaires
  • Objets métier
  • Patron de conception strategy

1 Ce que vous allez coder

Notre jeu s’inspirera de Pokémon Unite (aucune connaissance du jeu, ni de Pokémon n’est nécessaire).

En quelques mots, il y aura des Pokemons qui s’affronteront lors de Battles en lançant des Attacks.

1.1 Objets métier

Vous allez créer les objets métier pour un jeu Pokémon.

Objet métier (business object)

Un objet métier est une représentation d’une entité spécifique dans le contexte d’une application métier. Il encapsule les données et les comportements associés à cette entité et est souvent utilisé pour modéliser des concepts du monde réel dans le domaine de l’application.

Cela conduit à avoir des objets contenant essentiellement des attributs et très peu de méthodes autre que des getter et setter.

Pour prendre un autre exemple, voici des objets métier pour une application simple qui gère la location de vélos :

  • Velo : Représente un vélo (id, type, modèle…)
  • Client : Représente une personne (id, nom, prénom…)
  • Location : Représente une transaction de location (id, Client, Velo, debut, fin, montant…)

1.2 Les objets métier de l’appli

  • Pokemon : qui ont diverses caractéristiques et statistiques
    • un nom
    • un niveau
    • un type
      • ici les types sont : Attaquant, Défenseur, Polyvalant, Soutient et Rapide (et non pas : Feu, Eau, Plante…)
    • des statistiques contenues dans un objet Statistic défini ci-dessous
  • Statistic : pour éviter de surcharger la classe Pokemon, leurs stats sont stockées dans un objet de la classe Statistic
    • hp : health points
    • attack, defense, speed… : qui serviront déterminer la force de ses attaques
  • Attack : différents types d’attaques dont disposeront les Pokemons (partie 3)
  • Battle : servira à faire s’affronter 2 Pokemons pour déterminer l’issue du combat (partie 4)

1.3 Diagramme de classes

Voici un diagramme de classes très simplifié des classes principales à coder lors de ces TP.

classDiagram
  class Pokemon {
    # _name : str
    # _level : int
    # _type : str
    # _current_stat : Statistic
    + get_pokemon_attack_coef() float
  }
 
  class Statistic {
    - __hp : int
    - __attack : int
    - __defense : int
    - __sp_atk : int
    - __sp_def : int
    - __speed : int
  }

  class Attack{
    # _name : str
    # _power : int
    # _description : str
    + compute_damage(Pokemon, Pokemon) int
  }

  class Battle{
    - first_monstie : Pokemon
    - second_monstie : Pokemon
    - winner : Pokemon
  }

  Pokemon o-- Statistic : possesses
  Pokemon o-- Attack : uses
  Battle o.."2" Pokemon : call

2 Clone du dépôt

Le code du TP se trouve sur GitHub, vous allez créer un clone local.

    • À la main ou avec cette commande : mkdir -p /p/Cours2A/UE3-Complements-info/TP2 && cd $_
      • Elle crée l’arborescence demandée (mkdir)
      • Puis elle vous positionne dans ce dossier (cd)
    • git clone https://github.com/ludo2ne/ENSAI-2A-complement-info-TP.git

2.1 Ouverture dans VSCode

    • File > Open Folder
    • Allez dans le dossier P:\Cours2A\UE3-Complements-info\TP2
    • Cliquez une seule fois sur 📁 ENSAI-2A-complement-info-TP
    • Puis sur le bouton Sélectionner un dossier
Important
    • L’Explorer, à gauche, permet d’explorer l’arborsence des fichiers et dossiers

⚠️ Si le dossier parent n’est pas le bon, recommencez l’Open Folder où vous aurez de gros soucis par la suite !!!

    • Terminal > New Terminal (ou CTRL + ù)

3 Modélisation et implémentation

Dans un premier temps, nous allons travailler uniquement sur les Pokemons (la classe Statistic est déjà codée).

Avant d’écrire du code, nous allons réfléchir à la meilleure conception possible pour réaliser nos Pokémons. Notre conception essaiera au maximum de respecter la règle suivante : faible couplage, forte cohésion.

En d’autre termes nous allons essayer de faire :

  • des classes les plus disjointes possibles (faible couplage) pour qu’une modification dans une classe ne nous demande pas de modifier les autres
  • tout en essayant d’avoir dans chaque classe les tâches les plus liées possibles (forte cohésion).

3.1 Première approche : le « if/elif/else »

Nous nous interessons à la méthode get_pokemon_attack_coef() qui va servir à déterminer la puissance de l’attaque en fonction du type de Pokémon.

    • Imaginez s’il y avait plusieurs blocs de code similaires dans notre application, et que nous devions ajouter un nouveau type

3.2 La puissance de la POO

Au lieu d’externaliser les comportements de nos Pokemons, nous allons mettre tous leurs comportements spécifiques dans des classes filles d’une super classe Pokemon. Ceci est rendu possible grâce à deux propriétés des objets en POO :

  • héritage : il est possible de spécialiser une classe existante en modifiant son comportement, ou en ajoutant de nouveaux
  • polymorphisme : deux fonctions peuvent avoir le même nom mais avoir des comportements différents

En plus, comme chacun de nos Pokemons va forcement être d’un type, aucun ne sera simplement de la classe Pokemon, cela nous permet de rendre cette classe abstraite. En définissant clairement notre classe abstraite nous allons avoir :

  • Un plan pour toutes les classes qui en héritent. Cela à pour avantages de :
    • Donner des informations sur la structuration du code
    • Permettre de générer automatiquement les méthodes à définir
    • Limiter les bug. Si on oublie une méthode, le code plante au démarrage, ce qui évite des comportements non prévus difficiles à détecter
  • Une interface unique pour tous les types de Pokemons. Quelque soit le type du Pokemon, il sera considéré comme un AbstractPokemon partout dans le code.

    • renommez également le fichier en abstract_pokemon.py

Vous devriez arriver à la fin à une arborescence proche de celle-ci :

ENSAI-2A-COMPLEMENTS-INFO-TP
    └── src
        └── business_object
            ├── pokemon
            │   ├── abstract_pokemon.py
            │   ├── attacker.py
            │   ├── defender.py
            │   └── all_rounder.py
            └── statistic.py

Pour faire une classe abstraite, utilisez le package abc.

Voici, pour vous inspirer, un exemple de ce qui est attendu :

abstract_personnage.py
from abc import ABC, abstractmethod

class AbstractPersonnage(ABC):
    def __init__(self, phrase_attaque:str, phrase_defense:str):
        self._phrase_attaque = phrase_attaque
        self._phrase_defense = phrase_defense
       
    @abstractmethod  # décorateur qui définit une méthode comme abstraite
    def degat_attaque(self) -> int:
        pass
magicien.py
from abstract_personnage import AbstractPersonnage

class Magicien(AbstractPersonnage):
    def __init__(self):
        super().__init__("Lance une boule de feu", "Utilise une barrière magique")
       
    def degat_attaque(self) -> int:
        return 10

Pour vous aider, voici le diagramme de classes :

classDiagram
  class AbstractPokemon {
    <<abstract>>
    # _name : str
    # _level : int
    # _type : str
    # _current_stat : Statistic
    + get_pokemon_attack_coef() float
  }
 
  class Statistic {
    - __hp : int
    - __attack : int
    - __defense : int
    - __sp_atk : int
    - __sp_def : int
    - __speed : int
  }
 
  AbstractPokemon <|-- Attacker
  AbstractPokemon <|-- Defender
  AbstractPokemon <|-- AllRounder
  AbstractPokemon o-- Statistic

3.3 Testez votre code

    • python -m pytest -v

Pour cela vous allez utiliser le package pytest de python.

Ce package permet de réaliser des tests unitaires dans des classes séparées. L’avantage par rapport à doctest, c’est que les tests ne “polluent” pas vos classes, et qu’il est possible de patcher certains comportements des classes.

Un exemple de test est donné dans la classe testDefenderPokemon. Pour rappel, un test se décompose en 3 parties :

  • GIVEN : création des objets nécessaires à la réalisation du test
  • WHEN : appel de la méthode à tester
  • THEN : vérification du résultat

Les classes de test seront organisées de la manière suivante, en reproduisant l’architecture de votre application :

ENSAI-2A-COMPLEMENTS-INFO-TP
    └── src
        ├── business_object
        │   ├── pokemon
        │   │   ├── abstract_pokemon.py
        │   │   ├── attacker.py
        │   │   ├── defender.py
        │   │   └── all_rounder.py
        │   └── statistic.py
        └── test
            └── test_business_object
                └── test_pokemon
                    ├── test_attacker.py
                    ├── test_defender.py
                    └── test_all_rounder.py
Si vous rencontrez des problèmes d’imports
  • Vérifiez que le dossier parent dans l’explorer de VSCode est : ENSAI-2A-complement-info-TP
  • Utilisez des chemins complets d’import plutôt que des chemins relatifs
    • la racine des chemins est paramétrée au niveau du dossier src
    • exemple : from business_object.pokemon.abstract_pokemon import AbstractPokemon
  • Créez des fichiers __init__.py (vide)
    • dans TOUS les dossiers que vous créez
    • c’est un peu pénible mais ça peut débloquer la situation

4 L’agrégation, l’autre façon d’ajouter de la souplesse dans le code

Maintenant que nos Pokémons sont faits, nous allons y ajouter les attaques.

Notre système va devoir respecter certaines contraintes :

  • Plusieurs types d’attaques vont coexister, chacune avec un mode de calcul de dégâts différent :
    • Des attaques à dégâts variables séparées en 2 types :
      • attaques “physiques” qui utilisent l’attaque et la défense des Pokémons
      • attaques “spéciales” qui utilisent l’attaque spé et la défense spé des Pokémons
    • Des attaques à dégâts fixes qui font un nombre fixe de dégâts.
  • Un pokémon peut avoir plusieurs attaques et le type de l’attaque doit être transparent pour le pokémon.

4.1 Attaques à dégâts fixes

Nous allons commencer par les attaques à dégâts fixes. Comme il y aura un autre type d’attaques, toutes les attaques hériterons de la classe abstraite AbstractAttack déjà créée. Cette classe possède la méthode abstraite compute_damage() qui devra être implémentée dans les classes filles.

classDiagram

  class AbstractAttack{
    <<abstract>>
    # _power : int
    # _name : str
    # _description : str
    + compute_damage(APkm, APkm)$  int
  }

   class FixedDamageAttack{
    + compute_damage(APkm,APkm )  int
   }
 
   AbstractAttack <|-- FixedDamageAttack

    • ainsi que sa méthode compute_damage() qui retournera simplement la puissance (power) de l’attaque

4.2 Attaques à dégâts variables

Nous allons ensuite coder les attaques à dégâts variables. Elles utilisent la formule suivante :

\[Damage = \big ( \frac{(\frac{2*Level}{5}+2)* Power *Att}{Def*50} +2\big) *random* other\_multipliers\]

avec :

  • \(Att\)​ : égal soit à l’attaque ou l’attaque spé du Pokemon attaquant
  • \(Def\)​​ : égal soit à la défense ou défense spé du Pokemon défenseur
  • \(Power\)​ : la valeur de puissance de l’attaque
  • \(random\)​​ :une valeur comprise dans l’intervalle [0.85; 1]
  • \(other\_ multipliers\) : les autres multiplicateurs possibles, comme le coefficient d’attaque des pokémons.

La seule différence entre attaque physique et spéciale vient des coefficients \(Att\) et \(Def\), le reste de la formule des dégâts est identique. Nous allons donc utiliser le patron de conception template method, dont voici la modélisation UML dans notre cas :

classDiagram

  class AbstractAttack{
    <<abstract>>
    # _power : int
    # _name : str
    # _description : str
    + compute_damage(APkm, APkm)$  int
  }

   class FixedDamageAttack{
    + compute_damage(APkm,APkm )  int
   }

  class AbstractFormulaAttack{
    <<abstract>>
    -get_attack_stat(APkm)$  float
    -get_defense_stat(APkm)$  float
    +compute_damage(APkm,APkm)  int
  }
 
  class PhysicalFormulaAttack{
    -get_attack_stat(APkm)  float
    -get_defense_stat(APkm)  float
  }
 
  class SpecialFormulaAttack{
    -get_attack_stat(APkm)  float
    -get_defense_stat(APkm)  float
  }
 
   AbstractAttack <|-- FixedDamageAttack
   AbstractAttack <|-- AbstractFormulaAttack
   AbstractFormulaAttack <|-- SpecialFormulaAttack
   AbstractFormulaAttack <|-- PhysicalFormulaAttack

La classe AbstractFormulaAttack va contenir les méthodes :

  • compute_damage(). Cette méthode va contenir la formule de calcul des dégâts
    • mais en appelant les méthodes get_attaque_stat() et get_defense_stat() pour savoir quelle statistique utiliser
  • get_attack_stat() et get_defense_stat() (abstraites). Ces méthodes devront être implémentées dans les classes filles pour déterminer quelles statistiques utiliser.

5 Architecture finale

Bonus

Si vous avez le temps !

Nous allons maintenant rattacher les bouts pour créer notre architecture finale :

classDiagram
  class AbstractPokemon {
    <<abstract>>
    # _current_stat : Statistique
    # _level : int
    # _name : str
    # _attack_list : List~AbstractAttack~
    +get_pokemon_attack_coef()$  float
    +level_up() None
  }
 
  class Statistique {
    - hp : int
    - attaque : int
    - defense : int
    - spe_atk : int
    - spe_def : int
    - vitesse : int
  }
   
  class BattleService {
    + resolve_battle(APkm, APkm) : Battle
    + get_order(APkm, APkm)
    + choose_attack(APkm) : AAttack
  }  
  
  class Battle{
    - first_monstie : APkm
    - second_monstie : APkm
    - winner : APkm
    - rounds : List<Round>
  }

  class Round{
    attacker: APkm
    defender: APkm
    dealt_damage: int
    attack_description: str
  }
  BattleService ..>"2" AbstractPokemon : use
  AbstractPokemon <|-- Attacker
  AbstractPokemon <|-- Defender
  AbstractPokemon <|-- AllRounder
  Statistique *-- AbstractPokemon

  Battle .. BattleService
  Battle .. Round
 
  class AbstractAttack{
    <<abstract>>
    # _power : int
    # _name : str
    # _description : str
    +compute_damage(APkm, APkm)$ int
  }

  class FixedDamageAttack{
    + compute_damage(APkm,APkm )  int
  }

  class AbstractFormulaAttack{
    <<abstract>>
    -get_attack_stat(APkm)$  float
    -get_defense_stat(APkm)$  float
    + compute_damage(APkm,APkm ) int
  }
 
  class PhysicalFormulaAttack{
   -get_attack_stat(APkm)$  float
   -get_defense_stat(APkm)$  float
  }
 
  class SpecialFormulaAttack{
    -get_attack_stat(APkm)  float
    -get_defense_stat(APkm)  float
  }
 
  AbstractAttack <|-- FixedDamageAttack
  AbstractAttack <|-- AbstractFormulaAttack
  AbstractFormulaAttack <|-- SpecialFormulaAttack
  AbstractFormulaAttack <|-- PhysicalFormulaAttack
  BattleService >.. AbstractAttack  : use
  AbstractPokemon o-->"0..*" AbstractAttack

Cette architecture permet de décorréler les attaques des pokémons et de spécifier le comportement des attaques au fur et à mesure des héritages. Les avantages sont :

  • Pour la classe AbstractPokemon, toutes les attaques sont des AbstractAttack. Tant qu’elles exposent la méthode compute_damage() notre programme va fonctionner. On peut ainsi facilement ajouter de nouveaux types d’attaques sans problème.
  • Un Pokémon peut avoir des attaques de tous les types
  • Nous pouvons ajouter un système d’état comme la paralysie ou le poison assez facilement. Il faut pour cela modifier la classe AbstractAttack et les classes qui en héritent. Cela sera potentiellement long, mais ne demande pas de toucher à la partie “Pokémon” de notre architecture.
  • Une personne pourrait se concentrer sur la création des Pokémons alors qu’une autre pourrait se concentrer sur celles des attaques sans difficulté. Les deux parties du code sont relativement indépendantes, la seule zone de couplage sont les classes AbstractPokemon et AbstractAttack, qui servent avant tout à définir ce qui doit être fait par les classes filles et ce qui est accessible à l’extérieur.

Le fait d’externaliser le comportement des attaques dans des classes spécifiques puis de les lier aux Pokémons via une relation d’agrégation assez souple qui permet de changer dynamiquement les attaques d’un Pokémon est le patron de conception strategy.


Correction

Vous avez la possibilité de consulter la correction soit :

  • en changeant votre dépôt local de branche
  • en consultant la branche adéquat sur le dépôt distant

Voici quelques commandes git utiles pour changer de branche :

# Lister toutes les branches
git branch -a

# Avant de changer de branche, créez un point de sauvegarde de votre travail
git add .
git commit -m "<message>"

# changer de branche
git checkout <nouvelle_branche>   # dans le terminal, la branche courante est indiquée entre ()
git checkout -                    # pour retourner à la branche précédente