IdentifiantMot de passe
Loading...
Mot de passe oublié ?Je m'inscris ! (gratuit)

Plongez au coeur de Python ,De débutant à expert


précédentsommairesuivant

XIV. Ecriture des tests en premier

XIV-A. roman.py, étape 1

Maintenant que nos tests unitaires sont complets, il est temps d'écrire le code que nos cas de test essaient de tester. Nous allons faire cela par étapes, de manière à voir tous les cas échouer, puis à les voir passer un par un au fur et à mesure que nous remplissons les trous de roman.py.

Exemple 14.1. roman1.py

Ce fichier est disponible dans le sous-répertoire py/roman/stage1/ du répertoire des exemples.

Si vous ne l'avez pas déjà fait,vous pouvez télécharger cet exemple ainsi que les autres exemples du livre.

 
Sélectionnez
"""Convert to and from Roman numerals"""

#Define exceptions
class RomanError(Exception): pass                ***1***
class OutOfRangeError(RomanError): pass          ***2***
class NotIntegerError(RomanError): pass
class InvalidRomanNumeralError(RomanError): pass ***3***

def toRoman(n):
    """convert integer to Roman numeral"""
    pass                                         ***4***

def fromRoman(s):
    """convert Roman numeral to integer"""
    pass

****1*** C'est de cette manière que l'on défini ses propres exceptions en Python. Les exceptions sont des classes, on en crée de nouvelles en dérivant des exceptions existantes. Il est fortement recommandé (mais pas obligatoire) de dériver Exception, qui est la classe de base dont toutes les exceptions héritent. Ici, je définis RomanError (dérivée de Exception) comme classe de base de toutes mes autres exceptions à venir. C'est une question de style, j'aurais tout aussi bien pu dériver chaque exception directement de la classe Exception.

***2*** Les exceptions OutOfRangeError et NotIntegerError seront utilisées plus tard par toRoman pour signaler diverses sortes d'entrées invalides, tel que spécifié par ToRomanBadInput.

***3*** L'exception InvalidRomanNumeralError sera utilisée plus tard par fromRoman pour signaler une entrée invalide, comme spécifié par FromRomanBadInput.

***4*** A cette étape, nous voulons définir l'API de chacune de nos fonctions, mais nous ne voulons pas encore en écrire le code, nous les mettons donc en place à l'aide du mot réservé Python pass.

Et maintenant, l'instant décisif (roulement de tambour) : nous allons exécuter notre test unitaire avec cette ébauche de module. A ce niveau, chaque cas de test devrait échouer. En fait, si un cas de test passe à l'étape 1, il faut retourner à romantest.py et rechercher comment nous avons écrit un test inutile au point de passer avec des fonctions ne faisant rien.

Exécutez romantest1.py avec l'option de ligne de commande -v, qui donne une sortie plus détaillée pour voir exactement ce qui se passe à mesure que chaque test s'exécute. Si tout se passe bien, votre sortie devrait ressembler à ceci :

Exemple 14.2. Sortie de romantest1.py avec roman1.py

 
Sélectionnez
fromRoman should only accept uppercase input ... ERROR
toRoman should always return uppercase ... ERROR
fromRoman should fail with malformed antecedents ... FAIL
fromRoman should fail with repeated pairs of numerals ... FAIL
fromRoman should fail with too many repeated numerals ... FAIL
fromRoman should give known result with known input ... FAIL
toRoman should give known result with known input ... FAIL
fromRoman(toRoman(n))==n for all n ... FAIL
toRoman should fail with non-integer input ... FAIL
toRoman should fail with negative input ... FAIL
toRoman should fail with large input ... FAIL
toRoman should fail with 0 input ... FAIL

======================================================================
ERROR: fromRoman should only accept uppercase input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage1\romantest1.py", line 154, in testFromRomanCase
    roman1.fromRoman(numeral.upper())
AttributeError: 'None' object has no attribute 'upper'
======================================================================
ERROR: toRoman should always return uppercase
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage1\romantest1.py", line 148, in testToRomanCase
    self.assertEqual(numeral, numeral.upper())
AttributeError: 'None' object has no attribute 'upper'
======================================================================
FAIL: fromRoman should fail with malformed antecedents
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage1\romantest1.py", line 133, in testMalformedAntecedent
    self.assertRaises(roman1.InvalidRomanNumeralError, roman1.fromRoman, s)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: InvalidRomanNumeralError
======================================================================
FAIL: fromRoman should fail with repeated pairs of numerals
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage1\romantest1.py", line 127, in testRepeatedPairs
    self.assertRaises(roman1.InvalidRomanNumeralError, roman1.fromRoman, s)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: InvalidRomanNumeralError
