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