erplibre/test/test_transform_form.py

709 lines
26 KiB
Python
Raw Normal View History

#!/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()