======================================================================
FAIL: fromRoman should fail with too many repeated numerals
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage1\romantest1.py", line 122, in testTooManyRepeatedNumerals
    self.assertRaises(roman1.InvalidRomanNumeralError, roman1.fromRoman, s)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: InvalidRomanNumeralError
======================================================================
FAIL: fromRoman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage1\romantest1.py", line 99, in testFromRomanKnownValues
    self.assertEqual(integer, result)
  File "c:\python21\lib\unittest.py", line 273, in failUnlessEqual
    raise self.failureException, (msg or '%s != %s' % (first, second))
AssertionError: 1 != None
======================================================================
FAIL: toRoman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage1\romantest1.py", line 93, in testToRomanKnownValues
    self.assertEqual(numeral, result)
  File "c:\python21\lib\unittest.py", line 273, in failUnlessEqual
    raise self.failureException, (msg or '%s != %s' % (first, second))
AssertionError: I != None
======================================================================
FAIL: fromRoman(toRoman(n))==n for all n
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage1\romantest1.py", line 141, in testSanity
    self.assertEqual(integer, result)
  File "c:\python21\lib\unittest.py", line 273, in failUnlessEqual
    raise self.failureException, (msg or '%s != %s' % (first, second))
AssertionError: 1 != None
======================================================================
FAIL: toRoman should fail with non-integer input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage1\romantest1.py", line 116, in testNonInteger
    self.assertRaises(roman1.NotIntegerError, roman1.toRoman, 0.5)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: NotIntegerError
======================================================================
FAIL: toRoman should fail with negative input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage1\romantest1.py", line 112, in testNegative
    self.assertRaises(roman1.OutOfRangeError, roman1.toRoman, -1)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: OutOfRangeError
======================================================================
FAIL: toRoman should fail with large input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage1\romantest1.py", line 104, in testTooLarge
    self.assertRaises(roman1.OutOfRangeError, roman1.toRoman, 4000)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: OutOfRangeError
======================================================================
FAIL: toRoman should fail with 0 input                                 ***1***
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage1\romantest1.py", line 108, in testZero
    self.assertRaises(roman1.OutOfRangeError, roman1.toRoman, 0)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: OutOfRangeError                                        ***2***
----------------------------------------------------------------------
Ran 12 tests in 0.040s                                                 ***3***

FAILED (failures=10, errors=2)                                         ***4***

***1*** Lancer le script exécute unittest.main(), qui exécute chaque cas de test, c'est à dire chaque méthode de chaque classe dans romantest.py. Pour chaque cas de test, il affiche la doc string de la méthode et le résultat du test. Comme il était attendu, aucun de nos cas de test ne passe.

***2*** Pour chaque test échoué, unittest affiche la trace de pile montrant exactement ce qui s'est passé. Dans le cas présent, notre appel à assertRaises (appelé aussi failUnlessRaises) a déclenché une exception AssertionError car il s'attendait à ce que toRoman déclenche une exception OutOfRangeError, ce qui ne s'est pas produit.

***3*** Après le détail, unittest affiche en résumé le nombre de tests réalisés et le temps que cela a pris.

***4*** Le test unitaire dans son ensemble a échoué puisqu'au moins un cas de test n'est pas passé. Lorsqu'un cas de test ne passe pas, unittest distingue les échecs des erreurs. Un échec est un appel à une méthode assertXYZ, comme assertEqual ou assertRaises, qui échoue parce que la condition de l'assertion n'est pas vraie ou que l'exception attendue n'a pas été déclenchée. Une erreur est tout autre sorte d'exception déclenchée dans le code que l'on teste ou dans le test unitaire lui-même. Par exemple, la méthode testFromRomanCasefromRoman doit seulement accepter une entrée en majuscules») provoque une erreur parce que l'appel à numeral.upper() déclenche une exception AttributeError, toRoman étant supposé retourner une chaîne mais ne l'ayant pas fait. Mais testZerofromRoman doit échouer avec 0 en entrée») provoque un échec parce que l'appel à fromRoman n'a pas déclenché l'exception InvalidRomanNumeral que assertRaises attendait.

XIV-B. roman.py, étape 2

Maintenant que nous avons la structure de notre module roman en place, il est temps de commencer à écrire du code et à passer les cas de test.

Exemple 14.3. roman2.py

Ce fichier est disponible dans le sous-répertoire py/roman/stage2/ du répertoire des exemples.

