Le but de ce BE est d'illustrer le concept d'héritage de la programmation objet, en concevant un module pour manipuler des formes géométriques avec _Python_. Vous commencerez par définir les classes et leurs attributs, puis implémenterez les méthodes, et les validerez avec des tests.
Nous allons aborder dans ce TD le concept d'héritage de la programmation objet, et l'utilisation de tests unitaires pour améliorer la qualité du logiciel.
Le but de ce TD est de concevoir un module pour manipuler des formes géométriques avec Python. Ce module sera utilisé dans les TDs suivants, donc les tests seront essentiels pour limiter les éventuels bugs. Vous commencerez par définir les classes et leurs attributs, puis implémenterez les méthodes, et les validerez avec des tests.
## Modélisation avec UML (1h)
---
## Modélisation avec UML (1h30)
Les formes géométriques sont représentées par des classes, et l'héritage sera utilisé pour factoriser les propriétés communes. Nous nous limitons à un repère à deux dimensions orthonormé, avec les axes croissant vers la droite et le bas. Les coordonnées dans ce repère sont des entiers relatifs (c'est-à-dire possiblement négatifs). Dans cet espace, nous choisissons de représenter les formes suivantes:
*Les rectangles caractérisés par leur origine (`x`, `y`) et leurs dimensions (`l`, `h`).
*Les ellipses caractérisées par leur origine (`x`, `y`) et leurs rayons aux axes (`rx`, `ry`).
* Un type de forme de votre choix (ex. triangle, polygone, étoile, ...), qui possède au moins une origine (`x`, `y`).
*Les cercles caractérisés par leur origine (`x`, `y`) et leur rayon.
__Exercice 1 -__ Représentez les 3 classes dans un diagramme de classes UML (_voir [diagrams.net](https://app.diagrams.net) pour dessiner en ligne, avec l'onglet UML sur la gauche de l'interface_). Il est recommandé de commencer les noms des classes par une majuscule et les attributs par une minuscule. Les attributs devraient-ils être publics ou privés ?
__Exercice 1 -__ Représentez les 3 classes dans un diagramme de classes UML (_voir https://app.diagrams.net pour dessiner en ligne, avec l'onglet UML sur la gauche de l'interface_). Il est recommandé de commencer les noms des classes par une majuscule et les attributs par une minuscule. Les attributs devraient-ils être publics ou privés ?
Les attributs `x` et `y` étant partagés par les trois classes, on introduit l'héritage pour les regrouper. Toutes les formes géométriques hériteront d'une même classe __Forme__. L'intérêt de cette classe est double:
Les attributs `x` et `y` étant partagés par les trois classes et le cercle étant un cas particulier d'ellipse, on introduit l'héritage pour les regrouper. Toutes les formes géométriques hériteront d'une même classe __Forme__, et le cercle héritera de l'ellipse. L'intérêt de ces relations d'héritage est double:
*Du point de vue des développeurs du module, les méthodes dont le code est identique entre formes (ex. translation) sont fusionnées dans __Forme__, réduisant la quantité de code à produire (et donc la multiplication des erreurs possibles).
* Du point de vue des utilisateurs du module, on peut écrire du code qui manipule des rectangles et des ellipses (*p. ex.* système de collisions de formes) sans avoir à écrire du code séparément pour les rectangles et les ellipses. Cet aspect sera illustré dans un prochain TD.
*Du point de vue des utilisateurs du module, on peut écrire du code qui manipule des rectangles et des ellipses (*p. ex.* système de collisions de formes) sans avoir à écrire du code séparément pour les rectangles et les ellipses. Cet aspect sera illustré dans un prochain BE.
__Exercice 2 -__ Mettez à jour le diagramme UML en incluant la classe __Forme__ et les relations d'héritage. Seuls les attributs seront inclus pour le moment.
Enfin, on vous demande de supporter a minima pour chaque forme les méthodes suivantes :
*`deplacement(dx, dy)`, qui effectue une translation selon un vecteur donné.
*`contient_point(x, y)`, qui renvoie `True` si et seulement si le point donné est à l'intérieur de la forme ou sur sa frontière.
*`redimension_par_points(x0, y0, x1, y1)`, qui redimensionne la forme pour faire correspondre sa [boîte englobante](https://en.wikipedia.org/wiki/Minimum_bounding_rectangle) avec celle représentée par les points donnés.
__Exercice 3 -__ Complétez le diagramme UML avec ces méthodes. Les constructeurs devront également être renseignés (méthode `__init__` en Python), ainsi que les méthodes d'affichage (méthode `__str__` en Python).
__Exercice 3 -__ Complétez le diagramme UML avec ces méthodes. Les constructeurs devront également être renseignés (méthode `__init__` en _Python_), ainsi que les méthodes d'affichage (méthode `__str__`).
__Exercice 4 -__ Écrivez un squelette de code correspondant à votre diagramme UML, dans un fichier _formes.py_. Seuls les constructeurs devront être implémentés. À l'intérieur des autres méthodes, vous mettrez l'instruction `pass` de Python (qui ne fait rien mais vous rappelle que le code est inachevé).
__Exercice 4 -__ Écrivez un squelette de code correspondant à votre diagramme UML, dans un fichier _formes.py_. Seuls les constructeurs devront être implémentés. À l'intérieur des autres méthodes, vous mettrez l'instruction `pass` de _Python_ (qui ne fait rien mais vous rappelle que le code est inachevé).
## Implémentation des méthodes (2h)
---
## Implémentation des méthodes (2h30)
Créez un fichier _test_formes.py_ dans le même dossier que _formes.py_ et initialisé avec le code suivant :
...
...
@@ -65,37 +59,49 @@ def test_Ellipse():
asserte.contient_point(500,500)
assertnote.contient_point(101,201)
deftest_Cercle():
c=Cercle(10,20,30)
str(c)
asserte.contient_point(0,0)
assertnote.contient_point(-19,-9)
e.redimension_par_points(100,200,1100,700)
asserte.contient_point(500,500)
assertnote.contient_point(599,500)
if__name__=='__main__':
test_Rectangle()
test_Ellipse()
test_Cercle()
```
La commande `assert` de Python permet de spécifier une assertion (une condition qui doit toujours être vraie) à un point du programme. Elle sert avant un bloc de code à en documenter les prérequis, et après un bloc de code à en vérifier les résultats. Son échec signifie alors un bug du programme. `assert` reçoit une expression (comme ce qu'on passe à `if`), et vérifie son résultat :
La commande `assert` de _Python_ permet de spécifier une assertion (une condition qui doit toujours être vraie) à un point du programme. Elle sert, avant un bloc de code, à en documenter les prérequis et, après un bloc de code, à en vérifier les résultats. Son échec signifie alors un bug du programme. `assert` reçoit une expression (comme ce qu'on passe à `if`), et vérifie son résultat :
* Si `True`, l'assertion est vraie donc pas de problème, `assert` ne fait rien.
* Si `False`, l'assertion est fausse donc une exception `AssertionError` est déclenchée.
* Si l'expression renvoie une autre valeur, celle-ci est convertie en booléen pour se ramener aux deux cas précédents.
La vérification de cette condition est faite une fois au moment de son exécution (l'assertion ne sera pas valide dans le reste du programme). Dans _test_formes.py_ on utilise `assert` pour tester une fonctionnalité qui n'est pas encore implémentée, l'exécution de ce fichier échouera tant que les méthodes de seront pas codées. À l'issue de cette partie, elle ne devra renvoyer aucune erreur !
La vérification de cette condition est faite une fois au moment de son exécution (l'assertion ne sera pas valide dans le reste du programme). Dans _test_formes.py_, on utilise `assert` pour tester une fonctionnalité qui n'est pas encore implémentée, l'exécution de ce fichier échouera tant que les méthodes de seront pas codées. À l'issue de cette partie, elle ne devra renvoyer plus aucune erreur !
__Exercice 5 -__ Implémentez les méthodes d'affichage (`__str__`) de chacune des classes dans _formes.py_. Vous pourrez vérifier leur bon fonctionnement en exécutant _formes.py_ (bouton Run File - F5), puis par exemple avec une commande `print(Rectangle(0, 0, 10, 10))` dans la console IPython.
__Exercice 5 -__ Implémentez les méthodes d'affichage (`__str__`) de chacune des classes dans _formes.py_. Vous pourrez vérifier leur bon fonctionnement en exécutant _formes.py_ (bouton `Run File - F5`), puis par exemple avec une commande `print(Rectangle(0, 0, 10, 10))` dans la console _IPython_.
__Exercice 6 -__ Implémentez les méthodes d'accès getter/setter pour les champs privés de chacune des classes. Pour vérifier que les champs sont bien privés, le code suivant __doit__ échouer avec une erreur `AttributeError` :
__Exercice 6 -__ Implémentez les méthodes d'accès (les fameux _getter_/_setter_) pour les champs privés de chacune des classes. Pour vérifier que les champs sont bien privés, le code suivant __doit__ échouer avec une erreur `AttributeError` :
```python
r=Rectangle(0,0,10,10)
print(r.__l,r.__h)
```
__Exercice 7 -__ Implémentez les méthodes `contient_point` des trois sous-classes. Vous vérifierez que les deux premiers `assert` des méthodes de test ne déclenchent pas d'erreur.
__Exercice 7 -__ Implémentez les méthodes `contient_point` des deux sous-classes. Vous vérifierez que les deux premiers `assert` des méthodes de test ne déclenchent pas d'erreur.
__Exercice 8 -__ Implémentez les méthodes `redimension_par_points` de chacune des sous-classes. Le fichier _test_formes.py_ doit à présent s'exécuter sans problème.
## Tests unitaires (1h)
Une fois développées, vos classes vont être utilisées pour des besoins que vous n'aviez pas forcément anticipés. Certains vont révéler des bugs, et vos classes seront amenées à évoluer, y compris pour acquérir de nouvelles méthodes. Les tests unitaires servent à documenter les cas d'utilisation supportés, et également à vous assurer qu'une modification de votre code n'a pas introduit un bug (une _régression_).
---
## Tests unitaires (bonus)
Une fois développées, vos classes vont être utilisées pour des besoins que vous n'aviez pas forcément anticipés. Elles vont évoluer également pour acquérir de nouvelles fonctionnalités, et vont gagner en complexité. Dans ces conditions, il est courant de voir apparaître des bugs. Une pratique répandue pour améliorer la qualité logicielle est de définir des _tests unitaires_, c'est-à-dire de créer des situations extrêmes et vérifier que vos fonctions donnent toujours de bons résultats. Les tests unitaires serviront à documenter les cas d'utilisation supportés, et également à vous assurer qu'une modification de votre code n'a pas introduit un bug (une _régression_).
On vous fournit une méthode de test plus exhaustive pour __Rectangle__ :
Voici une liste de tests relativement exhaustive pour la classe __Rectangle__
```python
deftest_Rectangle():
...
...
@@ -124,14 +130,14 @@ def test_Rectangle():
assertstr(r)==reference
```
__Exercice 9 -__ Exécutez ce test sur votre code, et corrigez les éventuels bugs. Représentez ensuite dans un logiciel de dessin (ex. https://app.diagrams.net/) le rectangle et les positions des points qui sont testés. Quels bugs sont visés par chacun de ces tests ?
__Exercice 9 -__ Exécutez ce test sur votre code, et corrigez les éventuels bugs. Représentez ensuite, dans un logiciel de dessin (ex. [diagrams.net](https://app.diagrams.net)), le rectangle et les positions des points qui sont testés. Quels bugs sont visés par chacun de ces tests ?
La rédaction de tests unitaires consiste souvent à anticiper les bugs courants, pour améliorer la qualité du logiciel dès sa conception. On cherche donc délibérément à provoquer des situations difficiles à gérer (ex. points _sur_ le bord du rectangle). De telles situations sont par exemple :
* le choix de `<` ou `<=` dans le code
* le traitement de valeurs négatives
* les erreurs d'arrondis dans les opérations avec `float`
* la gestion de valeurs nulles (ex. largeur ou hauteur)
* le choix de `<` ou `<=` dans le code;
* le traitement de valeurs négatives;
* les erreurs d'arrondis dans les opérations avec `float`;
* la gestion de valeurs nulles (ex. largeur ou hauteur).
__Exercice 10 -__ Dessinez une ellipse dans votre logiciel de dessin, et représentez tous les points qu'il convient de tester avec `contient_point`. Pour chaque point (ou groupe de points), indiquez le type de bug qu'il vise en particulier. Implémentez ces tests dans _test_formes.py_.
...
...
@@ -139,7 +145,7 @@ __Exercice 11 -__ Dessinez une forme issue de votre troisième classe dans le lo
## Pour aller plus loin
<!-- ## Pour aller plus loin
Lorsque votre programme utilise un grand nombre de tests unitaires, il est possible d'automatiser leur collecte, leur exécution, et l'affichage d'un rapport synthétique. On utilisera alors le module [_pytest_](https://docs.pytest.org/en/stable/) pour Python.
...
...
@@ -154,3 +160,4 @@ Si vous rencontrez une erreur comme `conda: command not found`, c'est que l'exé
__Exercice 12 -__ Vérifiez que _pytest_ est installé en exécutant la commande `import pytest` qui ne doit pas renvoyer d'erreur. Ensuite exécutez la commande `pytest.main()`. Combien de fichiers ont été "collectés" ? Combien de tests ont réussi ? Combien ont échoué ?
__Exercice 13 -__[pytest.raises](https://docs.pytest.org/en/stable/assert.html#assertions-about-expected-exceptions) permet de vérifier qu'un bloc de code déclenche une exception (`raises` ne fait rien si le code échoue, et échoue si le code ne déclenche pas d'exception). Lisez les exemples de la documentation et ajoutez des tests pour vérifier que les variables privées sont inaccessibles de l'extérieur de chaque classe.
L'objectif de ce TD est d'apprendre à manipuler quelques composants du module Python _Tkinter_ permettant de créer des interfaces graphiques. Vous allez créer une application simple de dessin vectoriel, qui permettra de tracer à la souris les formes définies dans le BE #2.
L'objectif de ce BE est d'apprendre à manipuler quelques composants du module _Python_ _Tkinter_ permettant de créer des interfaces graphiques. Vous allez créer une application simple de dessin vectoriel, qui permettra de tracer à la souris les formes définies dans le BE #2.
## Quelques éléments de Tkinter (45 min.)
---
## Quelques éléments sur _Tkinter_ (45 min.)
Le module _Tkinter_ (_"Tk interface"_) permet de créer des interfaces graphiques. Il contient de nombreux composants graphiques (ou _widgets_), tels que les boutons (classe __Button__), les cases à cocher (classe __CheckButton__), les étiquettes (classe __Label__), les zones d'entrée de texte (classe __Entry__), les menus (classe __Menu__), ou les zones de dessin (classe __Canvas__).
Durant ce BE, nous vous recommandons de conserver la [documentation de _Tkinter_](http://effbot.org/tkinterbook/) ouverte dans un onglet. Elle contient des exemples de code qui vous seront utiles pour utiliser chacun des _widgets_.
Durant ce BE, nous vous recommandons de conserver la [documentation de _Tkinter_](http://effbot.org/tkinterbook/) ouverte dans un onglet de votre navigateur. Elle contient des exemples de code qui vous seront utiles pour utiliser chacun des _widgets_.
Voici un premier exemple de code _Tkinter_ :
...
...
@@ -43,16 +43,16 @@ if __name__ == '__main__':
# association des commandes aux widgets
boutonLancer.config(command=tirage)# appel dit callback (pas de parenthèses)
boutonQuitter.config(command=racine.quit)# idem
racine.mainloop()# affichage de l'interface jusqu'à quit
racine.mainloop()# affichage de l'interface jusqu'à l'appui de Quitter
```
__Exercice 1 -__ Copiez le code suivant dans un fichier appelé *Exo1.py* et exécutez-le pour observer le résultat.
__Attention, utilisateurs de Mac__ : l'association _Spyder_+_Tkinter_ ne fonctionne pas bien sous Mac! Lorsque vous quitterez l'interface (par le biais du bouton _quitter_), la fenêtre va se bloquer (_freeze_). Deux solutions:
__Attention, utilisateurs de Mac__ : l'association _Spyder_+_Tkinter_ ne fonctionne pas bien sous Mac! Lorsque vous quitterez l'interface (par le biais du bouton _Quitter_), la fenêtre va se bloquer (_freeze_). Deux solutions:
- soit vous forcez l'application à s’arrêter à chaque fois (utilisez le menu contextuel sur l'icône de l'application concernée dans la barre d'outils);
- soit vous exécutez votre programme en ligne de commande. Pour cela, ouvrez un terminal dans le répertoire de travail (clic-droit dessus → Nouveau terminal au dossier). Puis lancer la commande : `python3 Exo1.py`. Vous devriez pouvoir quitter l'application sans difficulté. N'oubliez pas de sauvegarder votre fichier sous _Spyder_ avant toute exécution!
- soit vous exécutez votre programme en ligne de commande. Pour cela, ouvrez un terminal dans le répertoire de travail (clic-droit dessus → Nouveau terminal au dossier). Puis lancer la commande : `python3 Exo1.py`. Vous devriez pouvoir quitter l'application sans difficulté. N'oubliez pas de sauvegarder votre fichier sous _Spyder_ avant toute exécution de cette manière !
Prenez le temps d'étudier cet exemple, et répondez aux questions suivantes :
...
...
@@ -63,24 +63,26 @@ Prenez le temps d'étudier cet exemple, et répondez aux questions suivantes :
* Comment peut-on colorier le texte du label en rouge ?
---
## Squelette de l'application de dessin (45 min.)
On souhaite obtenir l'interface ci-dessous, dans laquelle les utilisateurs sélectionneront le type de forme à dessiner avec les boutons, et créeront une forme en cliquant dans la zone située sous la barre d'outils (_widget_ __Canvas__ de _Tkinter_). On a donné une couleur grise au fond de la fenêtre pour vous aider à déterminer les widgets présents.
On souhaite obtenir l'interface ci-dessous, dans laquelle les utilisateurs sélectionneront le type de forme à dessiner avec les boutons, et créeront une forme en cliquant dans la zone située sous la barre d'outils (_widget_ __Canvas__ de _Tkinter_). On a donné une couleur grise au fond de la fenêtre pour vous aider à déterminer les différents _widgets_ présents.
__Exercice 2 -__ Dessinez l'arbre de scène correspondant à cette capture d'écran.
Une pratique courante dans les interfaces graphiques est de créer des classes qui _remplacent_ des nœuds de l'arbre de scène, et d'y mettre le code de l'application. Ces classes héritent des classes de _Tkinter_ (pour pouvoir les remplacer dans l'arbre), et nous leur ajouterons des attributs et méthodes spécifiques à leurs responsabilités dans l'application de dessin. Nous allons ainsi introduire deux classes:
*la classe __ZoneAffichage__, qui hérite de __Canvas__ et gère toutes les opérations de dessin spécifiques à votre application.
* la classe __FenPrincipale__, qui hérite de __Tk__ et gère l'initialisation de l'arbre de scène et des _callbacks_ des widgets.
*la classe __FenPrincipale__, qui hérite de __Tk__ et gère l'initialisation de l'arbre de scène et des _callbacks_ des _widgets_.
__Exercice 3 -__ Complétez le code ci-dessous avec l'initialisation de votre arbre de scène. Vous utiliserez une instance de __ZoneAffichage__ à la place de __Canvas__. À ce stade, on ne vous demande pas de programmer les actions, uniquement de mettre en place le design de l'interface. Vous trouverez des exemples d'utilisation de chacun des widgets dans la documentation référencée plus haut.
__Exercice 3 -__ Complétez le code ci-dessous avec l'initialisation de votre arbre de scène. Vous utiliserez une instance de __ZoneAffichage__ à la place de __Canvas__. À ce stade, on ne vous demande pas de programmer les actions, uniquement de mettre en place le design de l'interface. Vous trouverez des exemples d'utilisation de chacun des _widgets_ dans la documentation référencée plus haut.
```python
from tkinter import *
...
...
@@ -99,7 +101,7 @@ if __name__ == "__main__":
fen.mainloop()
```
---
## Dessin de formes dans le canevas (60 min.)
Vous trouverez dans le dossier de ce BE le fichier [formes.py](formes.py) développé durant le BE #2. Nous avons agrémenté les classes __Rectangle__ et __Ellipse__ pour qu'elles reçoivent un canevas en argument et se dessinent dessus lors de leur initialisation. Téléchargez ce fichier dans votre répertoire de travail.
...
...
@@ -115,6 +117,7 @@ __Exercice 5 -__ À l'aide de la méthode `bind` vue en cours, reliez les clics
__Exercice 6 -__ Ajoutez un attribut à __ZoneAffichage__ qui stocke le type de forme actuellement sélectionné, et associez les boutons Rectangle/Ellipse au type de forme qui est dessiné lorsqu'on clique dans le canevas.
---
## Quelques opérations de dessin supplémentaires (90 min.)
Nous allons à présent intégrer quelques commandes simples dans l'application de dessin:
...
...
@@ -130,12 +133,13 @@ __Exercice 8 -__ À l'aide du module _colorchooser_ de _Tkinter_ (```from tkinte
__Exercice 9 -__ À l'aide des types d’événements `<Button-1>`, `<B1-Motion>` et `<ButtonRelease-1>`, implémentez la translation des formes lors des actions d'appui-déplacement de la souris. Comment faire pour qu'elles n'interfèrent pas avec la création de nouvelles formes ?
---
## Exercices bonus
Il n'y a pas d'ordre prédéfini pour ces trois exercices supplémentaires, choisissez celui dont la fonctionnalité vous semble la plus intéressante.
__Bonus 1 -__ Durant le BE #2 vous avez conçu un troisième type de forme. Il est temps de l'intégrer à votre application de dessin ! Inspirez-vous du code du fichier _formes.py_ de ce BE pour adapter la classe que vous aviez développée. Vous trouverez également les instructions de dessin dans la documentation de Tkinter sur __Canvas__.
__Bonus 2 -__ Maintenant que votre programme de dessin vectoriel est fonctionnel, il devrait être possible d'exporter chaque image produite dans un fichier. On utilise pour cela le format SVG, qui est un fichier texte contenant des instructions de dessin. Il suffit d'écrire `<svg width=600 height=400 xmlns=http://www.w3.org/2000/svg>` au début du fichier, `</svg>` à la fin, et d'insérer des balises [`rect`](https://developer.mozilla.org/fr/docs/Web/SVG/Element/rect) et [`ellipse`](https://developer.mozilla.org/fr/docs/Web/SVG/Element/ellipse) entre les deux. C'est à vous !
__Bonus 2 -__ Maintenant que votre programme de dessin vectoriel est fonctionnel, il devrait être possible d'exporter chaque image produite dans un fichier. On utilise pour cela le format SVG, qui est un fichier texte contenant des instructions de dessin. Il suffit d'écrire `<svg width=600 height=400 xmlns=http://www.w3.org/2000/svg>` au début du fichier, `</svg>` à la fin, et d'insérer des balises [`rect`](https://developer.mozilla.org/fr/docs/Web/SVG/Element/rect) et [`ellipse`](https://developer.mozilla.org/fr/docs/Web/SVG/Element/ellipse) entre les deux. C'est à vous de jouer !
__Bonus 3 -__ Dans tout programme de dessin respectable, on doit pouvoir dessiner des formes de tailles arbitraires (pas prédéfinies). À l'aide des types d’événements `<Button-1>`, `<B1-Motion>` et `<ButtonRelease-1>`, faites qu'un mouvement de souris avec le bouton enfoncé dessine une forme en tirant ses coins (lorsqu'il ne déplace pas une forme existante). Vous utiliserez les méthodes `redimension_par_points` des classes __Rectangle__ et __Ellipse__.
__Bonus 3 -__ Dans tout programme de dessin respectable, on doit pouvoir dessiner des formes de tailles arbitraires (pas prédéfinies). À l'aide des types d’événements `<Button-1>`, `<B1-Motion>` et `<ButtonRelease-1>`, faites qu'un mouvement de souris avec le bouton enfoncé dessine une forme en tirant ses coins (lorsqu'il ne déplace pas une forme existante). Vous pourrez utiliser les méthodes `redimension_par_points` des classes __Rectangle__ et __Ellipse__.