erplibre/test/test_transform_form.py
Mathieu Benoit 04b9717e96 [ADD] transform data : anonymiser un fichier externe ou une base Odoo
Partager un fichier ou une base demandait d'anonymiser à la main,
colonne par colonne, ou de ne pas partager. L'entrée ouvre un fichier
externe — Excel, Access, CSV, XML, JSON — ou une base Odoo, dit ce
qu'elle porte, puis en tire une copie. L'original n'est jamais touché.

Deux règles gouvernent ce qui sort. Une colonne n'est laissée intacte
que sur son CONTENU mesuré, hors onze étiquettes structurelles : décider
sur l'étiquette recopiait les colonnes les plus identifiantes en les
annonçant protégées. Et la copie est relue octet par octet, un classeur
portant de la donnée en une douzaine d'endroits hors des cellules.

Vérifié : 5 748 tests — 13 rouges, déjà rouges sur master.

--- EN ---

Sharing a file or a database meant anonymising by hand, column by
column, or not sharing. The entry opens an external file — Excel,
Access, CSV, XML, JSON — or an Odoo database, says what it holds, then
draws a copy of it. The original is never touched.

Two rules govern what comes out. A column is left alone on its measured
CONTENT, bar eleven structural labels: deciding on the label copied the
most identifying columns verbatim while announcing them as protected.
And the copy is reread byte by byte, a workbook holding data in a dozen
places that are not cells.

Checked: 5,748 tests — 13 red, already red on master.

Assisted-by: Claude Opus 5
2026-09-11 00:06:39 -04:00

708 lines
26 KiB
Python