Si vous ne l'avez pas déjà fait,vous pouvez télécharger cet exemple ainsi que les autres exemples du livre.

 
Sélectionnez
"""Convert to and from Roman numerals"""

#Define exceptions
class RomanError(Exception): pass
class OutOfRangeError(RomanError): pass
class NotIntegerError(RomanError): pass
class InvalidRomanNumeralError(RomanError): pass

#Define digit mapping
romanNumeralMap = (('M',  1000), ***1***
                   ('CM', 900),
                   ('D',  500),
                   ('CD', 400),
                   ('C',  100),
                   ('XC', 90),
                   ('L',  50),
                   ('XL', 40),
                   ('X',  10),
                   ('IX', 9),
                   ('V',  5),
                   ('IV', 4),
                   ('I',  1))

def toRoman(n):
    """convert integer to Roman numeral"""
    result = ""
    for numeral, integer in romanNumeralMap:
        while n >= integer:      ***2***
            result += numeral
            n -= integer
    return result

def fromRoman(s):
    """convert Roman numeral to integer"""
    pass

***1*** romanNumeralMap est un tuple de tuples qui définit trois choses :

  1. La représentation en caractères des chiffres romains les plus élémentaires. Notez qu'il ne s'agit pas seulement des chiffres romains à un seul caractère mais que nous définissons également des paires comme CM («cent de moins que mille»). Cela rendra notre code pour toRoman plus simple.
  2. L'ordre des chiffres romains. Ils sont listés par ordre décroissant de valeur, de M jusqu'à I.
  3. La valeur de chaque chiffre romain. Chaque tuple est une paire de (romain, valeur).

***2*** C'est ici que nous bénéficions de notre structure de données élaborée, nous n'avons pas besoin de logique particulière pour prendre en charge la règle de soustraction. Pour convertir en chiffres romains, nous parcourons simplement romanNumeralMap à la recherche de la plus grande valeur entière inférieure ou égale à notre entrée. Une fois que nous l'avons trouvée, nous ajoutons sa représentation en chiffres romains à la fin de la sortie, soustrayons la valeur de l'entrée et répétons l'opération.

Exemple 14.4. Comment toRoman fonctionne

Si vous n'êtes pas sûr de comprendre comment fonctionne toRoman, ajoutez une instruction print à la fin de la boucle while :

 
Sélectionnez
        while n >= integer:
            result += numeral
            n -= integer
            print 'subtracting', integer, 'from input, adding', numeral, 'to output'

>>> import roman2
>>> roman2.toRoman(1424)
subtracting 1000 from input, adding M to output
subtracting 400 from input, adding CD to output
subtracting 10 from input, adding X to output
subtracting 10 from input, adding X to output
subtracting 4 from input, adding IV to output
'MCDXXIV'

toRoman a donc l'air de marcher, du moins pour notre petit test manuel. Mais passera-t-il l test unitaire ? Et bien non, pas complètement.

Exemple 14.5. Sortie de romantest2.py avec roman2.py

Rappelez-vous d'exécuter romantest2.py avec l'option de ligne de commande -v pour obtenir une sortie détaillée.

 
Sélectionnez
fromRoman should only accept uppercase input ... FAIL
toRoman should always return uppercase ... ok                  ***1***
fromRoman should fail with malformed antecedents ... FAIL
fromRoman should fail with repeated pairs of numerals ... FAIL
fromRoman should fail with too many repeated numerals ... FAIL
fromRoman should give known result with known input ... FAIL
toRoman should give known result with known input ... ok       ***2***
fromRoman(toRoman(n))==n for all n ... FAIL
toRoman should fail with non-integer input ... FAIL            ***3***
toRoman should fail with negative input ... FAIL
toRoman should fail with large input ... FAIL
toRoman should fail with 0 input ... FAIL

***1*** toRoman retourne bien toujours des majuscules puisque notre romanNumeralMap définit les représentation en nombres romains en majuscules. Donc ce test passe.

***2*** Voici la grande nouvelle : cette version de la fonction toRoman passe le test des valeurs connues. Rappelez-vous, elle n'est pas exhaustive mais elle teste largement la fonction avec un ensemble d'entrées correctes, y compris les entrées pour chaque nombre romain d'un caractère, l'entrée la plus grande possible (3999) et l'entrée produisant le nombre romain le plus long (3888). Arrivé là, on peut être raisonnablement confiant que la fonction marche pour toute valeur correcte qu'il lui est soumise.

***3*** Par contre, la fonction ne «marche» pas pour les valeurs incorrectes, elle échoue pour tous les tests de valeurs incorrectes. Cela semble logique puisque nous n'avons pas écrit de vérification d'entrée. Ces cas de test attendent le déclenchement d'exceptions spécifiques (à l'aide de assertRaises) et nous ne les déclenchons jamais. Nous le ferons à l'étape suivante.

Voici le reste de la sortie du test unitaire, détaillant tous les échecs. Nous n'en sommes plus qu'à 10.

 
Sélectionnez
======================================================================
FAIL: fromRoman should only accept uppercase input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage2\romantest2.py", line 156, in testFromRomanCase
    roman2.fromRoman, numeral.lower())
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: InvalidRomanNumeralError
======================================================================
FAIL: fromRoman should fail with malformed antecedents
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage2\romantest2.py", line 133, in testMalformedAntecedent
    self.assertRaises(roman2.InvalidRomanNumeralError, roman2.fromRoman, s)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: InvalidRomanNumeralError
======================================================================
FAIL: fromRoman should fail with repeated pairs of numerals
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage2\romantest2.py", line 127, in testRepeatedPairs
    self.assertRaises(roman2.InvalidRomanNumeralError, roman2.fromRoman, s)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: InvalidRomanNumeralError
======================================================================
FAIL: fromRoman should fail with too many repeated numerals
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage2\romantest2.py", line 122, in testTooManyRepeatedNumerals
    self.assertRaises(roman2.InvalidRomanNumeralError, roman2.fromRoman, s)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: InvalidRomanNumeralError
======================================================================
FAIL: fromRoman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage2\romantest2.py", line 99, in testFromRomanKnownValues
    self.assertEqual(integer, result)
  File "c:\python21\lib\unittest.py", line 273, in failUnlessEqual
    raise self.failureException, (msg or '%s != %s' % (first, second))
AssertionError: 1 != None
======================================================================
FAIL: fromRoman(toRoman(n))==n for all n
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage2\romantest2.py", line 141, in testSanity
    self.assertEqual(integer, result)
  File "c:\python21\lib\unittest.py", line 273, in failUnlessEqual
    raise self.failureException, (msg or '%s != %s' % (first, second))
AssertionError: 1 != None
======================================================================
FAIL: toRoman should fail with non-integer input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage2\romantest2.py", line 116, in testNonInteger
    self.assertRaises(roman2.NotIntegerError, roman2.toRoman, 0.5)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: NotIntegerError
======================================================================
FAIL: toRoman should fail with negative input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage2\romantest2.py", line 112, in testNegative
    self.assertRaises(roman2.OutOfRangeError, roman2.toRoman, -1)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: OutOfRangeError
======================================================================
FAIL: toRoman should fail with large input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage2\romantest2.py", line 104, in testTooLarge
    self.assertRaises(roman2.OutOfRangeError, roman2.toRoman, 4000)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: OutOfRangeError
======================================================================
FAIL: toRoman should fail with 0 input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage2\romantest2.py", line 108, in testZero
    self.assertRaises(roman2.OutOfRangeError, roman2.toRoman, 0)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: OutOfRangeError
----------------------------------------------------------------------
Ran 12 tests in 0.320s

FAILED (failures=10)

XIV-C. roman.py, étape 3

Maintenant que toRoman se comporte correctement avec des entrées correctes (des entiers de 1 à 3999), il est temps de faire en sorte qu'il se comporte bien avec des entrées incorrectes (tout le reste).

Exemple 14.6. roman3.py

Ce fichier est disponible dans le sous-répertoire py/roman/stage3/ du répertoire des exemples.

Si vous ne l'avez pas déjà fait,vous pouvez télécharger cet exemple ainsi que les autres exemples du livre.

 
Sélectionnez
"""Convert to and from Roman numerals"""

#Define exceptions
class RomanError(Exception): pass
class OutOfRangeError(RomanError): pass
class NotIntegerError(RomanError): pass
class InvalidRomanNumeralError(RomanError): pass

#Define digit mapping
romanNumeralMap = (('M',  1000),
                   ('CM', 900),
                   ('D',  500),
                   ('CD', 400),
                   ('C',  100),
                   ('XC', 90),
                   ('L',  50),
                   ('XL', 40),
                   ('X',  10),
                   ('IX', 9),
                   ('V',  5),
                   ('IV', 4),
                   ('I',  1))

def toRoman(n):
    """convert integer to Roman numeral"""
    if not (0 < n < 4000):                                             ***1***
        raise OutOfRangeError, "number out of range (must be 1..3999)" ***2***
    if int(n) <> n:                                                    ***3***
        raise NotIntegerError, "non-integers can not be converted"

    result = ""                                                        ***4***
    for numeral, integer in romanNumeralMap:
        while n >= integer:
            result += numeral
            n -= integer
    return result