#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""L'écran de périmètre de « Transform data ».
Un fichier À PART, et non une classe de plus dans
`test_transform_external.py` : celui-là s'importe sous DEUX interpréteurs
— celui du CLI, et le venv dédié qui bâtit les fixtures de classeur et
n'a ni `click` ni `textual`. Y mettre un test d'écran casserait la moitié
de ce fichier sur un `ModuleNotFoundError` sans rapport avec ce qu'il
éprouve.
Deux niveaux de preuve :
- les fonctions PURES et la structure de l'app, sans monter un widget ;
- un pilotage RÉEL par `run_test()`, qui seul prouve que l'écran répond
aux touches. Le reste peut passer sur un écran qui ne s'affiche pas.
"""
import asyncio
import os
import sys
import unittest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from script.todo import transform_form # noqa: E402
try:
import textual # noqa: F401
TEXTUAL = True
except ImportError: # pragma: no cover - textual peut manquer
TEXTUAL = False
SANS_TEXTUAL = (
"textual est absent de cet interpréteur : installer les dépendances du"
" CLI, ou lancer ce fichier sous .venv.erplibre"
)
def rapport():
"""Un rapport de moteur, réduit à ce que l'écran en lit.
Trois colonnes qui posent chacune un cas : une étiquetée, une SANS
libellé — le cas de la majorité des colonnes d'un export réel —, et
une planchéiée, qu'aucune réponse ne doit pouvoir cocher.
"""
return {
"chemin": "/tmp/classeur.xlsx",
"format": "xlsx",
"feuilles": [
{
"nom": "Ventes",
"lignes": 4,
"colonnes_n": 3,
"lignes_entete": [1],
"ligne_champs": 1,
"entete_declaree": False,
"colonnes": [
{
"index": 1,
"etiquette": "montant",
"type": "nombre",
"remplies": 3,
"distinctes": 3,
"exemples": ["12", "13", "14"],
"plancher": False,
},
{
"index": 2,
"etiquette": "",
"type": "texte",
"remplies": 3,
"distinctes": 3,
"exemples": ["aboulie", "acai", "adobe"],
"plancher": False,
},
{
"index": 3,
"etiquette": "partner_id",
"type": "texte",
"remplies": 3,
"distinctes": 3,
"exemples": ["base.p1"],
"plancher": True,
},
],
"lignes_sondees": [
{
"numero": 1,
"apercu": ["montant", "", "partner_id"],
"pleines": 2,
"mesure": {
"contraste": 0.5,
"hors_colonne": 1.0,
"accord": 0.0,
},
},
{
"numero": 2,
"apercu": ["12", "aboulie", "base.p1"],
"pleines": 3,
"mesure": {
"contraste": 0.0,
"hors_colonne": 0.3,
"accord": 1.0,
},
},
],
},
{
"nom": "Achats",
"lignes": 2,
"colonnes_n": 1,
"lignes_entete": [],
"ligne_champs": None,
"entete_declaree": False,
"colonnes": [
{
"index": 1,
"etiquette": "",
"type": "texte",
"remplies": 2,
"distinctes": 2,
"exemples": ["acanthe", "adelphique"],
"plancher": False,
}
],
"lignes_sondees": [
{
"numero": 1,
"apercu": ["acanthe"],
"pleines": 1,
"mesure": None,
}
],
},
],
}
class TestContexte(unittest.TestCase):
"""Le contexte est de la donnée PURE, tirée du rapport.
C'est ce qui garantit que l'écran n'ouvre rien lui-même : aucune
entrée-sortie et aucun sous-processus depuis l'affichage.
"""
def setUp(self):
self.ctx = transform_form.contexte_depuis_rapport(rapport())
def test_chaque_feuille_traverse(self):
self.assertEqual(
[f["nom"] for f in self.ctx["feuilles"]], ["Ventes", "Achats"]
)
def test_l_empan_mesure_traverse(self):
self.assertEqual(self.ctx["feuilles"][0]["lignes_entete"], [1])
self.assertEqual(self.ctx["feuilles"][1]["lignes_entete"], [])
self.assertIsNone(self.ctx["feuilles"][1]["ligne_champs"])
def test_un_rapport_vide_ne_leve_pas(self):
vide = transform_form.contexte_depuis_rapport({})
self.assertEqual(vide["feuilles"], [])
self.assertEqual(vide["fichier"], "")
def test_une_feuille_sans_colonne_ni_sondee_ne_leve_pas(self):
ctx = transform_form.contexte_depuis_rapport(
{"feuilles": [{"nom": "F"}]}
)
feuille = ctx["feuilles"][0]
self.assertEqual(feuille["colonnes"], [])
self.assertEqual(feuille["lignes_sondees"], [])
class TestMemoireDansLeContexte(unittest.TestCase):
"""Ce qu'une table de lot se rappelle arrive PRÉ-COCHÉ et MARQUÉ.
Jamais appliqué en silence : une réponse fausse appliquée sans être
vue est exactement comment une erreur gagne tout un lot.
"""
def test_sans_memoire_l_empan_est_celui_de_la_mesure(self):
ctx = transform_form.contexte_depuis_rapport(rapport())
self.assertEqual(ctx["feuilles"][0]["lignes_entete"], [1])
self.assertFalse(ctx["feuilles"][0]["entete_memorisee"])
def test_la_memoire_remplace_l_empan_et_se_MARQUE(self):
ctx = transform_form.contexte_depuis_rapport(
rapport(), {"Achats": [1, 2]}
)
achats = ctx["feuilles"][1]
self.assertEqual(achats["lignes_entete"], [1, 2])
self.assertTrue(achats["entete_memorisee"])
def test_une_memoire_VIDE_veut_dire_pas_d_en_tete(self):
"""Et se distingue d'une absence de mémoire : l'une répond « pas
d'en-tête », l'autre laisse la mesure trancher."""
ctx = transform_form.contexte_depuis_rapport(rapport(), {"Ventes": []})
ventes = ctx["feuilles"][0]
self.assertEqual(ventes["lignes_entete"], [])
self.assertTrue(ventes["entete_memorisee"])
def test_une_feuille_hors_memoire_garde_sa_mesure(self):
ctx = transform_form.contexte_depuis_rapport(
rapport(), {"Achats": [1]}
)
self.assertEqual(ctx["feuilles"][0]["lignes_entete"], [1])
self.assertFalse(ctx["feuilles"][0]["entete_memorisee"])
def test_des_lignes_en_chaines_sont_normalisees(self):
"""Elles arrivent d'un JSON relu à la main."""
ctx = transform_form.contexte_depuis_rapport(
rapport(), {"Achats": ["2", "1", "1"]}
)
self.assertEqual(ctx["feuilles"][1]["lignes_entete"], [1, 2])
@unittest.skipUnless(TEXTUAL, SANS_TEXTUAL)
class TestMemoireALEcran(unittest.TestCase):
"""L'écran part de la mémoire, et la montre."""
def test_l_empan_de_depart_vient_de_la_memoire(self):
app = transform_form.run_transform_form(
transform_form.contexte_depuis_rapport(
rapport(), {"Achats": [1, 2]}
),
run_app=False,
)
self.assertEqual(app._entetes["Achats"], {1, 2})
self.assertEqual(app._entetes["Ventes"], {1})
class TestLegende(unittest.TestCase):
"""La légende explique CHAQUE marque que l'écran peut poser.
Le test lisait sa propre copie du littéral : une marque ajoutée sans
être expliquée passait au vert. Il lit désormais la même constante que
`compose`, et n'ouvre aucun widget — donc rien à ignorer si textual
manque.
"""
MARQUES = (
"MARQUE_INTACTE",
"MARQUE_PLANCHER",
"MARQUE_CORRIGEE",
"MARQUE_DU_LOT",
)
def test_chaque_marque_figure_dans_la_legende(self):
from script.todo.todo_i18n import t as traduire
legende = traduire(transform_form.TEXTE_LEGENDE)
for nom in self.MARQUES:
with self.subTest(marque=nom):
self.assertIn(getattr(transform_form, nom), legende)
def test_la_marque_EN_PORTEE_n_a_pas_a_y_figurer(self):
"""C'est la case vide, le défaut : rien à expliquer."""
from script.todo.todo_i18n import t as traduire
self.assertNotIn(
transform_form.MARQUE_EN_PORTEE,
traduire(transform_form.TEXTE_LEGENDE),
)
def test_la_legende_est_traduite(self):
from script.todo.todo_i18n import TRANSLATIONS
self.assertIn(transform_form.TEXTE_LEGENDE, TRANSLATIONS)
class TestLibelles(unittest.TestCase):
"""Ce que l'écran montre d'une colonne et d'une ligne."""
def setUp(self):
self.ctx = transform_form.contexte_depuis_rapport(rapport())
self.colonnes = self.ctx["feuilles"][0]["colonnes"]
def test_une_colonne_sans_libelle_le_DIT(self):
"""Un blanc se lit comme une erreur d'affichage plutôt que comme
un fait, et c'est le cas de la majorité des colonnes."""
cellules = transform_form.libelle_de_colonne(
self.colonnes[1], transform_form.MARQUE_EN_PORTEE
)
self.assertTrue(cellules[2].strip())
self.assertNotEqual(cellules[2], "")
def test_les_exemples_sont_la_derniere_cellule(self):
"""CE qui distingue deux colonnes sans libellé : ni le type, ni
le compte, ni les bornes n'y suffisent."""
cellules = transform_form.libelle_de_colonne(
self.colonnes[1], transform_form.MARQUE_EN_PORTEE
)
self.assertIn("aboulie", cellules[-1])
self.assertIn("acai", cellules[-1])
def test_toutes_les_cellules_sont_des_chaines(self):
for colonne in self.colonnes:
with self.subTest(colonne=colonne["index"]):
for cellule in transform_form.libelle_de_colonne(
colonne, transform_form.MARQUE_EN_PORTEE
):
self.assertIsInstance(cellule, str)
def test_une_ligne_sondee_montre_ses_MESURES(self):
"""L'opérateur voit POURQUOI la mesure a tranché avant de la
contredire — contredire un verdict qu'on ne voit pas est un
pari."""
sondee = self.ctx["feuilles"][0]["lignes_sondees"][0]
cellules = transform_form.libelle_de_ligne_sondee(sondee, True)
self.assertEqual(cellules[0], transform_form.MARQUE_INTACTE)
self.assertIn("1.00", cellules[-1])
def test_une_ligne_sans_mesure_ne_montre_pas_de_zeros(self):
"""Rien à comparer n'est pas « toutes les mesures à zéro »."""
sondee = self.ctx["feuilles"][1]["lignes_sondees"][0]
cellules = transform_form.libelle_de_ligne_sondee(sondee, False)
self.assertEqual(cellules[-1], "")
class TestCleDeColonne(unittest.TestCase):
"""Une réponse d'ÉCRAN désigne une colonne par son index.
Pas par son étiquette : corriger l'empan d'en-tête renomme les
colonnes — la ligne de champs change — et une réponse portée par
l'étiquette tombait alors sur une autre colonne, ou sur aucune, sans
que rien ne le dise.
"""
def test_c_est_l_index_quelle_que_soit_l_etiquette(self):
for etiquette in ("montant", "", " ", None, "4"):
with self.subTest(etiquette=etiquette):
self.assertEqual(
transform_form.cle_de_colonne(
{"index": 4, "etiquette": etiquette}
),
4,
)
def test_deux_colonnes_ne_partagent_jamais_une_cle(self):
"""L'ambiguïté qui oblige l'invite à préférer l'étiquette — « 1 »
désigne la colonne étiquetée « 1 » ET la première — n'existe pas
ici."""
cles = {
transform_form.cle_de_colonne(c)
for c in (
{"index": 1, "etiquette": "3"},
{"index": 3, "etiquette": "x"},
)
}
self.assertEqual(len(cles), 2)
class TestBasculer(unittest.TestCase):
"""La décision, séparée du rendu pour être éprouvable."""
def test_ajoute_puis_retire(self):
ensemble = set()
self.assertTrue(transform_form.basculer(ensemble, "a"))
self.assertEqual(ensemble, {"a"})
self.assertFalse(transform_form.basculer(ensemble, "a"))
self.assertEqual(ensemble, set())
class TestSpec(unittest.TestCase):
"""La spec traverse un `json.dumps` : ni `set`, ni clé tuple."""
def setUp(self):
self.ctx = transform_form.contexte_depuis_rapport(rapport())
def test_la_spec_est_serialisable(self):
import json
spec = transform_form.spec_depuis_etat(
self.ctx, {"Ventes": {2}}, {"Ventes": {1, 2}}
)
json.dumps(spec, allow_nan=False)
self.assertEqual(
spec["colonnes_intactes_index_par_feuille"], {"Ventes": [2]}
)
self.assertEqual(spec["entetes_par_feuille"]["Ventes"], [1, 2])
def test_une_feuille_sans_colonne_cochee_ne_figure_pas(self):
spec = transform_form.spec_depuis_etat(
self.ctx, {"Ventes": set(), "Achats": set()}, {}
)
self.assertEqual(spec["colonnes_intactes_index_par_feuille"], {})
def test_CHAQUE_feuille_porte_son_empan_meme_vide(self):
"""Un empan vide veut dire « pas d'en-tête », ce qui met la ligne
1 en portée : le taire vaudrait « la ligne 1 », l'inverse."""
spec = transform_form.spec_depuis_etat(self.ctx, {}, {"Ventes": {1}})
self.assertEqual(
sorted(spec["entetes_par_feuille"]), ["Achats", "Ventes"]
)
self.assertEqual(spec["entetes_par_feuille"]["Achats"], [])
@unittest.skipUnless(TEXTUAL, SANS_TEXTUAL)
class TestStructureDeLApp(unittest.TestCase):
"""Ce que l'app promet, sans monter un widget."""
def setUp(self):
self.app = transform_form.run_transform_form(
transform_form.contexte_depuis_rapport(rapport()), run_app=False
)
def _touches(self):
rendu = {}
for item in self.app.BINDINGS:
if isinstance(item, tuple):
rendu[item[0]] = item[1]
else:
rendu[item.key] = item.action
return rendu
def test_chaque_touche_a_son_action(self):
for touche, action in self._touches().items():
with self.subTest(touche=touche):
self.assertTrue(
hasattr(self.app, "action_%s" % action),
"action_%s manque" % action,
)
def test_les_touches_promises_sont_la(self):
touches = self._touches()
for touche in ("space", "f5", "f9", "escape"):
with self.subTest(touche=touche):
self.assertIn(touche, touches)
def test_l_empan_mesure_est_le_point_de_depart(self):
"""Une correction part de ce que le moteur a trouvé ; les cases
de colonne partent VIDES, une réponse ne se pré-cochant pas."""
self.assertEqual(self.app._entetes, {"Ventes": {1}, "Achats": set()})
self.assertEqual(
self.app._intactes, {"Ventes": set(), "Achats": set()}
)
@unittest.skipUnless(TEXTUAL, SANS_TEXTUAL)
class TestPilotage(unittest.TestCase):
"""L'écran répond-il aux touches ? Seul un pilotage le prouve.
Tout le reste passerait sur un écran qui ne s'affiche pas.
"""
def _piloter(self, scenario, taille=None):
app = transform_form.run_transform_form(
transform_form.contexte_depuis_rapport(rapport()), run_app=False
)
async def tourner():
async with app.run_test(size=taille) as pilote:
await pilote.pause()
await scenario(app, pilote)
asyncio.run(tourner())
return app
def _rendu(self, app):
"""Ce que l'écran AFFICHE, non ce que le modèle porte.
Tous les autres pilotages lisent `app._intactes` ou une cellule
du modèle. C'est ce qui a laissé passer un défaut d'AFFICHAGE
complet : Rich lit `[x]` comme une balise de style et l'avale,
donc la case cochée n'apparaissait jamais — ni dans le tableau,
ni dans la légende — alors que le modèle disait « cochée ».
"""
return "\n".join(
"".join(segment.text for segment in bande)
for bande in app.screen._compositor.render_strips()
)
def _ligne_rendue(self, app, repere):
"""La ligne AFFICHÉE qui porte ce repère.
Chercher la marque dans tout l'écran ne prouverait rien : la
légende en porte une, et la ligne d'en-tête mesurée est
pré-cochée dans l'autre tableau.
"""
for ligne in self._rendu(app).splitlines():
if repere in ligne:
return ligne
return ""
def test_la_case_cochee_APPARAIT_a_l_ecran(self):
vu = {}
async def scenario(app, pilote):
table = app.query_one("#colonnes")
table.focus()
await pilote.pause()
vu["avant"] = self._ligne_rendue(app, "montant")
await pilote.press("space")
await pilote.pause()
vu["apres"] = self._ligne_rendue(app, "montant")
self._piloter(scenario, taille=(100, 30))
self.assertIn(transform_form.MARQUE_EN_PORTEE, vu["avant"])
self.assertIn(transform_form.MARQUE_INTACTE, vu["apres"])
self.assertNotIn(transform_form.MARQUE_EN_PORTEE, vu["apres"])
def test_la_legende_montre_CHAQUE_marque_a_l_ecran(self):
"""La légende traversait le même filtre : son `[x]` disparaissait,
donc l'opérateur ne pouvait pas non plus apprendre la marque."""
vu = {}
async def scenario(app, pilote):
vu["texte"] = self._rendu(app)
self._piloter(scenario, taille=(100, 30))
for nom in ("MARQUE_INTACTE", "MARQUE_PLANCHER", "MARQUE_CORRIGEE"):
with self.subTest(marque=nom):
self.assertIn(getattr(transform_form, nom), vu["texte"])
def test_une_valeur_a_crochets_n_est_pas_mangee(self):
"""La colonne des exemples porte des valeurs du FICHIER : l'une
qui ressemble à une balise ne doit pas disparaître."""
vu = {}
rap = rapport()
rap["feuilles"][0]["colonnes"][0]["exemples"] = ["[x]", "[bold]", "a"]
app = transform_form.run_transform_form(
transform_form.contexte_depuis_rapport(rap), run_app=False
)
async def tourner():
async with app.run_test(size=(120, 30)) as pilote:
await pilote.pause()
vu["texte"] = self._rendu(app)
asyncio.run(tourner())
self.assertIn("[bold]", vu["texte"])
def test_une_ligne_a_UNE_cellule_par_EN_TETE_monte(self):
"""Une cellule manquante ne lève pas : `DataTable` complète la
ligne, et les valeurs glissent d'une colonne — les exemples
s'affichaient sous « distinctes » sans que rien ne le dise.
Le nombre est pris sur les en-têtes RÉELLEMENT montés, non sur un
littéral : un « 7 » en dur dériverait au prochain ajout.
"""
vu = {}
async def scenario(app, pilote):
for selecteur in ("#colonnes", "#sondees"):
vu[selecteur] = len(app.query_one(selecteur).columns)
self._piloter(scenario)
contexte = transform_form.contexte_depuis_rapport(rapport())
feuille = contexte["feuilles"][0]
for colonne in feuille["colonnes"]:
with self.subTest(colonne=colonne["index"]):
self.assertEqual(
len(
transform_form.libelle_de_colonne(
colonne, transform_form.MARQUE_EN_PORTEE
)
),
vu["#colonnes"],
)
for ligne in feuille["lignes_sondees"]:
with self.subTest(ligne=ligne["numero"]):
self.assertEqual(
len(transform_form.libelle_de_ligne_sondee(ligne, False)),
vu["#sondees"],
)
def test_les_deux_tableaux_tiennent_sur_vingt_lignes(self):
"""Une hauteur fixe donnait ZÉRO ligne au tableau des colonnes.
C'est là que se décide « laisser cette colonne intacte » : le
tableau était présent, focalisable, et invisible. Les deux se
partagent donc la hauteur au lieu d'en réserver une.
"""
vu = {}
async def scenario(app, pilote):
for selecteur in ("#colonnes", "#sondees"):
vu[selecteur] = app.query_one(selecteur).size.height
self._piloter(scenario, taille=(80, 20))
self.assertGreaterEqual(vu["#colonnes"], 4)
self.assertGreaterEqual(vu["#sondees"], 4)
def test_la_colonne_des_mesures_n_est_pas_poussee_hors_de_l_ecran(self):
"""Trois exemples de quarante caractères font cent vingt-six
colonnes : la colonne libre ne va qu'en dernière place, sans quoi
elle chasse les mesures, qui sont la raison de montrer ces
lignes."""
vu = {}
async def scenario(app, pilote):
table = app.query_one("#sondees")
vu["largeurs"] = [c.width for c in table.columns.values()]
vu["volet"] = table.size.width
self._piloter(scenario, taille=(80, 20))
*avant_la_derniere, _derniere = vu["largeurs"]
# Toutes bornées sauf la dernière, et leur somme tient dans le
# volet : la colonne libre COMMENCE donc à l'écran.
self.assertTrue(all(l for l in avant_la_derniere))
self.assertLess(sum(avant_la_derniere), vu["volet"])
def test_les_deux_tableaux_se_remplissent(self):
vu = {}
async def scenario(app, pilote):
vu["colonnes"] = app.query_one("#colonnes").row_count
vu["sondees"] = app.query_one("#sondees").row_count
self._piloter(scenario)
self.assertEqual(vu["colonnes"], 3)
self.assertEqual(vu["sondees"], 2)
def test_espace_sur_une_colonne_la_laisse_intacte(self):
async def scenario(app, pilote):
app.query_one("#colonnes").focus()
await pilote.pause()
await pilote.press("space")
await pilote.pause()
app = self._piloter(scenario)
# L'INDEX, non l'étiquette : c'est la seule désignation qui
# survit à une correction d'en-tête.
self.assertEqual(app._intactes["Ventes"], {1})
def test_espace_sur_une_colonne_PLANCHEIEE_ne_fait_rien(self):
"""Le plancher passe avant la réponse : une case qui ne
changerait rien serait un mensonge."""
async def scenario(app, pilote):
table = app.query_one("#colonnes")
table.focus()
await pilote.pause()
table.move_cursor(row=2)
await pilote.pause()
await pilote.press("space")
await pilote.pause()
app = self._piloter(scenario)
self.assertEqual(app._intactes["Ventes"], set())
def test_espace_sur_une_ligne_ajoute_une_ligne_d_en_tete(self):
"""Plusieurs lignes d'en-tête : un export porte souvent une ligne
de catégorie au-dessus de la ligne de champs."""
async def scenario(app, pilote):
table = app.query_one("#sondees")
table.focus()
await pilote.pause()
table.move_cursor(row=1)
await pilote.pause()
await pilote.press("space")
await pilote.pause()
app = self._piloter(scenario)
self.assertEqual(app._entetes["Ventes"], {1, 2})
self.assertIn("Ventes", app._corrigees)
def test_f4_rend_la_feuille_a_la_mesure(self):
async def scenario(app, pilote):
app.query_one("#sondees").focus()
await pilote.pause()
await pilote.press("space")
await pilote.pause()
await pilote.press("f4")
await pilote.pause()
app = self._piloter(scenario)
self.assertEqual(app._entetes["Ventes"], {1})
self.assertNotIn("Ventes", app._corrigees)
def test_f5_rend_la_spec(self):
async def scenario(app, pilote):
await pilote.press("f5")
await pilote.pause()
app = self._piloter(scenario)
spec = app._resultat["spec"]
self.assertIsInstance(spec, dict)
self.assertIn("entetes_par_feuille", spec)
def test_f9_rend_un_dict_VIDE_et_non_None(self):
"""L'appelant distingue « poser les questions » de « annuler » :
les confondre supprimerait le repli."""
async def scenario(app, pilote):
await pilote.press("f9")
await pilote.pause()
app = self._piloter(scenario)
self.assertEqual(app._resultat["spec"], {})
def test_echap_annule(self):
async def scenario(app, pilote):
await pilote.press("escape")
await pilote.pause()
app = self._piloter(scenario)
self.assertIsNone(app._resultat["spec"])
if __name__ == "__main__":
unittest.main()