def fromRoman(s):
    """convert Roman numeral to integer"""
    pass

***1*** Voici un beau raccourci Pythonique : les comparaisons multiples. C'est l'équivalent de if not ((0 < n) and (n < 4000)), mais en beaucoup plus lisible. C'est notre vérification d'étendue, elle doit intercepter les entrées trop grandes, négatives ou égales à zéro.

***2*** Pour déclencher vous-même une exception, utilisez l'instruction raise. Vous pouvez déclencher n'importe quelle exception prédéfinie ou que vous avez défini vous-même. Le deuxième paramètre, le message d'erreur, est optionnel, il est affiché dans la trace de pile qui est affichée si l'exception n'est pas prise en charge.

***3*** Ceci est notre vérification de nombre décimal. Les nombres décimaux ne peuvent pas être convertis en chiffres romains.

***4*** Le reste de la fonction est inchangé.

Exemple 14.7. Gestion des entrées incorrectes par toRoman

 
Sélectionnez
>>> import roman3
>>> roman3.toRoman(4000)
Traceback (most recent call last):
  File "<interactive input>", line 1, in ?
  File "roman3.py", line 27, in toRoman
    raise OutOfRangeError, "number out of range (must be 1..3999)"
OutOfRangeError: number out of range (must be 1..3999)
>>> roman3.toRoman(1.5)
Traceback (most recent call last):
  File "<interactive input>", line 1, in ?
  File "roman3.py", line 29, in toRoman
    raise NotIntegerError, "non-integers can not be converted"
NotIntegerError: non-integers can not be converted

Exemple 14.8. Sortie de romantest3.py avec roman3.py

 
Sélectionnez
fromRoman should only accept uppercase input ... FAIL
toRoman should always return uppercase ... ok
fromRoman should fail with malformed antecedents ... FAIL
fromRoman should fail with repeated pairs of numerals ... FAIL
fromRoman should fail with too many repeated numerals ... FAIL
fromRoman should give known result with known input ... FAIL
toRoman should give known result with known input ... ok ***1***
fromRoman(toRoman(n))==n for all n ... FAIL
toRoman should fail with non-integer input ... ok        ***2***
toRoman should fail with negative input ... ok           ***3***
toRoman should fail with large input ... ok
toRoman should fail with 0 input ... ok

***1*** toRoman passe toujours le test des valeurs connues, ce qui est réconfortant. Tous les tests qui passaient à l'étape 2 passent toujours, donc notre nouveau code n'a rien endommagé.

***2*** Plus enthousiasmant, maintenant notre test de valeurs incorrectes passe. Ce test, testDecimal, passe grâce à la vérification int(n) <> n. Lorsqu'un nombre décimal est passé à toRoman, int(n) <> n le voit et déclenche l'exception NotIntegerError, qui est ce que testDecimalattend.

***3*** Ce test, testNegative, passe grâce à la vérification not (0 < n < 4000), qui déclenche une exception OutOfRangeError, qui est ce que testNegative attend.

 
Sélectionnez
======================================================================
FAIL: fromRoman should only accept uppercase input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage3\romantest3.py", line 156, in testFromRomanCase
    roman3.fromRoman, numeral.lower())
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: InvalidRomanNumeralError
======================================================================
FAIL: fromRoman should fail with malformed antecedents
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage3\romantest3.py", line 133, in testMalformedAntecedent
    self.assertRaises(roman3.InvalidRomanNumeralError, roman3.fromRoman, s)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: InvalidRomanNumeralError
======================================================================
FAIL: fromRoman should fail with repeated pairs of numerals
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage3\romantest3.py", line 127, in testRepeatedPairs
    self.assertRaises(roman3.InvalidRomanNumeralError, roman3.fromRoman, s)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: InvalidRomanNumeralError
======================================================================
FAIL: fromRoman should fail with too many repeated numerals
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage3\romantest3.py", line 122, in testTooManyRepeatedNumerals
    self.assertRaises(roman3.InvalidRomanNumeralError, roman3.fromRoman, s)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: InvalidRomanNumeralError
======================================================================
FAIL: fromRoman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage3\romantest3.py", line 99, in testFromRomanKnownValues
    self.assertEqual(integer, result)
  File "c:\python21\lib\unittest.py", line 273, in failUnlessEqual
    raise self.failureException, (msg or '%s != %s' % (first, second))
AssertionError: 1 != None
======================================================================
FAIL: fromRoman(toRoman(n))==n for all n
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage3\romantest3.py", line 141, in testSanity
    self.assertEqual(integer, result)
  File "c:\python21\lib\unittest.py", line 273, in failUnlessEqual
    raise self.failureException, (msg or '%s != %s' % (first, second))
AssertionError: 1 != None
----------------------------------------------------------------------
Ran 12 tests in 0.401s
FAILED (failures=6) ***1***

***1*** Nous n'en sommes plus qu'à 6 échecs, tous ayant trait à fromRoman : le test de valeurs connues, les trois tests de valeurs incorrectes, le test de casse et le test de cohérence. Cela signifie que toRoman a passé tous les tests qu'il peut passer par lui-même. (Il joue un rôle dans le test de cohérence, mais ce test à également besoin de fromRoman, qui n'est pas encore écrit.) Cela veut dire que nous devons arrêter d'écrire le code de toRoman immédiatement. Pas de réglages, pas de bidouilles et pas de vérification supplémentaires «au cas où». Arrêtez. Maintenant. Ecartez vous du clavier.

La chose la plus importante que des tests unitaires complets vous disent est quand vous arrêter d'écrire du code. Quand tous les tests unitaires d'une fonction passent, arrêtez d'écrire le code de la fonction. Quand tous les tests d'un module passent, arrêtez d'écrire le code du module.

XIV-D. roman.py, étape 4

Maintenant que toRoman est terminé, nous devons passer à fromRoman. Grâce à notre structure de données élaborée qui fait correspondre les nombres romains à des valeurs entières, ce n'est pas plus difficile que pour toRoman.

Exemple 14.9. roman4.py

Ce fichier est disponible dans le sous-répertoire py/roman/stage4/ du répertoire des exemples.

Si vous ne l'avez pas déjà fait,vous pouvez télécharger cet exemple ainsi que les autres exemples du livre.

 
Sélectionnez
"""Convert to and from Roman numerals"""

#Define exceptions
class RomanError(Exception): pass
class OutOfRangeError(RomanError): pass
class NotIntegerError(RomanError): pass
class InvalidRomanNumeralError(RomanError): pass

#Define digit mapping
romanNumeralMap = (('M',  1000),
                   ('CM', 900),
                   ('D',  500),
                   ('CD', 400),
                   ('C',  100),
                   ('XC', 90),
                   ('L',  50),
                   ('XL', 40),
                   ('X',  10),
                   ('IX', 9),
                   ('V',  5),
                   ('IV', 4),
                   ('I',  1))

# toRoman function omitted for clarity (it hasn't changed)

def fromRoman(s):
    """convert Roman numeral to integer"""
    result = 0
    index = 0
    for numeral, integer in romanNumeralMap:
        while s[index:index+len(numeral)] == numeral: ***1***
            result += integer
            index += len(numeral)
    return result

***1*** Le principe est le même que pour toRoman. Nous parcourons notre structure de données de chiffres romains (un tuple de tuples) et au lieu de chercher la plus grande valeur possible, nous cherchons la chaîne représentant les chiffres romains les plus «haut» possible.

Exemple 14.10. Comment fromRoman fonctionne

Si vous n'êtes pas sûr de comprendre comment fonctionne fromRoman, ajoutez une instruction print à la fin de la boucle while :

 
Sélectionnez
        while s[index:index+len(numeral)] == numeral:
            result += integer
            index += len(numeral)
            print 'found', numeral, 'of length', len(numeral), ', adding', integer

>>> import roman4
>>> roman4.fromRoman('MCMLXXII')
found M , of length 1, adding 1000
found CM , of length 2, adding 900
found L , of length 1, adding 50
found X , of length 1, adding 10
found X , of length 1, adding 10
found I , of length 1, adding 1
found I , of length 1, adding 1
1972

Exemple 14.11. Output of romantest4.py against roman4.py

 
Sélectionnez
fromRoman should only accept uppercase input ... FAIL
toRoman should always return uppercase ... ok
fromRoman should fail with malformed antecedents ... FAIL
fromRoman should fail with repeated pairs of numerals ... FAIL
fromRoman should fail with too many repeated numerals ... FAIL
fromRoman should give known result with known input ... ok ***1***
toRoman should give known result with known input ... ok
fromRoman(toRoman(n))==n for all n ... ok                  ***2***
toRoman should fail with non-integer input ... ok
toRoman should fail with negative input ... ok
toRoman should fail with large input ... ok
toRoman should fail with 0 input ... ok

***1*** Il y a deux bonnes nouvelles. La première est que fromRoman marche pour des entrées correctes, au moins pour toutes les valeurs connues que nous testons.

***2*** La deuxième est que notre test de cohérence passe également. Ces deux résultats nous permettent d'être raisonnablement sûrs que toRoman comme fromRoman marchent correctement pour toutes les valeurs correctes. (Cela n'est pas garanti, il est théoriquement possible que toRoman ait un bogue qui produise des chiffres romains erronés pour certaines entrées, et que fromRoman ait un bogue correspondant produisant les mêmes valeurs entières erronées pour les mêmes chiffres romains. En fonction de votre application et de vos besoins, cette possibilité peut vous déranger ; dans ce cas écrivez des tests plus exhaustifs jusqu'à ce que vous ayez l'esprit tranquille.)

 
Sélectionnez
======================================================================
FAIL: fromRoman should only accept uppercase input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage4\romantest4.py", line 156, in testFromRomanCase
    roman4.fromRoman, numeral.lower())
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: InvalidRomanNumeralError
======================================================================
FAIL: fromRoman should fail with malformed antecedents
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage4\romantest4.py", line 133, in testMalformedAntecedent
    self.assertRaises(roman4.InvalidRomanNumeralError, roman4.fromRoman, s)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: InvalidRomanNumeralError
======================================================================
FAIL: fromRoman should fail with repeated pairs of numerals
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage4\romantest4.py", line 127, in testRepeatedPairs
    self.assertRaises(roman4.InvalidRomanNumeralError, roman4.fromRoman, s)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: InvalidRomanNumeralError
======================================================================
FAIL: fromRoman should fail with too many repeated numerals
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage4\romantest4.py", line 122, in testTooManyRepeatedNumerals
    self.assertRaises(roman4.InvalidRomanNumeralError, roman4.fromRoman, s)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: InvalidRomanNumeralError
----------------------------------------------------------------------
Ran 12 tests in 1.222s

FAILED (failures=4)

XIV-E. roman.py, étape 5

Maintenant que fromRoman fonctionne pour des entrées correctes, nous devons mettre en place la dernière pièce du puzzle : le faire fonctionner avec des entrées incorrectes. Cela veut dire trouver une manière d'examiner une chaîne et de déterminer si elle constitue un nombre en chiffres romains valide. C'est intrinsèquement plus difficile que de valider une entrée numérique dans toRoman, mais nous avons un outil puissant à notre disposition : les expressions régulières.

Si vous n'êtes pas familiarisé avec les expressions régulières et que vous n'avez pas lu le Chapitre 7, Expressions régulières, il est sans doute temps de le faire.

Comme nous l'avons vu au Section 7.3, «Exemple : chiffres romains», il y a plusieurs règles simples pour construire des nombres en chiffres romains à l'aide des lettres M, D, C, L, X, V et I. Récapitulons ces règles :

  • Les caractères sont additifs. I est 1, II est 2 et III est 3. VI est 6 (littéralement «5 et 1»), VII est 7 et VIII est 8.
  • Les caractères en un (I, X, C, and M) peuvent être répétés jusqu'à trois fois. A 4, vous devez soustraire du prochain caractère en cinq. Vous ne pouvez pas représenter 4 par IIII, au lieu de ça il est représenté par IV («1 de moins que 5»). 40 s'écrit XL («10 de moins que 50»), 41 s'écrit XLI, 42 XLII, 43 XLIII et 44 XLIV («10 de moins que 50, puis 1 de moins que 5»).
  • De manière similaire, à 9, vous devez soustraire du prochain caractère en un : 8 est VIII mais 9 est IX («1 de moins que 10»), pas VIIII (puisque le caractère I ne peut être répété quatre fois). 90 est XC et 900 CM.
  • Les caractères en cinq ne peuvent être répétés. 10 est toujours représenté par X, jamais par VV. 100 est toujours C, jamais LL.
  • Les chiffres romains sont toujours écrits du plus haut vers le plus bas et lus de gauche à droite, l'ordre des caractères est donc très important. DC est 600, CD est un nombre totalement différent (400, «100 de moins que 500»). CI est 101, IC n'est même pas valide en chiffres romains (puisqu'on ne peut pas soustraire 1 directement de 100, il faudrait l'écrire XCIX, «10 de moins que 100, puis 1 de moins que 10»).

Exemple 14.12. roman5.py

Ce fichier est disponible dans le sous-répertoire py/roman/stage5/ du répertoire des exemples.

Si vous ne l'avez pas déjà fait,vous pouvez télécharger cet exemple ainsi que les autres exemples du livre.

 
Sélectionnez
"""Convert to and from Roman numerals"""
import re

#Define exceptions
class RomanError(Exception): pass
class OutOfRangeError(RomanError): pass
class NotIntegerError(RomanError): pass
class InvalidRomanNumeralError(RomanError): pass

#Define digit mapping
romanNumeralMap = (('M',  1000),
                   ('CM', 900),
                   ('D',  500),
                   ('CD', 400),
                   ('C',  100),
                   ('XC', 90),
                   ('L',  50),
                   ('XL', 40),
                   ('X',  10),
                   ('IX', 9),
                   ('V',  5),
                   ('IV', 4),
                   ('I',  1))

def toRoman(n):
    """convert integer to Roman numeral"""
    if not (0 < n < 4000):
        raise OutOfRangeError, "number out of range (must be 1..3999)"
    if int(n) <> n:
        raise NotIntegerError, "non-integers can not be converted"

    result = ""
    for numeral, integer in romanNumeralMap:
        while n >= integer:
            result += numeral
            n -= integer
    return result

#Define pattern to detect valid Roman numerals
romanNumeralPattern = '^M?M?M?(CM|CD|D?C?C?C?)(XC|XL|L?X?X?X?)(IX|IV|V?I?I?I?)$' ***1***

def fromRoman(s):
    """convert Roman numeral to integer"""
    if not re.search(romanNumeralPattern, s):                                    ***2***
        raise InvalidRomanNumeralError, 'Invalid Roman numeral: %s' % s

    result = 0
    index = 0
    for numeral, integer in romanNumeralMap:
        while s[index:index+len(numeral)] == numeral:
            result += integer
            index += len(numeral)
    return result

***1*** C'est simplement l'extension du motif que nous avons vu au Section 7.3, «Exemple : chiffres romains». Les dizaines sont soit XC (90), soit XL (40), soit un L optionnel suivi de 0 à 3 X optionnels. Les unités sont soit IX (9), soit IV (4), soit un V optionnel suivi de 0 à 3 I optionnels.

***2*** Une fois toute cette logique encodée dans notre expression régulière, le code vérifiant la validité des nombres romain est une formalité. Si re.search retourne un objet, alors l'expression régulière à reconnu la chaîne et notre entrée est valide, sinon notre entrée est invalide.

A ce stade, vous avez le droit d'être sceptique quant à la capacité de cette expression régulière longue et disgracieuse d'intercepter tous les types de nombres romains invalides. Mais vous n'avez pas à me croire sur parole, observez plutôt les résultats :

Exemple 14.13. Sortie de romantest5.py avec roman5.py

 
Sélectionnez
fromRoman should only accept uppercase input ... ok          ***1***
toRoman should always return uppercase ... ok
fromRoman should fail with malformed antecedents ... ok      **2***
fromRoman should fail with repeated pairs of numerals ... ok ***3***
fromRoman should fail with too many repeated numerals ... ok
fromRoman should give known result with known input ... ok
toRoman should give known result with known input ... ok
fromRoman(toRoman(n))==n for all n ... ok
toRoman should fail with non-integer input ... ok
toRoman should fail with negative input ... ok
toRoman should fail with large input ... ok
toRoman should fail with 0 input ... ok

----------------------------------------------------------------------
Ran 12 tests in 2.864s

OK                                                           ***4***

***1*** Il y a une chose que je n'ai pas mentionné à propos des expressions régulières, c'est que par défaut, elles sont sensibles à la casse. Comme notre expression régulière romanNumeralPattern est exprimée en majuscules, notre vérification re.search rejettera toute entrée qui n'est pas entièrement en majuscules. Donc notre test d'entrée en majuscules uniquement passe.

***2*** Plus important encore, notre test d'entrée incorrecte passe. Par exemple, le test d'antécédent mal formé vérifie les cas comme MCMC. Comme nous l'avons vu, cela ne correspond pas à notre expression régulière, donc fromRoman déclenche une exception InvalidRomanNumeralError, ce qui est ce que le cas de test d'antécédent mal formé attend, donc le test passe.

***3*** En fait, tous les tests d'entrées incorrectes passent. Cette expression régulière intercepte tout ce que nous avons pu imaginer quand nous avons écrit nos cas de test.

***4*** Et le prix du triomphe modeste est attribué au petit «OK» qui est affiché par le module unittest quand tous les tests passent.

Quand tous vos tests passent, arrêtez d'écrire du code.


précédentsommairesuivant