erplibre/test/test_transform_external.py
Mathieu Benoit cd2b8266d4 [FIX] requirement erplibre : déclarer openpyxl, sauter sans lui
Three Transform data tests wrote an Excel workbook with openpyxl, which the
.venv.erplibre requirements never declared, so the unit suite reported three
errors on every install. openpyxl is now declared there. The tests skip when it
cannot be imported in their own interpreter, since a venv built before this
line still lacks it; the dedicated engine venv is not the right probe, as the
tests write their workbook in-process. Checked: 707 Transform tests pass, and
with openpyxl made unimportable the three are skipped, not errors.

--- FR ---

Trois tests de Transform data écrivaient un classeur Excel avec openpyxl, que
les dépendances de .venv.erplibre ne déclaraient pas : la suite unitaire
rendait trois erreurs sur toute installation. openpyxl y est désormais
déclaré. Les tests se sautent quand il ne s'importe pas dans leur interpréteur,
un venv bâti avant cette ligne ne l'ayant toujours pas ; le venv dédié du
moteur n'est pas la bonne sonde, les tests écrivant leur classeur sur place.
Vérifié : 707 tests de Transform passent, et openpyxl rendu inimportable, les
trois sont sautés et non en erreur.

Assisted-by: Claude Opus 5
2026-09-16 05:36:13 -04:00

7224 lines
284 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Transformer un fichier externe : ce qui compte est ce qui NE sort PAS.
Remplacer une cellule est facile. Ce qui produit un fichier faussement
propre, c'est de croire qu'un classeur ne porte de la donnée que dans ses
cellules. Un `.xlsx` en porte dans une douzaine d'autres endroits, et deux
d'entre eux — le cache d'un tableau croisé et un lien externe — en portent
une COPIE entière.
Le test de fuite est donc le cœur de ce fichier. Il pose 24 marqueurs
inventés, un par vecteur : 19 disparaissent, et 5 restent — ces cinq étant
référencés par des formules que la règle préserve, et qu'on ne peut donc
pas supprimer sans casser ce qu'on vient de garantir. Il assert la liste
EXACTE des survivants et non « rien d'autre » : c'est ce qui le fait tomber
quand une montée de version d'openpyxl rouvre un vecteur.
Trois pièges que seule l'exécution a donnés, et que ce fichier fige :
`isinstance(True, int)` vaut True, donc une case à cocher deviendrait un
montant ; `xlrd` stocke une erreur par son CODE ENTIER, donc `#REF!`
deviendrait un montant plausible et `#NULL!` un zéro légitime ; et
`csv.reader` ne rend que des chaînes, donc une colonne de montants
deviendrait des mots.
La suite tourne sous `.venv.erplibre`, qui n'a pas openpyxl : tout ce qui
en dépend est derrière `skipUnless` et se DIT ignoré, jamais vert en
silence.
"""
import builtins
import datetime
import io
import json
import os
import random
import shutil
import subprocess
import sys
import tempfile
import unittest
import zipfile
from script.data import external_file as noyau
from script.data import external_file_formats as formats
from script.todo import todo_i18n, transform_setup
VIVIER = noyau.vivier_de_mots()
def _options(**extra):
base = {
"vivier": VIVIER,
"nombres": True,
"texte": True,
"entetes": False,
"feuilles": None,
"etiquettes": {},
"colonnes_intactes": set(),
"bornes": {},
}
base.update(extra)
return base
# `transform_menu` et `todo.py` importent `click`, que le venv dédié n'a
# pas — et la fixture du test de fuite importe CE module sous cet
# interprète-là pour bâtir un classeur. Les imports du menu sont donc
# PARESSEUX, comme ceux du moteur : sans cela, tout le test de fuite tombe
# sur un `ModuleNotFoundError` sans rapport avec ce qu'il éprouve.
def _tm():
"""Le module du menu, importé au premier appel."""
from script.todo import transform_menu
return transform_menu
def _MenuBouchon():
"""Le mixin, monté sur les aides que `TODO` lui fournit.
Les aides sont PRISES sur la vraie classe et non recopiées : « o »,
« oui », « y », « yes » et la différence entre un défaut oui et un
défaut non sont sa règle, et une copie dériverait sans qu'un test le
voie.
"""
from script.todo.todo import TODO
class Bouchon(_tm().TransformMenuMixin):
_is_yes = staticmethod(TODO._is_yes)
_is_yes_default_yes = staticmethod(TODO._is_yes_default_yes)
return Bouchon()
class _Entrees:
"""`input()` bouchonné, et la trace de ce qui a été demandé.
Rend les réponses dans l'ordre. Une question de plus que de réponses
lève : un test qui répondrait « à côté » passerait sinon en silence.
"""
def __init__(self, *reponses):
self.reponses = list(reponses)
self.demandes = []
def __call__(self, invite=""):
self.demandes.append(invite)
if not self.reponses:
raise AssertionError(
"question sans réponse : %r après %d"
% (invite, len(self.demandes) - 1)
)
return self.reponses.pop(0)
class TestMenuFonctionsPures(unittest.TestCase):
"""Ce que le menu décide sans rien demander."""
def setUp(self):
self.menu = _MenuBouchon()
def test_le_format_vient_de_l_extension(self):
for nom, attendu in (
("a.xlsx", "xlsx"),
("a.XLSM", "xlsx"),
("a.xlsb", "xlsx"),
("a.xls", "xls"),
("a.mdb", "access"),
("a.accdb", "access"),
("a.csv", "csv"),
("a.xml", "xml"),
("a.json", "json"),
):
with self.subTest(nom=nom):
self.assertEqual(self.menu._transform_format(nom), attendu)
def test_une_extension_inconnue_tombe_sur_le_venv_dedie(self):
"""Le repli doit être le venv qui sait TOUT lire : celui du CLI
ferait lever un import sur un classeur mal nommé."""
self.assertEqual(self.menu._transform_format("a.dat"), "xlsx")
self.assertEqual(self.menu._transform_format("sans_extension"), "xlsx")
def test_l_interpreteur_suit_l_union_des_bibliotheques(self):
"""Le processus lit la source ET écrit la cible.
La source seule laissait csv→xlsx importer openpyxl sous
l'interpréteur du CLI, qui ne l'a pas ; la cible seule enverrait
un classeur au même interpréteur, qui ne sait pas le lire.
"""
self.assertEqual(
self.menu._transform_fmt_moteur("csv", "xlsx"), "xlsx"
)
self.assertEqual(self.menu._transform_fmt_moteur("xls", "csv"), "xls")
self.assertEqual(
self.menu._transform_fmt_moteur("csv", "json"), "json"
)
self.assertEqual(
self.menu._transform_fmt_moteur("access", "xlsx"), "access"
)
def test_la_table_va_TOUJOURS_sous_private(self):
"""Elle ré-identifie la copie à elle seule : la poser à côté du
fichier à transmettre fait partir la clé avec le chiffré."""
for destination in (
"/tmp/livraison/copie.xlsx",
os.path.join("private", "transform", "c.xlsx"),
"c.csv",
):
with self.subTest(destination=destination):
table = self.menu._transform_table_par_defaut(destination)
self.assertTrue(
table.startswith(_tm().SORTIE_PAR_DEFAUT), table
)
self.assertTrue(table.endswith(".table.json"), table)
def test_deux_livraisons_de_meme_nom_ne_partagent_pas_leur_table(self):
"""« vide = nouvelle » est une promesse.
Le nom ne tenait qu'au nom de BASE : « 2026/export.xlsx » et
« 2027/export.xlsx » donnaient le même chemin, et le moteur
chargeait la table de la livraison précédente — ses mots, ses
nombres, ses empans — sans que rien ne signale sa présence.
"""
base = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, base, True)
vrai = _tm().SORTIE_PAR_DEFAUT
_tm().SORTIE_PAR_DEFAUT = base
self.addCleanup(setattr, _tm(), "SORTIE_PAR_DEFAUT", vrai)
premiere = self.menu._transform_table_par_defaut(
"/tmp/livraison/2026/export.xlsx"
)
with open(premiere, "w", encoding="utf-8") as flux:
flux.write("{}")
seconde = self.menu._transform_table_par_defaut(
"/tmp/livraison/2027/export.xlsx"
)
self.assertNotEqual(premiere, seconde)
self.assertFalse(os.path.exists(seconde))
self.assertTrue(seconde.endswith(".table.json"), seconde)
def test_un_nom_libre_garde_le_nom_de_la_destination(self):
"""Il reste le plus utile pour retrouver la table d'une copie."""
base = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, base, True)
vrai = _tm().SORTIE_PAR_DEFAUT
_tm().SORTIE_PAR_DEFAUT = base
self.addCleanup(setattr, _tm(), "SORTIE_PAR_DEFAUT", vrai)
chemin = self.menu._transform_table_par_defaut("/tmp/l/export.xlsx")
self.assertEqual(
chemin, self.menu._transform_table_derivee("/tmp/l/export.xlsx")
)
self.assertEqual(os.path.basename(chemin), "export.table.json")
def test_private_se_reconnait_par_realpath(self):
"""Un test de préfixe sur le chemin TAPÉ taisait l'avertissement
pour le chemin absolu du navigateur, et le levait pour
« privateer/ »."""
racine = transform_setup.racine()
self.assertTrue(
self.menu._transform_sous_private(
os.path.join(racine, "private", "transform", "c.xlsx")
)
)
self.assertTrue(
self.menu._transform_sous_private(os.path.join(racine, "private"))
)
self.assertFalse(
self.menu._transform_sous_private(
os.path.join(racine, "privateer", "c.xlsx")
)
)
self.assertFalse(self.menu._transform_sous_private("/tmp/c.xlsx"))
class TestMenuQuestions(unittest.TestCase):
"""Les douze questions, et la sortie qui doit exister à chacune.
S'apercevoir à la onzième qu'on a ouvert le mauvais fichier ne doit
pas obliger à répondre à tout puis à refuser un nom de fichier.
"""
RAPPORT = {
"format": "xlsx",
"feuilles": [{"nom": "Ventes"}, {"nom": "Achats"}],
"hors_cellules": {},
}
def setUp(self):
self.menu = _MenuBouchon()
# L'écran réel prendrait le terminal : ce qu'on éprouve ici est le
# chemin TEXTUEL, dont l'écran est le repli. `{}` veut dire
# « pose-moi les questions ».
self.menu._transform_ecran = lambda *args: {}
self.vrai_input = builtins.input
self.addCleanup(setattr, builtins, "input", self.vrai_input)
self.sortie = io.StringIO()
self.vrai_stdout = sys.stdout
sys.stdout = self.sortie
self.addCleanup(setattr, sys, "stdout", self.vrai_stdout)
def _repondre(self, *reponses):
entrees = _Entrees(*reponses)
builtins.input = entrees
return entrees
# -- le chemin qui aboutit -----------------------------------------
def test_le_chemin_complet_rend_les_douze_reponses(self):
self._repondre(
"o", # anonymiser
"csv", # convertir
"", # table du lot — posée AVANT l'écran
"o", # options avancées
"Ventes", # feuilles
"id, ref", # colonnes intactes
"", # nombres, défaut oui
"", # calibre des chiffres, défaut non
"", # texte, défaut oui
"", # en-têtes, défaut non
"42", # graine
)
options = self.menu._transform_ask_options(self.RAPPORT)
self.assertEqual(options["conversion"], "csv")
self.assertEqual(options["feuilles"], ["Ventes"])
self.assertEqual(options["colonnes_intactes"], ["id", "ref"])
self.assertTrue(options["nombres"])
self.assertTrue(options["texte"])
self.assertFalse(options["entetes"])
self.assertEqual(options["graine"], "42")
self.assertEqual(options["table_chemin"], "")
def test_le_chemin_COURT_ne_pose_que_quatre_questions(self):
"""Sept questions dont chacune a un défaut évident : les répondre
une par une pour arriver au même endroit fait passer les invites
par réflexe, et une invite qu'on passe ne consent à rien.
"""
entrees = self._repondre("o", "", "", "n")
options = self.menu._transform_ask_options(self.RAPPORT)
self.assertEqual(len(entrees.demandes), 4)
for cle, valeur in _tm().DEFAUTS_RAPIDES.items():
with self.subTest(cle=cle):
self.assertEqual(options[cle], valeur)
def test_le_chemin_court_GARDE_les_largeurs(self):
"""Le seul choix qui ne suit pas le défaut de son invite. Ce que
l'option abandonne — l'étendue mesurée — est dit par l'aperçu."""
self._repondre("o", "", "", "n")
options = self.menu._transform_ask_options(self.RAPPORT)
self.assertTrue(options["calibre_chiffres"])
def test_le_chemin_court_DIT_ce_qu_il_prend(self):
"""Un raccourci qui ne dit pas ce qu'il choisit n'est pas un
raccourci mais une surprise."""
self._repondre("o", "", "", "n")
self.menu._transform_ask_options(self.RAPPORT)
rendu = self.sortie.getvalue()
self.assertIn(todo_i18n.t("Taken as given:"), rendu)
self.assertIn(todo_i18n.t("every sheet"), rendu)
self.assertIn(todo_i18n.t("numbers replaced, widths kept"), rendu)
def test_le_chemin_court_pose_QUAND_MEME_macros_et_graphiques(self):
"""Elles ne se voient pas dans les cellules et ne se devinent
pas : la réponse change ce que la copie porte."""
entrees = self._repondre("o", "", "", "n", "o", "n")
rapport = dict(
self.RAPPORT, hors_cellules={"macros": 1, "graphiques": 2}
)
options = self.menu._transform_ask_options(rapport)
self.assertTrue(options["garder_macros"])
self.assertFalse(options["garder_graphiques"])
self.assertEqual(len(entrees.demandes), 6)
def test_le_chemin_court_n_efface_pas_les_reponses_de_l_ECRAN(self):
"""L'écran a pu répondre par feuille : refuser les options
avancées ne doit pas effacer ce qu'on vient de cocher."""
self.menu._transform_ecran = lambda *args: {
"colonnes_intactes_index_par_feuille": {"Ventes": [2]},
"entetes_par_feuille": {"Ventes": [1]},
}
self._repondre("o", "", "", "n")
options = self.menu._transform_ask_options(self.RAPPORT)
self.assertEqual(
options["colonnes_intactes_index_par_feuille"], {"Ventes": [2]}
)
self.assertEqual(options["colonnes_intactes"], [])
def test_le_resume_DIT_les_colonnes_gelees_a_l_ecran(self):
"""Le chemin court garde ce que l'écran a gelé : annoncer
« aucune colonne laissée intacte » dirait le contraire de ce qui
s'applique, sur le seul écran où l'opérateur consent."""
self.menu._transform_ecran = lambda *args: {
"colonnes_intactes_index_par_feuille": {"Ventes": [2, 5]},
"entetes_par_feuille": {"Ventes": [1]},
}
self._repondre("o", "", "", "n")
self.menu._transform_ask_options(self.RAPPORT)
rendu = self.sortie.getvalue()
self.assertIn(
todo_i18n.t("column(s) left untouched, chosen on screen"), rendu
)
self.assertIn("2 ", rendu)
self.assertNotIn(todo_i18n.t("no column left untouched"), rendu)
def test_le_resume_dit_AUCUNE_quand_l_ecran_n_a_rien_gele(self):
self._repondre("o", "", "", "n")
self.menu._transform_ask_options(self.RAPPORT)
rendu = self.sortie.getvalue()
self.assertIn(todo_i18n.t("no column left untouched"), rendu)
self.assertNotIn(
todo_i18n.t("column(s) left untouched, chosen on screen"), rendu
)
def test_le_resume_DIT_la_feuille_que_l_ecran_a_privee_d_en_tete(self):
"""Un empan VIDE veut dire « pas d'en-tête », et l'écran y mène
d'une frappe : la première ligne devient de la donnée et se fait
remplacer. Annoncer l'en-tête protégé dit alors le contraire de
ce qui s'applique."""
self.menu._transform_ecran = lambda *args: {
"colonnes_intactes_index_par_feuille": {},
"entetes_par_feuille": {"Ventes": []},
}
self._repondre("o", "", "", "n")
self.menu._transform_ask_options(self.RAPPORT)
rendu = self.sortie.getvalue()
self.assertIn(
todo_i18n.t("sheet(s) with no header row on screen"), rendu
)
self.assertNotIn(todo_i18n.t("the header row is left readable"), rendu)
def test_un_empan_declare_laisse_l_en_tete_protege(self):
self.menu._transform_ecran = lambda *args: {
"colonnes_intactes_index_par_feuille": {},
"entetes_par_feuille": {"Ventes": [1]},
}
self._repondre("o", "", "", "n")
self.menu._transform_ask_options(self.RAPPORT)
rendu = self.sortie.getvalue()
self.assertIn(todo_i18n.t("the header row is left readable"), rendu)
self.assertNotIn(
todo_i18n.t("sheet(s) with no header row on screen"), rendu
)
def test_une_feuille_ABSENTE_de_la_table_garde_son_en_tete(self):
"""Absente veut dire « mesure-la », non « elle n'en a pas »."""
self.menu._transform_ecran = lambda *args: {
"colonnes_intactes_index_par_feuille": {},
"entetes_par_feuille": {},
}
self._repondre("o", "", "", "n")
self.menu._transform_ask_options(self.RAPPORT)
self.assertIn(
todo_i18n.t("the header row is left readable"),
self.sortie.getvalue(),
)
def test_zero_annule_a_la_porte_des_options_avancees(self):
self._repondre("o", "", "", "0")
self.assertIsNone(self.menu._transform_ask_options(self.RAPPORT))
def test_un_defaut_vide_ne_convertit_ni_ne_restreint(self):
self._repondre("oui", "", "", "o", "", "", "n", "n", "o", "")
options = self.menu._transform_ask_options(self.RAPPORT)
self.assertEqual(options["conversion"], "")
self.assertEqual(options["feuilles"], [])
self.assertFalse(options["nombres"])
self.assertFalse(options["texte"])
self.assertTrue(options["entetes"])
# -- les sorties ---------------------------------------------------
def test_refuser_d_anonymiser_ne_pose_aucune_autre_question(self):
entrees = self._repondre("n")
self.assertIsNone(self.menu._transform_ask_options(self.RAPPORT))
self.assertEqual(len(entrees.demandes), 1)
def test_zero_annule_a_CHAQUE_question(self):
"""Une réponse valide jusqu'au rang N, puis « 0 »."""
# L'ordre du dialogue, la table venant maintenant en troisième.
valides = ["o", "csv", "", "o", "Ventes", "", "", "", "", "", "42"]
for rang in range(len(valides)):
with self.subTest(rang=rang):
entrees = self._repondre(*(valides[:rang] + ["0"]))
self.assertIsNone(
self.menu._transform_ask_options(self.RAPPORT)
)
self.assertEqual(len(entrees.demandes), rang + 1)
def test_une_cible_inconnue_est_refusee(self):
entrees = self._repondre("o", "parquet")
self.assertIsNone(self.menu._transform_ask_options(self.RAPPORT))
self.assertEqual(len(entrees.demandes), 2)
def test_les_quatre_cibles_sont_acceptees(self):
for cible in _tm().CIBLES:
with self.subTest(cible=cible):
self._repondre("o", cible, "", "o", "", "", "", "", "", "", "")
options = self.menu._transform_ask_options(self.RAPPORT)
self.assertEqual(options["conversion"], cible)
# -- les feuilles --------------------------------------------------
def test_une_feuille_inconnue_est_refusee_AVANT_d_ecrire(self):
entrees = self._repondre("o", "", "", "o", "Trésorerie")
self.assertIsNone(self.menu._transform_ask_options(self.RAPPORT))
self.assertEqual(len(entrees.demandes), 5)
def test_le_nom_de_feuille_se_resout_sans_la_casse(self):
"""Le nom rendu est celui du CLASSEUR, non celui tapé : la portée
s'apparie ensuite par égalité exacte."""
self._repondre(
"o", "", "", "o", " ventes , ACHATS ", "", "", "", "", "", ""
)
options = self.menu._transform_ask_options(self.RAPPORT)
self.assertEqual(options["feuilles"], ["Ventes", "Achats"])
def test_une_source_d_une_seule_feuille_ne_pose_pas_la_question(self):
entrees = self._repondre("o", "", "", "o", "", "", "", "", "", "")
rapport = dict(self.RAPPORT, feuilles=[{"nom": "F"}])
options = self.menu._transform_ask_options(rapport)
self.assertNotIn("feuilles", options)
self.assertEqual(len(entrees.demandes), 10)
# -- macros et graphiques ------------------------------------------
def test_les_macros_ne_se_demandent_que_si_le_fichier_en_a(self):
entrees = self._repondre(
"o", "", "", "o", "", "", "", "", "", "", "", "o", "n"
)
rapport = dict(
self.RAPPORT, hors_cellules={"macros": 1, "graphiques": 2}
)
options = self.menu._transform_ask_options(rapport)
self.assertTrue(options["garder_macros"])
self.assertFalse(options["garder_graphiques"])
self.assertEqual(len(entrees.demandes), 13)
def test_une_conversion_hors_xlsx_ne_les_demande_pas(self):
"""Un csv ne porte ni macro ni graphique : poser la question
laisserait croire que la réponse change quelque chose."""
entrees = self._repondre(
"o", "csv", "", "o", "", "", "", "", "", "", ""
)
rapport = dict(
self.RAPPORT, hors_cellules={"macros": 1, "graphiques": 2}
)
options = self.menu._transform_ask_options(rapport)
self.assertNotIn("garder_macros", options)
self.assertEqual(len(entrees.demandes), 11)
class _Acheve:
"""Ce que `subprocess.run` rend, réduit à ce que le menu en lit."""
def __init__(self, stdout="", stderr="", returncode=0):
self.stdout = stdout
self.stderr = stderr
self.returncode = returncode
class TestMenuMoteurEnSousProcessus(unittest.TestCase):
"""stdout ne porte qu'un objet JSON, stderr la progression.
Un stdout vide ou inanalysable est traité comme une ERREUR et non
relayé en exception, pour que le menu affiche les dernières lignes de
stderr plutôt que de tomber à son tour.
"""
def setUp(self):
self.menu = _MenuBouchon()
self.sortie = io.StringIO()
vrai = sys.stdout
sys.stdout = self.sortie
self.addCleanup(setattr, sys, "stdout", vrai)
self.vrai_run = _tm().subprocess.run
self.addCleanup(setattr, _tm().subprocess, "run", self.vrai_run)
def _rendre(self, acheve):
_tm().subprocess.run = lambda *a, **k: acheve
def test_un_objet_json_traverse(self):
self._rendre(_Acheve(stdout='{"format": "csv", "taille": 12}'))
self.assertEqual(
self.menu._transform_run(["--report", "a.csv"], "csv"),
{"format": "csv", "taille": 12},
)
def test_la_progression_de_stderr_s_affiche_sans_son_diese(self):
self._rendre(
_Acheve(stdout="{}", stderr="# lecture en cours\nbruit interne")
)
self.menu._transform_run(["--report", "a.csv"], "csv")
rendu = self.sortie.getvalue()
self.assertIn("lecture en cours", rendu)
self.assertNotIn("bruit interne", rendu)
def test_un_stdout_vide_ne_leve_pas_et_montre_stderr(self):
"""Le menu doit afficher la cause, non tomber à son tour."""
self._rendre(_Acheve(stdout="", stderr="Traceback ligne 1"))
self.assertIsNone(
self.menu._transform_run(["--report", "a.csv"], "csv")
)
self.assertIn("Traceback ligne 1", self.sortie.getvalue())
def test_un_stdout_inanalysable_est_traite_comme_une_erreur(self):
self._rendre(_Acheve(stdout="pas du json"))
self.assertIsNone(
self.menu._transform_run(["--report", "a.csv"], "csv")
)
def test_une_erreur_du_moteur_s_affiche_avec_son_detail(self):
self._rendre(
_Acheve(
stdout=json.dumps(
{"erreur": "Nothing to do.", "detail": " ici"}
)
)
)
self.assertIsNone(
self.menu._transform_run(["--apply", "a.csv"], "csv")
)
self.assertIn("ici", self.sortie.getvalue())
def test_le_CONSEIL_s_affiche_quand_le_refus_en_porte_un(self):
"""Le détail nomme une partie du format ; le conseil dit quoi
répondre à la prochaine exécution."""
avis = (
"The kept VBA project quotes a source value;"
" answer no to the macro question to write the copy."
)
self._rendre(
_Acheve(
stdout=json.dumps(
{"erreur": "Nothing to do.", "conseil": avis}
)
)
)
self.assertIsNone(
self.menu._transform_run(["--apply", "a.xlsm"], "xlsx")
)
rendu = self.sortie.getvalue()
self.assertIn("→", rendu)
self.assertIn(todo_i18n.TRANSLATIONS[avis]["fr"][:30], rendu)
def test_un_interpreteur_introuvable_ne_leve_pas(self):
def tombe(*a, **k):
raise OSError("introuvable")
_tm().subprocess.run = tombe
self.assertIsNone(
self.menu._transform_run(["--report", "a.csv"], "csv")
)
class TestMenuApercu(unittest.TestCase):
"""Montrer, PUIS demander. La convention du dépôt pour ce qui écrit."""
def setUp(self):
self.menu = _MenuBouchon()
self.sortie = io.StringIO()
vrai = sys.stdout
sys.stdout = self.sortie
self.addCleanup(setattr, sys, "stdout", vrai)
vrai_input = builtins.input
self.addCleanup(setattr, builtins, "input", vrai_input)
def _repondre(self, reponse):
builtins.input = lambda invite="": reponse
def test_une_reponse_vide_ECRIT(self):
"""Le défaut de la dernière question est oui : l'opérateur vient
de lire l'aperçu."""
self._repondre("")
self.assertTrue(self.menu._transform_preview({"remplacees": 3}))
def test_un_non_n_ecrit_pas(self):
self._repondre("n")
self.assertFalse(self.menu._transform_preview({"remplacees": 3}))
def test_l_intact_est_ANNONCE_avant_le_consentement(self):
"""Une date et un booléen traversent par RÈGLE et sont de vraies
valeurs du client : les taire faisait signer un consentement sur
un fichier dont une colonne part en clair."""
self._repondre("")
self.menu._transform_preview(
{"remplacees": 3, "intactes": {"date": 7, "booleen": 2}}
)
rendu = self.sortie.getvalue()
self.assertIn("7", rendu)
self.assertIn(todo_i18n.t("date(s)"), rendu)
def test_l_empan_garde_est_montre_cellule_par_cellule(self):
"""Un compteur ne dirait pas qu'un nom est dedans."""
self._repondre("")
self.menu._transform_preview(
{
"remplacees": 1,
"entete_gardee": [{"cellule": "L1C1", "valeur": "aboulie"}],
}
)
rendu = self.sortie.getvalue()
self.assertIn("L1C1", rendu)
self.assertIn("aboulie", rendu)
def test_une_colonne_entierement_en_clair_est_NOMMEE(self):
"""Le compte par famille dit POURQUOI, non OÙ.
Sur quarante colonnes, « 7 date(s) laissées » ne dit pas laquelle
sort, et c'est la colonne qui se transmet, non la famille.
"""
self._repondre("")
self.menu._transform_preview(
{
"remplacees": 1,
"colonnes_en_clair": [
{
"feuille": "Ventes",
"index": 2,
"etiquette": "actif",
"cellules": 3,
},
{
"feuille": "Ventes",
"index": 5,
"etiquette": "",
"cellules": 3,
},
],
}
)
rendu = self.sortie.getvalue()
self.assertIn(todo_i18n.t("column(s) entirely in clear"), rendu)
self.assertIn("Ventes/actif", rendu)
# Sans étiquette, l'index : sinon la colonne n'est pas désignable.
self.assertIn("Ventes/#5", rendu)
def test_ce_que_le_plafond_n_a_pas_liste_est_DIT(self):
"""Sinon l'opérateur consent sur un extrait pris pour le tout."""
self._repondre("")
self.menu._transform_preview(
{
"remplacees": 1,
"entete_gardee": [{"cellule": "L1C1", "valeur": "aboulie"}],
"entete_gardee_omises": {"Ventes": 3},
}
)
rendu = self.sortie.getvalue()
self.assertIn("Ventes", rendu)
self.assertIn("3", rendu)
self.assertIn(todo_i18n.t("more, not listed"), rendu)
def test_les_avertissements_sont_traduits(self):
self._repondre("")
avis = "The copy is written in UTF-8, whatever the source was."
self.menu._transform_preview(
{"remplacees": 1, "avertissements": [avis]}
)
self.assertIn(
todo_i18n.TRANSLATIONS[avis]["fr"][:30], self.sortie.getvalue()
)
def test_les_fichiers_prevus_sont_montres(self):
"""Ce sur quoi l'opérateur consent inclut OÙ ça va."""
self._repondre("")
self.menu._transform_preview(
{"remplacees": 1, "fichiers": ["/tmp/a.csv", "/tmp/b.csv"]}
)
rendu = self.sortie.getvalue()
self.assertIn("/tmp/a.csv", rendu)
self.assertIn("/tmp/b.csv", rendu)
class TestMenuDestination(unittest.TestCase):
"""Le défaut NE REPREND PAS le nom source : il porte le client."""
def setUp(self):
self.menu = _MenuBouchon()
self.sortie = io.StringIO()
vrai = sys.stdout
sys.stdout = self.sortie
self.addCleanup(setattr, sys, "stdout", vrai)
vrai_input = builtins.input
self.addCleanup(setattr, builtins, "input", vrai_input)
def _repondre(self, reponse):
builtins.input = lambda invite="": reponse
def test_le_defaut_ne_reprend_pas_le_nom_source(self):
self._repondre("")
rendu = self.menu._transform_select_destination(
"/tmp/Cabinet_Lavigne_2024.xlsx", "xlsx"
)
self.assertNotIn("Lavigne", rendu)
self.assertTrue(rendu.endswith(".xlsx"), rendu)
self.assertTrue(rendu.startswith(_tm().SORTIE_PAR_DEFAUT), rendu)
def test_l_extension_suit_le_format_de_SORTIE(self):
for fmt, extension in (
("xlsx", ".xlsx"),
("csv", ".csv"),
("json", ".json"),
("xml", ".xml"),
("xls", ".xlsx"),
("access", ".xlsx"),
):
with self.subTest(fmt=fmt):
self._repondre("")
rendu = self.menu._transform_select_destination("s", fmt)
self.assertTrue(rendu.endswith(extension), rendu)
def test_garder_les_macros_nomme_la_copie_xlsm(self):
"""Excel lie l'extension au contenu et refuse d'ouvrir un .xlsx
qui porte un projet VBA : la copie était juste et n'ouvrait pas."""
self._repondre("")
rendu = self.menu._transform_select_destination(
"s.xlsm", "xlsx", macros=True
)
self.assertTrue(rendu.endswith(".xlsm"), rendu)
self.assertIn(".xlsm", self.sortie.getvalue())
def test_les_macros_ne_changent_rien_hors_xlsx(self):
self._repondre("")
rendu = self.menu._transform_select_destination(
"s.csv", "csv", macros=True
)
self.assertTrue(rendu.endswith(".csv"), rendu)
def test_un_chemin_tape_est_pris_et_developpe(self):
self._repondre("~/copie.xlsx")
self.assertEqual(
self.menu._transform_select_destination("s", "xlsx"),
os.path.expanduser("~/copie.xlsx"),
)
def test_zero_renonce(self):
self._repondre("0")
self.assertIsNone(self.menu._transform_select_destination("s", "xlsx"))
class TestMenuEcrasement(unittest.TestCase):
"""Le nom tapé EN ENTIER, comme le reste du dépôt l'exige."""
def setUp(self):
self.menu = _MenuBouchon()
self.base = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.base, True)
self.sortie = io.StringIO()
vrai = sys.stdout
sys.stdout = self.sortie
self.addCleanup(setattr, sys, "stdout", vrai)
vrai_input = builtins.input
self.addCleanup(setattr, builtins, "input", vrai_input)
self.existant = os.path.join(self.base, "copie.xlsx")
with open(self.existant, "w", encoding="utf-8") as flux:
flux.write("x")
def test_rien_a_ecraser_ne_demande_rien(self):
def refuse(invite=""):
raise AssertionError("aucune question ne devait être posée")
builtins.input = refuse
absent = os.path.join(self.base, "neuf.xlsx")
self.assertTrue(self.menu._transform_confirm_overwrite([absent]))
def test_le_nom_exact_autorise(self):
builtins.input = lambda invite="": "copie.xlsx"
self.assertTrue(
self.menu._transform_confirm_overwrite([self.existant])
)
def test_un_nom_approchant_refuse(self):
for reponse in ("copie", "copie.xls", "o", "", self.existant):
with self.subTest(reponse=reponse):
builtins.input = lambda invite="", r=reponse: r
self.assertFalse(
self.menu._transform_confirm_overwrite([self.existant])
)
def test_les_cibles_existantes_sont_TOUTES_nommees(self):
second = os.path.join(self.base, "autre.csv")
with open(second, "w", encoding="utf-8") as flux:
flux.write("y")
builtins.input = lambda invite="": "copie.xlsx"
self.menu._transform_confirm_overwrite([self.existant, second, None])
rendu = self.sortie.getvalue()
self.assertIn("copie.xlsx", rendu)
self.assertIn("autre.csv", rendu)
class TestMenuInventaire(unittest.TestCase):
"""Ce que l'outil a produit se reconnaît à son NOM.
Le navigateur ouvre son parcours dans ce même répertoire : ce qui s'y
trouve n'est pas toujours une copie produite ici, et l'effacement en
bloc l'emportait aussi.
"""
def setUp(self):
self.menu = _MenuBouchon()
self.base = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.base, True)
vrai = _tm().SORTIE_PAR_DEFAUT
_tm().SORTIE_PAR_DEFAUT = self.base
self.addCleanup(setattr, _tm(), "SORTIE_PAR_DEFAUT", vrai)
def _poser(self, nom, contenu="x"):
chemin = os.path.join(self.base, nom)
with open(chemin, "w", encoding="utf-8") as flux:
flux.write(contenu)
return chemin
def test_les_copies_et_les_tables_sont_dites_produites(self):
self._poser("20260101-000000.anon.xlsx")
self._poser("20260101-000000.table.json")
produites, etrangeres = self.menu._transform_inventaire()
self.assertEqual(
[n for n, _ in produites],
["20260101-000000.anon.xlsx", "20260101-000000.table.json"],
)
self.assertEqual(etrangeres, [])
def test_un_fichier_depose_par_quelqu_un_d_autre_est_ETRANGER(self):
"""Il n'est pas à effacer : le parcours ouvre ici."""
self._poser("export_du_client.xlsx")
produites, etrangeres = self.menu._transform_inventaire()
self.assertEqual(produites, [])
self.assertEqual([n for n, _ in etrangeres], ["export_du_client.xlsx"])
def test_un_repertoire_de_conversion_pese_la_somme_de_ses_fichiers(self):
"""Une source à plusieurs feuilles convertie en csv écrit un
RÉPERTOIRE. Il était compté à la taille de son inode."""
dossier = os.path.join(self.base, "20260101.anon.csv")
os.makedirs(os.path.join(dossier, "sous"))
for chemin, contenu in (
(os.path.join(dossier, "a.csv"), "12345"),
(os.path.join(dossier, "sous", "b.csv"), "678"),
):
with open(chemin, "w", encoding="utf-8") as flux:
flux.write(contenu)
produites, _etrangeres = self.menu._transform_inventaire()
self.assertEqual(produites, [("20260101.anon.csv", 8)])
def test_un_repertoire_vide_pese_zero_et_reste_liste(self):
os.makedirs(os.path.join(self.base, "20260101.anon.csv"))
produites, _e = self.menu._transform_inventaire()
self.assertEqual(produites, [("20260101.anon.csv", 0)])
class TestMenuFichierEntree(unittest.TestCase):
"""Le parcours d'abord, la saisie en repli.
La garde d'import est celle de `database_manager` : urwid peut
manquer, et sans elle « p » lèverait sur None.
"""
def setUp(self):
self.menu = _MenuBouchon()
self.base = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.base, True)
self.sortie = io.StringIO()
vrai_out = sys.stdout
sys.stdout = self.sortie
self.addCleanup(setattr, sys, "stdout", vrai_out)
vrai_input = builtins.input
self.addCleanup(setattr, builtins, "input", vrai_input)
# Sans navigateur, la saisie répond : c'est le chemin qu'on teste,
# et lancer urwid dans une suite unitaire n'a pas de sens.
vrai_nav = _tm().todo_file_browser
_tm().todo_file_browser = None
self.addCleanup(setattr, _tm(), "todo_file_browser", vrai_nav)
def _repondre(self, reponse):
builtins.input = lambda invite="": reponse
def test_un_fichier_ordinaire_est_pris(self):
chemin = os.path.join(self.base, "s.csv")
with open(chemin, "w", encoding="utf-8") as flux:
flux.write("a\n")
self._repondre(chemin)
self.assertEqual(self.menu._transform_select_file(), chemin)
def test_un_repertoire_est_refuse(self):
"""`--report` sur un répertoire ferait lever le moteur."""
self._repondre(self.base)
self.assertIsNone(self.menu._transform_select_file())
def test_un_chemin_absent_est_refuse(self):
self._repondre(os.path.join(self.base, "absent.csv"))
self.assertIsNone(self.menu._transform_select_file())
def test_une_reponse_vide_ou_zero_renonce(self):
for reponse in ("", "0", " "):
with self.subTest(reponse=reponse):
self._repondre(reponse)
self.assertIsNone(self.menu._transform_select_file())
class TestMenuRendus(unittest.TestCase):
"""Le rapport et le bilan : ce que l'opérateur lit pour décider."""
def setUp(self):
self.menu = _MenuBouchon()
self.sortie = io.StringIO()
vrai = sys.stdout
sys.stdout = self.sortie
self.addCleanup(setattr, sys, "stdout", vrai)
def test_le_rapport_nomme_le_fichier_sa_taille_et_son_format(self):
rendu = self.menu._transform_render_report(
{"chemin": "/tmp/s.csv", "taille": 42, "format": "csv"}
)
self.assertIn("s.csv", rendu)
self.assertIn("42", rendu)
self.assertIn("csv", rendu)
def test_une_divergence_entre_octets_et_extension_est_DITE(self):
"""Un export d'ERP en HTML sous une extension .csv se lit comme
du HTML : le taire ferait consentir sur un format supposé."""
rendu = self.menu._transform_render_report(
{
"chemin": "s.csv",
"taille": 1,
"format": "xml",
"divergence": True,
}
)
self.assertIn(
todo_i18n.t("Contents do not match the extension: read as "),
rendu,
)
def test_l_encodage_et_le_delimiteur_disent_QUI_les_a_decides(self):
rendu = self.menu._transform_render_report(
{
"chemin": "s.csv",
"taille": 1,
"format": "csv",
"encodage": "cp1252",
"encodage_source": "chardet",
"delimiteur": ";",
"delimiteur_source": "sniffer",
}
)
self.assertIn("cp1252", rendu)
self.assertIn(todo_i18n.t("chardet"), rendu)
self.assertIn(todo_i18n.t("sniffer"), rendu)
def test_les_colonnes_sans_exemple_sont_COMPTEES_dans_le_rapport(self):
"""Une colonne sans exemple se lit comme une colonne vide."""
rendu = self.menu._transform_render_report(
{
"chemin": "s.csv",
"taille": 1,
"format": "csv",
"feuilles": [
{
"nom": "F",
"lignes": 3,
"colonnes_n": 505,
"formules": 0,
"colonnes": [],
"exemples_manquants": 5,
}
],
}
)
self.assertIn(todo_i18n.t("column(s) without examples"), rendu)
self.assertIn("5", rendu)
def test_le_bilan_nomme_CHAQUE_fichier_ecrit(self):
"""Une conversion à plusieurs feuilles en écrit plusieurs."""
self.menu._transform_render_bilan(
{"fichiers": ["/tmp/a.csv", "/tmp/b.csv"], "remplacees": 4},
"/tmp/ignore",
)
rendu = self.sortie.getvalue()
self.assertIn("/tmp/a.csv", rendu)
self.assertIn("/tmp/b.csv", rendu)
self.assertNotIn("ignore", rendu)
def test_sans_liste_le_bilan_retombe_sur_la_destination(self):
self.menu._transform_render_bilan({"remplacees": 1}, "/tmp/seul.xlsx")
self.assertIn("/tmp/seul.xlsx", self.sortie.getvalue())
class TestMenuEffacement(unittest.TestCase):
"""L'effacement en bloc n'emporte QUE ce que l'outil a produit."""
def setUp(self):
self.menu = _MenuBouchon()
self.base = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.base, True)
vrai_sortie = _tm().SORTIE_PAR_DEFAUT
_tm().SORTIE_PAR_DEFAUT = self.base
self.addCleanup(setattr, _tm(), "SORTIE_PAR_DEFAUT", vrai_sortie)
self.ecran = io.StringIO()
vrai_out = sys.stdout
sys.stdout = self.ecran
self.addCleanup(setattr, sys, "stdout", vrai_out)
vrai_input = builtins.input
self.addCleanup(setattr, builtins, "input", vrai_input)
self.copie = os.path.join(self.base, "20260101.anon.xlsx")
self.table = os.path.join(self.base, "20260101.table.json")
self.etranger = os.path.join(self.base, "export_du_client.xlsx")
for chemin in (self.copie, self.table, self.etranger):
with open(chemin, "w", encoding="utf-8") as flux:
flux.write("x")
self.dossier = os.path.join(self.base, "20260102.anon.csv")
os.makedirs(self.dossier)
with open(os.path.join(self.dossier, "a.csv"), "w") as flux:
flux.write("y")
def test_oui_efface_les_copies_et_LAISSE_l_etranger(self):
builtins.input = lambda invite="": "o"
self.menu._transform_copies()
self.assertFalse(os.path.exists(self.copie))
self.assertFalse(os.path.exists(self.table))
self.assertFalse(os.path.exists(self.dossier))
self.assertTrue(os.path.exists(self.etranger))
def test_non_n_efface_rien(self):
for reponse in ("n", "", "0", "x"):
with self.subTest(reponse=reponse):
builtins.input = lambda invite="", r=reponse: r
self.menu._transform_copies()
self.assertTrue(os.path.exists(self.copie))
self.assertTrue(os.path.exists(self.dossier))
def test_l_etranger_est_NOMME_a_l_ecran(self):
"""Le parcours ouvre dans ce répertoire : ce qui s'y trouve n'est
pas toujours une copie produite ici."""
builtins.input = lambda invite="": "n"
self.menu._transform_copies()
rendu = self.ecran.getvalue()
self.assertIn("export_du_client.xlsx", rendu)
self.assertIn(todo_i18n.t("Not produced here, left alone:"), rendu)
def test_un_repertoire_absent_ne_leve_pas(self):
_tm().SORTIE_PAR_DEFAUT = os.path.join(self.base, "absent")
def refuse(invite=""):
raise AssertionError("rien à effacer, rien à demander")
builtins.input = refuse
self.menu._transform_copies()
def test_sans_copie_produite_aucune_question_n_est_posee(self):
os.remove(self.copie)
os.remove(self.table)
shutil.rmtree(self.dossier)
def refuse(invite=""):
raise AssertionError("rien à effacer, rien à demander")
builtins.input = refuse
self.menu._transform_copies()
self.assertTrue(os.path.exists(self.etranger))
class TestMenuDerouleComplet(unittest.TestCase):
"""L'ordre du dialogue EST la règle : rapport, questions, marche à
blanc, écriture. Une question posée avant de savoir ce qui sera touché
n'est pas un consentement.
Le moteur est bouchonné : ce qu'on éprouve ici est le CÂBLAGE — quels
arguments partent, dans quel ordre, et ce qui arrête le déroulé.
"""
def setUp(self):
self.menu = _MenuBouchon()
self.base = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.base, True)
self.source = os.path.join(self.base, "s.csv")
with open(self.source, "w", encoding="utf-8") as flux:
flux.write("etiquette\naboulie\n")
self.ecran = io.StringIO()
vrai_out = sys.stdout
sys.stdout = self.ecran
self.addCleanup(setattr, sys, "stdout", vrai_out)
vrai_input = builtins.input
self.addCleanup(setattr, builtins, "input", vrai_input)
vrai_nav = _tm().todo_file_browser
_tm().todo_file_browser = None
self.addCleanup(setattr, _tm(), "todo_file_browser", vrai_nav)
# `ensure` installerait un venv : ici le format est du pur stdlib.
self.appels = []
self.rendus = []
self.menu._transform_run = self._run
# Comme ci-dessus : l'écran réel bloquerait la suite.
self.menu._transform_ecran = lambda *args: {}
# Le rapport est un VRAI rapport, non une main écrite : la forme
# que le rendu attend change avec le moteur, et une fixture à la
# main dériverait sans qu'un test le voie.
self.rapport = formats.report(self.source)
def _run(self, arguments, fmt=None):
self.appels.append((list(arguments), fmt))
return self.rendus.pop(0) if self.rendus else None
def _dialogue(self, *reponses):
builtins.input = _Entrees(*reponses)
def test_le_deroule_qui_aboutit_appelle_plan_PUIS_apply(self):
self.rendus = [
self.rapport,
{"remplacees": 1, "fichiers": [os.path.join(self.base, "o.csv")]},
{"remplacees": 1, "fichiers": [os.path.join(self.base, "o.csv")]},
]
self._dialogue(
self.source, # le fichier
"o",
"",
"",
"",
"",
"",
"",
"",
"", # les questions
os.path.join(self.base, "o.csv"), # la destination
"", # écrire ? défaut oui
)
self.menu._transform_open_and_report()
etapes = [a[0][0] for a in self.appels]
self.assertEqual(etapes, ["--report", "--plan", "--apply"])
def test_un_arret_annonce_par_le_moteur_stoppe_avant_les_questions(self):
"""Un `.xlsb` reconnu et illisible ici s'arrête là : poser les
douze questions pour finir sur un refus est une perte de temps."""
self.rendus = [dict(self.rapport, arret="Nothing to do.")]
# L'arrêt vient du moteur : le rapport est complet, mais il dit
# que rien ne peut être écrit.
posees = []
def refuse(invite=""):
posees.append(invite)
if len(posees) == 1:
return self.source
raise AssertionError("aucune question ne devait suivre l'arrêt")
builtins.input = refuse
self.menu._transform_open_and_report()
self.assertEqual([a[0][0] for a in self.appels], ["--report"])
def test_refuser_l_apercu_n_appelle_PAS_apply(self):
self.rendus = [self.rapport, {"remplacees": 1}]
self._dialogue(
self.source,
"o", # anonymiser
"", # convertir : garder le format
"", # table du lot
"o", # options avancées
"", # colonnes intactes
"", # nombres
"", # calibre des chiffres
"", # texte
"", # en-têtes
"", # graine
os.path.join(self.base, "o.csv"),
"n",
)
self.menu._transform_open_and_report()
self.assertEqual(
[a[0][0] for a in self.appels], ["--report", "--plan"]
)
def test_renoncer_aux_questions_n_appelle_ni_plan_ni_apply(self):
self.rendus = [self.rapport]
self._dialogue(self.source, "n")
self.menu._transform_open_and_report()
self.assertEqual([a[0][0] for a in self.appels], ["--report"])
def test_la_table_par_defaut_est_passee_au_moteur(self):
"""Sans elle, un lot ne donne pas le même mot au même client."""
self.rendus = [self.rapport, {"remplacees": 1}, {"remplacees": 1}]
self._dialogue(
self.source,
"o", # anonymiser
"", # convertir : garder le format
"", # table du lot
"o", # options avancées
"", # colonnes intactes
"", # nombres
"", # calibre des chiffres
"", # texte
"", # en-têtes
"", # graine
os.path.join(self.base, "o.csv"),
"",
)
self.menu._transform_open_and_report()
plan = self.appels[1][0]
self.assertIn("--table", plan)
table = plan[plan.index("--table") + 1]
self.assertTrue(table.endswith(".table.json"), table)
self.assertTrue(table.startswith(_tm().SORTIE_PAR_DEFAUT), table)
def test_l_ecrasement_est_confirme_sur_les_fichiers_du_PLAN(self):
"""La marche à blanc dit les chemins réels ; confirmer sur la
seule destination manquait ceux d'une conversion par feuille."""
deja = os.path.join(self.base, "deja.csv")
with open(deja, "w", encoding="utf-8") as flux:
flux.write("z")
self.rendus = [self.rapport, {"remplacees": 1, "fichiers": [deja]}]
self._dialogue(
self.source,
"o", # anonymiser
"", # convertir : garder le format
"", # table du lot
"o", # options avancées
"", # colonnes intactes
"", # nombres
"", # calibre des chiffres
"", # texte
"", # en-têtes
"", # graine
os.path.join(self.base, "o.csv"),
"",
"pas-le-bon-nom",
)
self.menu._transform_open_and_report()
self.assertEqual(
[a[0][0] for a in self.appels], ["--report", "--plan"]
)
self.assertIn(
todo_i18n.t("Name does not match, nothing was written."),
self.ecran.getvalue(),
)
def test_les_options_partent_en_json_analysable(self):
self.rendus = [self.rapport, {"remplacees": 1}, {"remplacees": 1}]
# Une source d'UNE feuille ne pose pas la question des feuilles.
self._dialogue(
self.source,
"o", # anonymiser
"json", # convertir
"", # table du lot — posée AVANT l'écran
"o", # options avancées
"id", # colonnes intactes
"", # nombres
"", # calibre des chiffres
"", # texte
"", # en-têtes
"7", # graine
os.path.join(self.base, "o.json"),
"", # écrire
)
self.menu._transform_open_and_report()
plan = self.appels[1][0]
options = json.loads(plan[plan.index("--options") + 1])
self.assertEqual(options["conversion"], "json")
self.assertEqual(options["colonnes_intactes"], ["id"])
self.assertEqual(options["graine"], "7")
self.assertTrue(options["destination"].endswith("o.json"))
def _colonne(
etiquette, mini, maxi, distinctes, entiere=True, plancher=False, index=1
):
return {
"index": index,
"etiquette": etiquette,
"type": "nombre",
"remplies": distinctes,
"distinctes": distinctes,
"min": mini,
"max": maxi,
"entiere": entiere,
"plancher": plancher,
}
def _rapport(colonnes, nom="F", format_lu="xlsx"):
return {
"format": format_lu,
"feuilles": [{"nom": nom, "colonnes": colonnes}],
"hors_cellules": {},
}
class TestColonneSaturee(unittest.TestCase):
"""Une colonne d'entiers pleine ressort PERMUTÉE.
Le tirage est sans remise et reste dans l'étendue mesurée : avec
autant de valeurs distinctes que l'étendue compte d'entiers,
l'ensemble de sortie EST l'ensemble d'entrée. Ce n'est pas une fuite —
la permutation ne s'inverse pas sans la table — mais l'écran annonce
« N nombres remplacés » et une comparaison d'ensembles ne montrerait
rien.
"""
OPTIONS = {"nombres": True, "colonnes_intactes": []}
def _saturees(self, colonnes, **surcharges):
options = dict(self.OPTIONS, **surcharges)
return formats._colonnes_saturees(_rapport(colonnes), options)
def test_une_etendue_pleine_est_signalee(self):
"""830 valeurs distinctes dans 830 entiers : aucune liberté."""
self.assertEqual(
self._saturees([_colonne("OrderID", 10248, 11077, 830)]),
[("F", "OrderID")],
)
def test_une_etendue_large_ne_l_est_pas(self):
self.assertEqual(
self._saturees([_colonne("montant", 1, 100000, 830)]), []
)
def test_neuf_dixiemes_suffisent(self):
"""La copie porte déjà presque le même ensemble."""
self.assertTrue(self._saturees([_colonne("k", 1, 100, 90)]))
self.assertFalse(self._saturees([_colonne("k", 1, 100, 89)]))
def test_une_colonne_decimale_n_est_jamais_saturee(self):
"""Entre deux entiers, un décimal a une infinité de places."""
self.assertEqual(
self._saturees([_colonne("taux", 1, 30, 30, entiere=False)]),
[],
)
def test_une_petite_colonne_pleine_ne_dit_rien_de_personne(self):
"""Un drapeau à deux états, un mois sur douze : l'avertissement y
serait du bruit, et le bruit finit par se lire comme du fond."""
for etendue in (2, 12, 19):
with self.subTest(etendue=etendue):
self.assertEqual(
self._saturees([_colonne("m", 1, etendue, etendue)]), []
)
self.assertTrue(self._saturees([_colonne("m", 1, 20, 20)]))
def test_une_colonne_plancheiee_n_entre_pas(self):
"""Elle n'est pas remplacée du tout, et `colonnes_ecartees` la
nomme déjà."""
self.assertEqual(
self._saturees(
[_colonne("partner_id", 1, 830, 830, plancher=True)]
),
[],
)
def test_une_colonne_laissee_intacte_n_entre_pas(self):
self.assertEqual(
self._saturees(
[_colonne("OrderID", 1, 830, 830)],
colonnes_intactes={"OrderID"},
),
[],
)
def test_sans_remplacement_des_nombres_rien_n_est_dit(self):
self.assertEqual(
self._saturees([_colonne("k", 1, 830, 830)], nombres=False), []
)
def test_un_compte_au_PLAFOND_ne_conclut_rien(self):
"""`_stats_colonnes` cesse de compter au-delà : le nombre ne dit
plus combien la colonne porte, et rien n'en découle."""
plafond = formats.PLAFOND_DISTINCTES
self.assertEqual(
self._saturees([_colonne("k", 1, plafond, plafond)]), []
)
def test_une_feuille_hors_selection_n_entre_pas(self):
rapport = _rapport([_colonne("k", 1, 830, 830)], nom="Achats")
options = dict(self.OPTIONS, feuilles=["Ventes"])
self.assertEqual(formats._colonnes_saturees(rapport, options), [])
def test_l_avertissement_est_dit_une_seule_fois(self):
colonnes = [
_colonne("a", 1, 830, 830, index=1),
_colonne("b", 1, 830, 830, index=2),
]
dits = formats._avertissements(_rapport(colonnes), self.OPTIONS)
avis = [d for d in dits if "saturated" in d]
self.assertEqual(len(avis), 1)
self.assertIn(avis[0], todo_i18n.TRANSLATIONS)
class TestAvertissementDesNomsDeFeuille(unittest.TestCase):
"""Il ne vaut QUE pour le chemin qui les recopie tels quels.
Toute conversion passe par un classeur neuf dont les onglets
reçoivent un nom de la table, et `.xls` comme Access n'ont pas de
graveur — leur copie repart par cette même conversion. Le dire quand
ce n'est pas vrai apprend à ne plus lire la liste, qui EST la surface
du consentement.
"""
AVIS = "Sheet names are kept so formulas resolve; they may identify."
def _dits(self, format_lu, conversion=""):
return formats._avertissements(
{"format": format_lu, "hors_cellules": {}, "feuilles": []},
{"conversion": conversion},
)
def test_un_xlsx_sans_conversion_les_garde(self):
self.assertIn(self.AVIS, self._dits("xlsx"))
self.assertIn(self.AVIS, self._dits("xlsx", "xlsx"))
def test_une_conversion_les_anonymise(self):
for cible in ("csv", "json", "xml"):
with self.subTest(cible=cible):
self.assertNotIn(self.AVIS, self._dits("xlsx", cible))
def test_xls_et_access_n_ont_pas_de_graveur(self):
"""Leur copie repart en .xlsx par la conversion, qui renomme."""
for format_lu in ("xls", "access"):
with self.subTest(format_lu=format_lu):
self.assertNotIn(self.AVIS, self._dits(format_lu))
def test_les_plages_nommees_suivent_la_meme_condition(self):
avis = (
"Range and table names are kept so formulas resolve;"
" they may hold identifying strings."
)
garde = formats._avertissements(
{
"format": "xlsx",
"hors_cellules": {"plages_nommees": 2},
"feuilles": [],
},
{"conversion": ""},
)
convertit = formats._avertissements(
{
"format": "xlsx",
"hors_cellules": {"plages_nommees": 2},
"feuilles": [],
},
{"conversion": "csv"},
)
self.assertIn(avis, garde)
self.assertNotIn(avis, convertit)
class _NavigateurBouchon:
"""Le navigateur de fichiers, réduit à son CONTRAT.
Il appelle le rappel avec un chemin, puis sort de sa boucle — ce que
le vrai fait aussi, `exit_program()` suivant l'appel dans son code.
Le bouchonner à `None` sautait la branche entière, et c'est ainsi
qu'un rappel introuvable a atteint le premier usage de l'entrée.
"""
def __init__(self, rendu):
self.rendu = rendu
self.appele_avec = None
def FileBrowser(self, depart, rappel, open_dir=False):
self.depart = depart
self.open_dir = open_dir
navigateur = self
class Fenetre:
def run_main_frame(self):
navigateur.appele_avec = navigateur.rendu
rappel(navigateur.rendu)
return Fenetre()
class TestMenuNavigateurDeFichiers(unittest.TestCase):
"""Le rappel que le navigateur appelle doit EXISTER sur la classe.
Ce mixin fournit le sien : `TODO` nomme le sien `on_dir_selected`, et
le préfixé vit sur le gestionnaire de bases, qui n'est pas un mixin.
L'emprunter faisait lever `AttributeError` à l'ouverture du
navigateur — donc sur la première ligne du menu, au premier usage.
"""
def setUp(self):
self.menu = _MenuBouchon()
self.base = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.base, True)
self.ecran = io.StringIO()
vrai_out = sys.stdout
sys.stdout = self.ecran
self.addCleanup(setattr, sys, "stdout", vrai_out)
vrai_input = builtins.input
self.addCleanup(setattr, builtins, "input", vrai_input)
self.vrai_nav = _tm().todo_file_browser
self.addCleanup(setattr, _tm(), "todo_file_browser", self.vrai_nav)
def _navigateur(self, rendu):
bouchon = _NavigateurBouchon(rendu)
_tm().todo_file_browser = bouchon
return bouchon
def test_le_rappel_existe_sur_la_classe_d_accueil(self):
"""La vraie classe, non le bouchon : c'est `TODO` qui reçoit le
mixin, et c'est là que le rappel manquait."""
from script.todo.todo import TODO
self.assertTrue(hasattr(TODO, "_on_dir_selected"))
hote = TODO.__new__(TODO)
hote._on_dir_selected("/tmp/choisi")
self.assertEqual(hote._dir_path, "/tmp/choisi")
def test_un_fichier_choisi_au_navigateur_est_rendu(self):
chemin = os.path.join(self.base, "s.csv")
with open(chemin, "w", encoding="utf-8") as flux:
flux.write("a\n")
self._navigateur(chemin)
self.assertEqual(self.menu._transform_select_file(), chemin)
def test_un_choix_qui_n_est_pas_un_fichier_retombe_sur_la_saisie(self):
"""Le navigateur peut rendre un répertoire : la saisie tranche."""
self._navigateur(self.base)
chemin = os.path.join(self.base, "s.csv")
with open(chemin, "w", encoding="utf-8") as flux:
flux.write("a\n")
builtins.input = lambda invite="": chemin
self.assertEqual(self.menu._transform_select_file(), chemin)
def test_la_destination_passe_aussi_par_le_rappel(self):
"""Second appel du navigateur, en mode répertoire."""
bouchon = self._navigateur(self.base)
builtins.input = lambda invite="": "p"
rendu = self.menu._transform_select_destination("s.xlsx", "xlsx")
self.assertTrue(bouchon.open_dir)
self.assertEqual(os.path.dirname(rendu), self.base)
self.assertTrue(rendu.endswith(".xlsx"), rendu)
def test_un_navigateur_absent_ne_leve_pas(self):
"""urwid peut manquer : « p » lèverait alors sur None."""
_tm().todo_file_browser = None
builtins.input = lambda invite="": "p"
self.assertTrue(
self.menu._transform_select_destination("s.xlsx", "xlsx")
)
class TestGardeeSousSesDeuxFormes(unittest.TestCase):
"""Le filet cherche la valeur telle que la TABLE la porte.
Un en-tête qui finit par une espace est annoncé remplacé sous sa forme
brute — là où il est en portée — et n'était excusé que sous sa forme
dépouillée là où il est gardé. Un fichier ordinaire dont une colonne
s'intitule « Montant » avec une espace finale se faisait refuser.
"""
def test_les_deux_formes_sont_notees(self):
gardees = {}
formats._noter_gardee(gardees, "F", "aboulie ")
self.assertEqual(gardees["F"], {"aboulie ", "aboulie"})
def test_une_valeur_sans_espace_n_est_notee_qu_une_fois(self):
gardees = {}
formats._noter_gardee(gardees, "F", "aboulie")
self.assertEqual(gardees["F"], {"aboulie"})
def test_le_seuil_du_filet_s_applique_aux_deux(self):
"""Sous le seuil, le filet ne cherche pas : le noter serait
tolérer une chaîne qu'il n'examine jamais."""
gardees = {}
formats._noter_gardee(gardees, "F", " ab ")
self.assertEqual(gardees.get("F"), {" ab "})
def test_une_valeur_non_texte_traverse_sans_lever(self):
gardees = {}
formats._noter_gardee(gardees, "F", 12345)
formats._noter_gardee(gardees, "F", None)
self.assertEqual(gardees.get("F"), {"12345"})
class TestNomsDeColonneDeTableau(unittest.TestCase):
"""OOXML exige que `tableColumn.name` égale sa cellule d'en-tête, et
que les noms d'un tableau soient DISTINCTS.
Deux manquements, chacun grave à sa façon : un nom qu'aucune cellule
ne porte laissait sortir le texte d'origine SANS que le filet puisse
le voir — il n'a jamais été lu d'une cellule, donc jamais annoncé — et
un en-tête de deux lignes donnait deux fois le même nom, ce qu'Excel
annonce comme un fichier à réparer.
"""
# La fonction ne touche que des ATTRIBUTS : un bouchon suffit, et le
# test tourne alors sous l'interpréteur du CLI, qui n'a pas openpyxl.
# Le faire par un vrai classeur l'aurait exclu de la suite.
@staticmethod
def _classeur(lignes, hauteur_entete=1, noms=("a", "b")):
class Colonne:
def __init__(self, nom):
self.name = nom
self.totalsRowLabel = None
class Cellule:
def __init__(self, ligne, colonne, valeur):
self.row = ligne
self.column = colonne
self.value = valeur
class Tableau:
def __init__(self):
self.ref = "A1:B%d" % len(lignes)
self.headerRowCount = hauteur_entete
self.tableColumns = [Colonne(n) for n in noms]
class Onglet:
tables = {}
def __getitem__(self, adresse):
assert adresse == "A1", adresse
return Cellule(1, 1, None)
def cell(self, row, column):
ligne = lignes[row - 1] if row <= len(lignes) else []
valeur = ligne[column - 1] if column <= len(ligne) else None
return Cellule(row, column, valeur)
class Classeur:
def __init__(self, onglet):
self.worksheets = [onglet]
onglet = Onglet()
tableau = Tableau()
onglet.tables = {"T1": tableau}
return Classeur(onglet), onglet, tableau
def _noms(self, *args, **kwargs):
classeur, _onglet, tableau = self._classeur(*args, **kwargs)
formats._resynchroniser_tableaux(classeur)
return [c.name for c in tableau.tableColumns]
def test_le_nom_suit_la_cellule_d_en_tete(self):
self.assertEqual(
self._noms([["acai", "acanthe"], [1, 2]], noms=("vieux", "vieil")),
["acai", "acanthe"],
)
def test_un_en_tete_de_DEUX_lignes_prend_la_derniere(self):
"""La ligne de catégorie se répète souvent d'une colonne à
l'autre : la prendre donnait deux noms identiques."""
self.assertEqual(
self._noms(
[["cat", "cat"], ["acai", "acanthe"], [1, 2]],
hauteur_entete=2,
noms=("vieux", "vieil"),
),
["acai", "acanthe"],
)
def test_un_nom_sans_cellule_est_remplace_positionnellement(self):
"""Il sortait tel quel dans `xl/tables/`, hors d'atteinte du
filet."""
noms = self._noms(
[[None, "acanthe"], [1, 2]], noms=("aboulie", "acai")
)
self.assertEqual(noms, ["colonne_1", "acanthe"])
self.assertNotIn("aboulie", noms)
def test_une_cellule_non_texte_ne_nomme_pas_une_colonne(self):
noms = self._noms([[2024, "acanthe"], [1, 2]], noms=("aboulie", "x"))
self.assertEqual(noms[0], "colonne_1")
def test_deux_en_tetes_IDENTIQUES_se_distinguent(self):
"""Sans quoi le tableau porte deux fois le même nom."""
noms = self._noms([["acai", "acai"], [1, 2]], noms=("v", "w"))
self.assertEqual(len(set(noms)), 2, noms)
self.assertEqual(noms[0], "acai")
def test_le_libelle_de_ligne_de_total_part(self):
"""Il n'est jamais une cellule et survivait quelles que soient
les options."""
classeur, _o, tableau = self._classeur([["acai", "acanthe"], [1, 2]])
tableau.tableColumns[0].totalsRowLabel = "aboulie"
formats._resynchroniser_tableaux(classeur)
self.assertIsNone(tableau.tableColumns[0].totalsRowLabel)
class TestLitterauxDeFormule(unittest.TestCase):
"""Ce qu'une formule PRÉSERVE, et que le filet doit donc excuser.
Les guillemets ne suffisent pas : une référence structurée de tableau
porte le nom de colonne entre CROCHETS et sans guillemets. Toutes les
cellules d'une colonne calculée partagent le même texte, si bien que
le bloc toléré ne l'excuse qu'une fois là où la copie le porte deux
fois — dans la feuille et dans `xl/tables/`.
"""
def test_une_reference_structuree_rend_le_nom_de_colonne(self):
rendu = set(
formats._litteraux_de_formule("=T1[[#This Row],[Montant]]*S$5")
)
self.assertIn("Montant", rendu)
self.assertIn("#This Row", rendu)
def test_un_littéral_entre_guillemets_reste_rendu(self):
self.assertIn(
"aboulie",
formats._litteraux_de_formule('=IF(A1="aboulie",1,0)'),
)
def test_le_texte_ENTIER_est_rendu_aussi(self):
"""La tolérance d'origine : le filet compte les occurrences dans
le bloc joint, et le texte complet y participe."""
self.assertIn("=A1+1", formats._litteraux_de_formule("=A1+1"))
def test_une_valeur_texte_NUE_ne_tolère_rien(self):
"""La garde sur « = » porte tout le filet : sans elle, la
fonction étant appelée sur chaque cellule, toute valeur texte
serait tolérée et plus rien ne serait refusé."""
for valeur in ("Montant", "aboulie", "", " ", "A1+1"):
with self.subTest(valeur=valeur):
self.assertEqual(
set(formats._litteraux_de_formule(valeur)), set()
)
def test_un_nom_a_espace_finale_garde_son_espace(self):
"""Le nom réel est celui que la formule porte, espaces compris :
le dépouiller ne l'aurait jamais fait correspondre."""
self.assertIn(
"expected 1 ",
formats._litteraux_de_formule("=T[[#This Row],[expected 1 ]]"),
)
def test_l_apostrophe_d_echappement_est_retirée(self):
"""Excel échappe `[`, `]`, `#` et l'apostrophe par une
apostrophe : le nom réel est la forme déséchappée."""
rendu = formats._litteraux_de_formule("=T[[#This Row],[a'#b]]")
self.assertIn("a#b", rendu)
def test_une_formule_matricielle_n_est_pas_une_chaine(self):
"""Un objet à `text` : le tester par `startswith` la faisait
passer inaperçue."""
class Matricielle:
text = "=T[[#This Row],[acanthe]]"
self.assertIn("acanthe", formats._litteraux_de_formule(Matricielle()))
def test_sans_egal_pour_un_texte_que_l_appelant_SAIT_etre_une_formule(
self,
):
"""OOXML omet le « = » dans `calculatedColumnFormula`. Le drapeau
est réservé aux appelants qui lisent une partie de FORMULE : le
passer sur une cellule rouvrirait le trou que la garde ferme."""
self.assertIn(
"acanthe",
formats._litteraux_de_formule(
"T[[#This Row],[acanthe]]", sans_egal=True
),
)
self.assertEqual(
set(formats._litteraux_de_formule("T[[#This Row],[acanthe]]")),
set(),
)
def test_ni_none_ni_un_nombre_ne_lèvent(self):
for valeur in (None, 12345, 3.5, True):
with self.subTest(valeur=valeur):
self.assertEqual(
set(formats._litteraux_de_formule(valeur)), set()
)
class TestValeursGardeesDesFormules(unittest.TestCase):
"""La collecte doit appeler la fonction là où les formules vivent.
Deux endroits : la grille, et la formule PROPRE d'une colonne de
tableau — celle-là vit dans `xl/tables/`, ne passe par aucune cellule,
et son nom de colonne n'était excusé par personne.
"""
@staticmethod
def _classeur(valeurs_de_cellule=(), formule_de_colonne=None):
"""Un bouchon : la fonction ne lit que des attributs, si bien que
le test tourne sous l'interpréteur du CLI, sans openpyxl."""
class Cellule:
def __init__(self, valeur):
self.value = valeur
class Colonne:
def __init__(self, formule):
self.calculatedColumnFormula = formule
self.totalsRowFormula = None
class Tableau:
def __init__(self, formule):
self.tableColumns = [Colonne(formule)]
class Noms(dict):
pass
class Onglet:
title = "T"
defined_names = Noms()
def __init__(self):
self.tables = (
{"T1": Tableau(formule_de_colonne)}
if formule_de_colonne is not None
else {}
)
def iter_rows(self):
yield [Cellule(v) for v in valeurs_de_cellule]
class Classeur:
defined_names = Noms()
def __init__(self, onglet):
self.worksheets = [onglet]
return Classeur(Onglet())
def _gardees(self, **kwargs):
return formats._valeurs_gardees(self._classeur(**kwargs), [], "xlsx")
def test_une_formule_de_CELLULE_est_dépouillée(self):
gardees = self._gardees(
valeurs_de_cellule=("=T1[[#This Row],[Montant]]*2",)
)
self.assertIn("Montant", gardees)
def test_une_formule_de_COLONNE_de_tableau_aussi(self):
"""Elle vit dans `xl/tables/` et ne passe par aucune cellule."""
class Formule:
text = "T1[[#This Row],[acanthe]]*2"
gardees = self._gardees(formule_de_colonne=Formule())
self.assertIn("acanthe", gardees)
def test_une_valeur_texte_de_cellule_n_est_PAS_tolérée(self):
"""Sinon le filet ne refuserait plus rien."""
gardees = self._gardees(valeurs_de_cellule=("aboulie", "acai"))
self.assertNotIn("aboulie", gardees)
self.assertNotIn("acai", gardees)
def test_un_classeur_sans_tableau_ne_lève_pas(self):
self.assertIsInstance(self._gardees(), set)
def _corps(hauteur=8):
"""Un corps de tableau réaliste : trois formes de colonne distinctes."""
return [
["ZK%06d" % (204817 + i), 501 + i, 100.5 + i] for i in range(hauteur)
]
class TestLigneDeChamps(unittest.TestCase):
"""La ligne d'en-tête est MESURÉE, non présumée.
La présumer en ligne 1 est faux dans les deux sens : un rapport dont
A1 porte un titre a son en-tête plus bas, et une feuille sans en-tête
voyait sa première ligne de DONNÉES recopiée en clair.
"""
ENTETE = ["N° facture", "N° magasin", "Montant"]
def test_un_en_tete_en_ligne_1_est_trouve(self):
empan, champs = formats.lignes_entete([self.ENTETE] + _corps())
self.assertEqual(empan, {1})
self.assertEqual(champs, 1)
def test_un_titre_en_A1_repousse_l_en_tete(self):
"""Le cas qui motive la mesure : la ligne 1 n'est pas l'en-tête."""
lignes = [
["Rapport annuel", None, None],
[None, None, None],
self.ENTETE,
] + _corps()
empan, champs = formats.lignes_entete(lignes)
self.assertEqual(champs, 3)
self.assertEqual(empan, {1, 2, 3})
def test_une_feuille_SANS_en_tete_n_en_invente_pas(self):
"""Sa ligne 1 est de la donnée : la garder la recopiait en clair."""
empan, champs = formats.lignes_entete(_corps())
self.assertEqual(empan, set())
self.assertIsNone(champs)
def test_une_ligne_de_categorie_ne_nomme_pas_les_colonnes(self):
"""Elle répète un mot sur plusieurs colonnes : la retenir donnait
deux colonnes de même nom, ce qu'OOXML refuse."""
lignes = [["Bloc", "Bloc", "Bloc"], self.ENTETE] + _corps()
empan, champs = formats.lignes_entete(lignes)
self.assertEqual(champs, 2)
self.assertEqual(empan, {1, 2})
def test_l_empan_s_arrete_a_la_premiere_ligne_de_donnees(self):
"""Ce qui surmonte la ligne de champs sans être des données est de
la mise en page ; une ligne de données borne la remontée."""
lignes = [self.ENTETE] + _corps()
empan, _champs = formats.lignes_entete(lignes)
self.assertEqual(empan, {1})
def test_au_dela_des_lignes_sondees_on_ne_cherche_plus(self):
"""Un en-tête plus bas n'est pas un tableau, c'est une mise en
page — et l'opérateur corrige mieux qu'une mesure."""
bourrage = [[None, None, None]] * formats.LIGNES_SONDEES
empan, champs = formats.lignes_entete(
bourrage + [self.ENTETE] + _corps()
)
self.assertEqual(empan, set())
self.assertIsNone(champs)
def test_une_feuille_vide_ou_d_une_ligne_ne_leve_pas(self):
for lignes in ([], [self.ENTETE], [[]]):
with self.subTest(lignes=lignes):
self.assertEqual(formats.lignes_entete(lignes), (set(), None))
class TestSignauxEnLot(unittest.TestCase):
"""Le lot rend EXACTEMENT ce que le rang par rang rendait.
C'est la propriété qui autorise le raccourci : `corps(rang)` est
`corps(rang + 1)` plus une ligne, donc descendre les rangs en
AJOUTANT une ligne à l'état par colonne remplace vingt balayages de la
feuille par un. Sans ce test, une retouche à l'une des deux voies
ferait dériver les verdicts sans que rien ne le dise.
"""
def _comparer(self, lignes):
lot = formats._signaux_par_rang(lignes, formats.LIGNES_SONDEES)
attendus = {}
for rang in range(1, min(formats.LIGNES_SONDEES, len(lignes)) + 1):
attendus[rang] = (
formats._signaux_entete(lignes, rang),
formats._accord_de_forme(lignes, rang),
)
self.assertEqual(sorted(lot), sorted(attendus))
for rang, (signaux, accord) in attendus.items():
with self.subTest(rang=rang):
self.assertEqual(lot[rang][0], signaux)
if accord is None:
self.assertIsNone(lot[rang][1])
else:
self.assertAlmostEqual(lot[rang][1], accord, places=12)
ENTETE = ["N° facture", "N° magasin", "Montant"]
def test_les_cas_nommes(self):
cas = {
"en-tête en ligne 1": [self.ENTETE] + _corps(),
"titre en A1": [
["Rapport annuel", None, None],
[None, None, None],
self.ENTETE,
]
+ _corps(),
"sans en-tête": _corps(),
"ligne de catégorie": [["Bloc", "Bloc", "Bloc"], self.ENTETE]
+ _corps(),
"feuille courte": [
["Client%d" % rang, "Ville%d" % rang, 100 + rang]
for rang in range(12)
],
"une seule ligne": [self.ENTETE],
"dents de scie": [["a"], ["b", "c", "d"], ["e", "f"], ["g"]],
"lignes vides": [[], [], []],
"vide": [],
}
for nom, lignes in cas.items():
with self.subTest(cas=nom):
self._comparer(lignes)
def test_sur_quatre_cents_grilles_tirees(self):
"""Les cas nommés couvrent ce qu'on a pensé ; le tirage couvre le
reste — colonnes à trous, types mêlés, feuilles d'une ligne."""
rng = random.Random(11)
for _ in range(400):
lignes = []
for _ in range(rng.randint(1, 14)):
ligne = []
for _ in range(rng.randint(1, 6)):
genre = rng.choice("nsSvdb")
if genre == "n":
ligne.append(rng.randint(-50, 5000))
elif genre == "s":
ligne.append(
rng.choice(("Aubel", "Bruant", "Cerdan", "nom"))
)
elif genre == "S":
ligne.append("ZK%05d" % rng.randint(1, 300))
elif genre == "v":
ligne.append(None)
elif genre == "d":
ligne.append("2011-03-%02d" % rng.randint(1, 28))
else:
ligne.append(rng.choice((True, False)))
lignes.append(ligne)
self._comparer(lignes)
def test_la_dominante_de_forme_ne_depend_pas_de_l_ordre(self):
"""`most_common` tranche une égalité par l'ordre d'INSERTION :
deux agrégations du même corps donnaient deux dominantes, donc
deux verdicts."""
import collections
premier = collections.Counter({"a+": 2, "9+": 2})
second = collections.Counter()
second["9+"] = 2
second["a+"] = 2
self.assertEqual(
formats._forme_dominante(premier),
formats._forme_dominante(second),
)
def test_un_compteur_vide_n_a_pas_de_dominante(self):
import collections
self.assertIsNone(formats._forme_dominante(collections.Counter()))
class TestFormeDeValeur(unittest.TestCase):
"""La signature de forme : ce qui sépare un nom de champ d'une donnée
là où le TYPE ne dit rien, les deux étant du texte."""
def test_deux_valeurs_du_meme_moule_ont_une_seule_forme(self):
self.assertEqual(
formats._forme_de_valeur("ZK204817"),
formats._forme_de_valeur("MPQ204818"),
)
def test_les_longueurs_ne_distinguent_pas(self):
"""Les répétitions sont écrasées : « 99999 » et « 999999 » sont
une forme, sinon chaque longueur ferait une forme à part."""
self.assertEqual(
formats._forme_de_valeur(12345),
formats._forme_de_valeur(1234567),
)
def test_un_nom_de_champ_se_distingue_de_ses_donnees(self):
self.assertNotEqual(
formats._forme_de_valeur("N° facture"),
formats._forme_de_valeur("ZK204817"),
)
def test_une_date_et_un_nombre_ne_se_confondent_pas(self):
self.assertNotEqual(
formats._forme_de_valeur("2011-03-09"),
formats._forme_de_valeur(20110309),
)
def test_none_rend_la_forme_vide(self):
self.assertEqual(formats._forme_de_valeur(None), "")
def test_l_accord_est_HAUT_sur_une_ligne_de_donnees(self):
"""C'est le sens du signal : ressembler à ses données, c'en être."""
lignes = _corps()
self.assertGreaterEqual(formats._accord_de_forme(lignes, 1), 0.9)
def test_l_accord_est_BAS_sur_une_ligne_de_champs(self):
lignes = [["N° facture", "N° magasin", "Montant"]] + _corps()
self.assertLessEqual(
formats._accord_de_forme(lignes, 1),
formats.ACCORD_DE_DONNEE,
)
def test_sans_corps_sous_la_ligne_il_n_y_a_pas_de_verdict(self):
"""Rien à comparer n'est pas « c'est un en-tête »."""
self.assertIsNone(formats._accord_de_forme([["a", "b"]], 1))
self.assertIsNone(formats._signaux_entete([["a", "b"]], 1))
class TestSignalHorsColonne(unittest.TestCase):
"""`hors_colonne` n'a de pouvoir que sur une colonne à VOCABULAIRE.
La régression qu'il ferme : sur une grille tout-texte aux valeurs
toutes distinctes, une ligne de DONNÉES est absente du reste de sa
colonne exactement autant qu'un libellé l'est. Rapporté à la largeur,
le signal vaut 1,0 pour n'importe quelle ligne, et une première ligne
de données ponctuée autrement que son corps passe pour un en-tête —
donc sort en clair dans la copie.
"""
# Trois colonnes tout-texte, toutes valeurs distinctes, sans en-tête.
# La ligne 1 est ponctuée au tiret là où le corps l'est à l'espace :
# c'est ce qui met son accord de forme à zéro.
SANS_ENTETE = [["Aubel-Nord", "Piece-A1", "Lot-B1"]] + [
["Bureau %d" % i, "Piece %d" % i, "Lot %d" % i] for i in range(1, 9)
]
# Les mêmes colonnes, mais à vocabulaire : des valeurs s'y répètent.
ENTETE = ["N° ville", "Statut du dossier", "Categorie"]
CORPS = [
["Aubel", "ouvert", "papeterie"],
["Bruant", "ferme", "papeterie"],
["Aubel", "ouvert", "outillage"],
["Bruant", "ferme", "outillage"],
["Aubel", "ouvert", "papeterie"],
["Bruant", "ferme", "outillage"],
]
def test_sur_une_colonne_tout_distincte_le_signal_se_TAIT(self):
"""0,0 dit « ce signal ne dit rien ici », non « c'est un en-tête ».
Le dénominateur compte les colonnes où le signal porte quelque
chose. Aucune n'en a : il vaut zéro et laisse `accord` trancher
seul, ce qui est le comportement voulu.
"""
signaux = formats._signaux_entete(self.SANS_ENTETE, 1)
self.assertEqual(signaux["hors_colonne"], 0.0)
self.assertEqual(signaux["compares"], 3)
def test_une_ligne_de_donnees_ainsi_ponctuee_n_est_PAS_un_en_tete(self):
"""Le bout du fil : sans le dénominateur propre au signal,
l'empan garde cette ligne et la copie la sort en clair."""
self.assertEqual(
formats.lignes_entete(self.SANS_ENTETE), (set(), None)
)
def test_sur_une_colonne_a_vocabulaire_le_signal_PARLE(self):
"""Et il porte seul : le contraste est muet sur du tout-texte."""
lignes = [self.ENTETE] + self.CORPS
signaux = formats._signaux_entete(lignes, 1)
self.assertEqual(signaux["contraste"], 0.0)
self.assertGreaterEqual(
signaux["hors_colonne"], formats.HORS_COLONNE_MINIMAL
)
self.assertEqual(formats.lignes_entete(lignes), ({1}, 1))
def test_la_meme_table_sans_son_en_tete_n_en_invente_pas(self):
signaux = formats._signaux_entete(self.CORPS, 1)
self.assertEqual(signaux["hors_colonne"], 0.0)
self.assertEqual(formats.lignes_entete(self.CORPS), (set(), None))
class TestCeQuiEstDeLaDonnee(unittest.TestCase):
"""`_est_de_la_donnee` borne la remontée de l'empan : deux conditions,
et les deux sont nécessaires."""
ENTETE = ["N° ville", "N° magasin", "Montant"]
CORPS = [["Aubel", 501 + i, 100.5 + i] for i in range(8)]
def test_la_forme_seule_ne_suffit_pas(self):
"""Un titre seul en A1 partage la forme de la colonne de mots
qu'il surmonte ; sans la condition de remplissage, l'extension
s'arrêtait dessus et le mettait en PORTÉE."""
lignes = [["Rapport", None, None], self.ENTETE] + self.CORPS
self.assertEqual(formats._accord_de_forme(lignes, 1), 1.0)
self.assertLess(
formats._signaux_entete(lignes, 1)["rempli"],
formats.REMPLISSAGE_MINIMAL,
)
self.assertFalse(formats._est_de_la_donnee(lignes, 1))
self.assertEqual(formats.lignes_entete(lignes), ({1, 2}, 2))
def test_le_remplissage_seul_ne_suffit_pas(self):
"""Une ligne de catégorie couvre la largeur sans être des
données : sa forme ne s'accorde pas à celle du corps."""
lignes = [["Bloc", "Bloc", "Bloc"], self.ENTETE] + self.CORPS
signaux = formats._signaux_entete(lignes, 1)
self.assertGreaterEqual(signaux["rempli"], formats.REMPLISSAGE_MINIMAL)
self.assertLess(formats._accord_de_forme(lignes, 1), 0.5)
self.assertFalse(formats._est_de_la_donnee(lignes, 1))
self.assertEqual(formats.lignes_entete(lignes), ({1, 2}, 2))
def test_une_donnee_au_dessus_annule_le_candidat(self):
"""La remontée bornée par une DONNÉE place le candidat au milieu
d'elle : un en-tête ne se trouve pas sous des enregistrements.
Sur une feuille courte, le corps sous le rang sondé fond jusqu'à
trois lignes et la forme dominante y bascule — la ligne se
distingue alors de ce qui reste sans rien nommer, et sortait en
clair au milieu des données. Le verdict est donc « pas d'en-tête »,
ce qui ANONYMISE, et l'écran corrige.
"""
lignes = [["Bruant", 599, 199.5], self.ENTETE] + self.CORPS
self.assertTrue(formats._est_de_la_donnee(lignes, 1))
self.assertEqual(formats.lignes_entete(lignes), (set(), None))
def test_de_la_MISE_EN_PAGE_au_dessus_ne_l_annule_pas(self):
"""L'empan atteint la ligne 1 en la traversant : c'est la
différence que `_est_de_la_donnee` sert à faire."""
lignes = [["Rapport", None, None], self.ENTETE] + self.CORPS
self.assertFalse(formats._est_de_la_donnee(lignes, 1))
self.assertEqual(formats.lignes_entete(lignes), ({1, 2}, 2))
def test_sans_corps_sous_la_ligne_ce_n_est_pas_de_la_donnee(self):
"""Rien à comparer n'est pas un verdict : le refus fait entrer la
ligne dans l'empan, du côté qui ne fait pas sortir de donnée."""
self.assertFalse(formats._est_de_la_donnee([self.ENTETE], 1))
class TestEtiquettesDeLaLigneDeChamps(unittest.TestCase):
"""`Feuille.etiquettes` suit la ligne de champs, non la ligne 1."""
def test_les_etiquettes_viennent_de_la_ligne_de_champs(self):
feuille = formats.Feuille(
"F", [["titre", None], ["nom", "montant"], ["x", 1]]
)
feuille.ligne_champs = 2
self.assertEqual(feuille.etiquettes, ["nom", "montant"])
def test_sans_ligne_de_champs_il_n_y_a_pas_d_etiquette(self):
"""Le plancher répond alors par l'INDEX : les prendre sur une
ligne de données faisait planchéier au hasard."""
feuille = formats.Feuille("F", [["x", 1], ["y", 2]])
feuille.ligne_champs = None
self.assertEqual(feuille.etiquettes, [])
def test_la_ligne_1_reste_le_defaut(self):
"""Les lecteurs qui FABRIQUENT leur ligne 1 la connaissent
d'avance : Access, JSON et XML n'ont rien à mesurer."""
feuille = formats.Feuille("F", [["cle", "valeur"], ["a", 1]])
self.assertEqual(feuille.ligne_champs, 1)
self.assertEqual(feuille.etiquettes, ["cle", "valeur"])
def test_une_ligne_de_champs_hors_des_lignes_ne_leve_pas(self):
feuille = formats.Feuille("F", [["a"]])
feuille.ligne_champs = 9
self.assertEqual(feuille.etiquettes, [])
class TestUniciteDesClesI18n(unittest.TestCase):
"""Une clé dupliquée dans un littéral de dict Python écrase la
précédente, sans erreur ni avertissement.
Les deux entrées peuvent être identiques aujourd'hui : le jour où l'on
corrige l'une, la correction se perd en silence, et aucun test
d'intégrité existant ne regarde les DOUBLONS — ils lisent le dict
déjà construit, où le doublon a déjà disparu. D'où la lecture par AST
du fichier SOURCE.
"""
@staticmethod
def _cles_du_source():
import ast
chemin = os.path.join(
os.path.dirname(__file__),
"..",
"script",
"todo",
"todo_i18n.py",
)
with open(chemin, encoding="utf-8") as flux:
arbre = ast.parse(flux.read())
for noeud in ast.walk(arbre):
if not isinstance(noeud, ast.Assign):
continue
if getattr(noeud.targets[0], "id", "") != "TRANSLATIONS":
continue
return [
cle.value
for cle in noeud.value.keys
if isinstance(cle, ast.Constant) and isinstance(cle.value, str)
]
return []
def test_le_dict_source_a_bien_ete_lu(self):
"""Si la forme du fichier change, ce test doit tomber ici plutôt
que de déclarer « aucun doublon » sur une liste vide."""
self.assertGreater(len(self._cles_du_source()), 1000)
def test_aucune_cle_n_est_declaree_deux_fois(self):
import collections
compte = collections.Counter(self._cles_du_source())
doubles = sorted(k for k, n in compte.items() if n > 1)
self.assertEqual(doubles, [], "clés déclarées deux fois")
class TestPorteeDeLEmpanDEnTete(unittest.TestCase):
"""La portée obéit à l'empan mesuré — l'endroit où une erreur laisse
sortir de la donnée.
Le défaut de l'ABSENCE porte tout : un appelant qui ne mesure pas
mettrait la ligne de champs en portée, ses libellés remplacés et la
copie illisible. Une clé PRÉSENTE et vide veut dire « pas d'en-tête ».
"""
BASE = {"entetes": False, "colonnes_intactes": set()}
def test_la_cle_ABSENTE_garde_la_ligne_1(self):
"""Le comportement d'avant la mesure : le gabarit d'options des
tests de portée ne porte que quelques clés."""
self.assertFalse(noyau.cellule_en_portee("F", 1, 1, dict(self.BASE)))
self.assertTrue(noyau.cellule_en_portee("F", 2, 1, dict(self.BASE)))
def test_la_cle_PRESENTE_ET_VIDE_met_la_ligne_1_en_portee(self):
"""C'est le correctif de la fuite : une feuille sans en-tête voit
sa première ligne de DONNÉES anonymisée."""
options = dict(self.BASE, lignes_entete=set())
self.assertTrue(noyau.cellule_en_portee("F", 1, 1, options))
def test_un_empan_de_deux_lignes_les_garde_toutes_les_deux(self):
options = dict(self.BASE, lignes_entete={("F", 1), ("F", 2)})
self.assertFalse(noyau.cellule_en_portee("F", 1, 1, options))
self.assertFalse(noyau.cellule_en_portee("F", 2, 1, options))
self.assertTrue(noyau.cellule_en_portee("F", 3, 1, options))
def test_l_empan_est_PAR_FEUILLE(self):
"""Garder la ligne 3 d'une feuille ne garde pas la ligne 3 des
autres : l'empan est mesuré feuille par feuille."""
options = dict(self.BASE, lignes_entete={("A", 3)})
self.assertFalse(noyau.cellule_en_portee("A", 3, 1, options))
self.assertTrue(noyau.cellule_en_portee("B", 3, 1, options))
def test_repondre_oui_a_l_en_tete_met_tout_en_portee(self):
"""L'option existante garde sa parole : l'empan ne la contredit
pas."""
options = dict(
self.BASE, entetes=True, lignes_entete={("F", 1), ("F", 2)}
)
self.assertTrue(noyau.cellule_en_portee("F", 1, 1, options))
def test_preparer_pose_TOUJOURS_la_cle(self):
"""Sinon l'absence — qui veut dire « ligne 1 » — s'appliquerait à
une feuille dont l'en-tête est ailleurs."""
base = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, base, True)
source = os.path.join(base, "s.csv")
with open(source, "w", encoding="utf-8") as flux:
flux.write("etiquette,montant,date\n")
flux.write("aboulie,12,2019-01-02\n")
flux.write("acai,13,2019-01-03\n")
_f, _feuilles, _c, _r, options = formats._preparer(source, {})
self.assertIn("lignes_entete", options)
# Le nom vient du module : un csv n'a pas d'onglet, et coder son
# nom en dur dans le test ferait tomber au premier renommage.
self.assertEqual(
options["lignes_entete"],
{(formats.NOM_FEUILLE_NEUTRE, 1)},
)
class TestMesurerEntetes(unittest.TestCase):
"""Quatre sources, dans un ordre qui n'est pas négociable."""
ENTETE = ["etiquette", "montant", "date", "code"]
CORPS = [
["aboulie", 1200, "2019-01-02", "A1"],
["acai", 830, "2019-01-03", "B2"],
["adobe", 940, "2019-01-04", "C3"],
]
@classmethod
def _feuille(cls, nom="F", lignes=None):
"""Types MÊLÉS : le régime où la mesure tranche. Une grille
étroite et tout-alphabétique ne se décide pas."""
return formats.Feuille(
nom, lignes if lignes is not None else [cls.ENTETE] + cls.CORPS
)
def test_la_correction_de_l_operateur_passe_avant_tout(self):
feuille = self._feuille()
feuille.entete_declaree = {1}
formats.mesurer_entetes([feuille], "xlsx", {"F": [2, 3]})
self.assertEqual(feuille.lignes_entete, {2, 3})
self.assertEqual(feuille.ligne_champs, 3)
def test_la_DECLARATION_du_fichier_passe_avant_la_mesure(self):
"""`_resynchroniser_tableaux` lit déjà `headerRowCount` pour
nommer les colonnes d'un tableau : deux notions d'en-tête qui se
contredisent feraient renommer depuis une ligne anonymisée."""
feuille = self._feuille(lignes=[["cat"] * 4, self.ENTETE] + self.CORPS)
feuille.entete_declaree = {1, 2}
formats.mesurer_entetes([feuille], "xlsx")
self.assertEqual(feuille.lignes_entete, {1, 2})
self.assertEqual(feuille.ligne_champs, 2)
def test_un_lecteur_qui_FABRIQUE_sa_ligne_1_ne_mesure_rien(self):
for format_lu in formats.FORMATS_ENTETE_FABRIQUEE:
with self.subTest(format_lu=format_lu):
# Une grille SANS en-tête : la mesure rendrait
# `set()`, et le défaut du lecteur doit l'emporter.
feuille = self._feuille(lignes=self.CORPS)
formats.mesurer_entetes([feuille], format_lu)
self.assertEqual(feuille.lignes_entete, {1})
def test_une_correction_vide_veut_dire_PAS_d_en_tete(self):
feuille = self._feuille()
formats.mesurer_entetes([feuille], "csv", {"F": []})
self.assertEqual(feuille.lignes_entete, set())
self.assertIsNone(feuille.ligne_champs)
def test_une_correction_ne_touche_pas_les_autres_feuilles(self):
une, deux = self._feuille("A"), self._feuille("B")
formats.mesurer_entetes([une, deux], "csv", {"A": []})
self.assertEqual(une.lignes_entete, set())
self.assertEqual(deux.ligne_champs, 1)
class TestCorpsSousLEmpan(unittest.TestCase):
"""Les lignes de DONNÉES : toutes celles hors de l'empan d'en-tête."""
def test_une_donnee_AU_DESSUS_de_la_ligne_de_champs_est_gardee(self):
"""Elle est hors de l'empan exprès, et partir de la dernière ligne
d'en-tête la faisait disparaître d'une conversion json ou xml —
anonymisée, comptée comme remplacée, puis absente du fichier."""
feuille = formats.Feuille(
"F", [["z", 9], ["a", "b"], ["x", 1], ["y", 2]]
)
feuille.lignes_entete = {2}
self.assertEqual(
formats.corps(feuille), [["z", 9], ["x", 1], ["y", 2]]
)
def test_un_empan_NON_contigu_ne_reprend_pas_ce_qu_il_saute(self):
"""L'opérateur n'est pas tenu de cocher des lignes voisines."""
feuille = formats.Feuille(
"F", [["a", "b"], ["x", 1], ["c", "d"], ["y", 2]]
)
feuille.lignes_entete = {1, 3}
self.assertEqual(formats.corps(feuille), [["x", 1], ["y", 2]])
def test_le_corps_saute_tout_l_empan(self):
feuille = formats.Feuille(
"F", [["cat", "cat"], ["a", "b"], ["x", 1], ["y", 2]]
)
feuille.lignes_entete = {1, 2}
self.assertEqual(formats.corps(feuille), [["x", 1], ["y", 2]])
def test_sans_en_tete_le_corps_est_TOUTE_la_grille(self):
"""Sinon la première ligne de données devient les noms de clé :
en clair dans la copie, et perdue comme donnée."""
feuille = formats.Feuille("F", [["x", 1], ["y", 2]])
feuille.lignes_entete = set()
self.assertEqual(formats.corps(feuille), [["x", 1], ["y", 2]])
def test_des_noms_neutres_remplacent_l_en_tete_absent(self):
feuille = formats.Feuille("F", [["x", 1, 2], ["y", 3, 4]])
self.assertEqual(
formats._noms_neutres(feuille),
["colonne_1", "colonne_2", "colonne_3"],
)
class TestValeurDExemple(unittest.TestCase):
"""Une valeur montrable : toujours une `str`, toujours bornée.
C'est la seule colonne du rapport qui porte de la donnée du client à
l'écran, et la charge utile traverse un `json.dump` en
`allow_nan=False` : ce qui échoue là échoue APRÈS tout le travail du
moteur, et l'écran n'affiche alors qu'une trace.
"""
def test_tout_rendu_est_une_chaine(self):
import datetime
for valeur in (
None,
0,
0.0,
False,
True,
1200,
"aboulie",
datetime.date(2019, 1, 2),
b"\x00\xff",
):
with self.subTest(valeur=valeur):
self.assertIsInstance(formats.valeur_d_exemple(valeur), str)
def test_le_zero_et_le_faux_ne_s_effacent_pas(self):
"""`or ""` les écrasait : une colonne de montants nuls montrait
des exemples vides."""
self.assertEqual(formats.valeur_d_exemple(0), "0")
self.assertEqual(formats.valeur_d_exemple(0.0), "0.0")
self.assertEqual(formats.valeur_d_exemple(False), "False")
def test_des_octets_ne_sortent_pas_en_clair(self):
"""`valeur_hors_tableur` tomberait sur son `str()` final et
rendrait le `repr` d'un `bytes` — du binaire lisible."""
rendu = formats.valeur_d_exemple(b"\x00\xff\x00")
self.assertNotIn("\\x", rendu)
self.assertIn("3", rendu)
def test_un_flottant_non_fini_ne_casse_pas_la_serialisation(self):
"""`allow_nan=False` lèverait, et le moteur aurait tout fait."""
for valeur in (float("nan"), float("inf"), -float("inf")):
with self.subTest(valeur=valeur):
self.assertEqual(formats.valeur_d_exemple(valeur), "")
def test_une_valeur_longue_est_bornee_et_le_dit(self):
rendu = formats.valeur_d_exemple("x" * 5000)
self.assertEqual(len(rendu), formats.EXEMPLE_LONGUEUR)
self.assertTrue(rendu.endswith("…"))
class TestRapportDeLEnTete(unittest.TestCase):
"""Ce que le rapport DIT de la mesure, pour qu'une invention se lise
avant d'être consentie."""
def setUp(self):
self.base = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.base, True)
def _rapport(self, contenu, nom="s.csv"):
chemin = os.path.join(self.base, nom)
with open(chemin, "w", encoding="utf-8") as flux:
flux.write(contenu)
return formats.report(chemin)
AVEC = (
"etiquette,montant,date,code\n"
"aboulie,1200,2019-01-02,A1\n"
"acai,830,2019-01-03,B2\n"
"adobe,940,2019-01-04,C3\n"
)
SANS = "ZK204817,501,100.5\n" "ZK204818,502,101.5\n" "ZK204819,503,102.5\n"
def test_l_empan_est_une_LISTE_serialisable(self):
"""Un set ne passe pas `json.dump`, et ce rapport traverse un
sous-processus."""
feuille = self._rapport(self.AVEC)["feuilles"][0]
self.assertEqual(feuille["lignes_entete"], [1])
self.assertEqual(feuille["ligne_champs"], 1)
json.dumps(feuille, allow_nan=False)
def test_sans_en_tete_le_rapport_le_dit(self):
feuille = self._rapport(self.SANS)["feuilles"][0]
self.assertEqual(feuille["lignes_entete"], [])
self.assertIsNone(feuille["ligne_champs"])
self.assertIsNone(feuille["entete_mesure"])
def test_les_cinq_mesures_sont_affichables(self):
"""L'opérateur voit POURQUOI la mesure a tranché avant de la
contredire."""
mesure = self._rapport(self.AVEC)["feuilles"][0]["entete_mesure"]
self.assertEqual(
sorted(mesure),
["accord", "contraste", "distinct", "hors_colonne", "rempli"],
)
for cle, valeur in mesure.items():
with self.subTest(cle=cle):
self.assertIsInstance(valeur, float)
self.assertLessEqual(valeur, 1.0)
def test_chaque_colonne_montre_des_exemples(self):
"""CE qui distingue deux colonnes sans libellé : ni le type, ni le
compte, ni les bornes n'y suffisent."""
colonnes = self._rapport(self.AVEC)["feuilles"][0]["colonnes"]
for colonne in colonnes:
with self.subTest(colonne=colonne["index"]):
self.assertTrue(colonne["exemples"])
self.assertLessEqual(
len(colonne["exemples"]), formats.EXEMPLES_PAR_COLONNE
)
def test_les_exemples_sont_des_valeurs_DISTINCTES(self):
"""Trois fois la même ne montre rien de la colonne."""
rapport = self._rapport(
"etiquette,constante,date\n"
"aboulie,7,2019-01-02\n"
"acai,7,2019-01-03\n"
"adobe,7,2019-01-04\n"
)
par_index = {
c["index"]: c["exemples"]
for c in rapport["feuilles"][0]["colonnes"]
}
self.assertEqual(par_index[2], ["7"])
self.assertEqual(len(par_index[1]), 3)
def test_l_en_tete_de_la_ligne_de_champs_n_est_pas_un_exemple(self):
"""Les statistiques sautent l'empan : le libellé n'est pas une
valeur de sa colonne."""
colonnes = self._rapport(self.AVEC)["feuilles"][0]["colonnes"]
self.assertNotIn("etiquette", colonnes[0]["exemples"])
class TestColonneRepondue(unittest.TestCase):
"""La règle des colonnes laissées intactes, en UN endroit.
Elle vivait en trois copies — la portée, les colonnes écartées, les
colonnes saturées — donc trois occasions de divergence. Et l'ensemble
était GLOBAL : répondre « 3 » gelait la colonne 3 des dix feuilles
d'un classeur, si bien qu'un écran laissant cocher la colonne 3 de la
septième feuille aurait tenu une promesse fausse.
"""
def test_l_ensemble_plat_reste_global(self):
"""La question textuelle répond comme avant : elle ne sait pas de
quelle feuille elle parle."""
options = {"colonnes_intactes": {"3"}}
self.assertTrue(noyau.colonne_repondue("A", 3, None, options))
self.assertTrue(noyau.colonne_repondue("B", 3, None, options))
def test_l_ensemble_par_feuille_ne_vaut_QUE_pour_elle(self):
options = {"colonnes_intactes_par_feuille": {"A": {"ref"}}}
self.assertTrue(noyau.colonne_repondue("A", 5, "ref", options))
self.assertFalse(noyau.colonne_repondue("B", 5, "ref", options))
def test_l_index_ne_repond_que_sans_etiquette(self):
"""Sinon « 1 » désigne à la fois la colonne étiquetée « 1 » et la
première colonne, et une réponse en épargne deux."""
options = {"colonnes_intactes": {"3"}}
self.assertFalse(noyau.colonne_repondue("A", 3, "nom", options))
self.assertTrue(noyau.colonne_repondue("A", 3, None, options))
self.assertTrue(noyau.colonne_repondue("A", 3, " ", options))
def test_les_deux_ensembles_se_cumulent(self):
options = {
"colonnes_intactes": {"nom"},
"colonnes_intactes_par_feuille": {"A": {"ref"}},
}
self.assertTrue(noyau.colonne_repondue("A", 1, "nom", options))
self.assertTrue(noyau.colonne_repondue("A", 2, "ref", options))
self.assertTrue(noyau.colonne_repondue("B", 1, "nom", options))
self.assertFalse(noyau.colonne_repondue("B", 2, "ref", options))
def test_sans_aucune_reponse_rien_n_est_gele(self):
for options in (
{},
{"colonnes_intactes": set()},
{"colonnes_intactes_par_feuille": {}},
):
with self.subTest(options=options):
self.assertFalse(
noyau.colonne_repondue("A", 1, "nom", options)
)
def test_la_portee_reprend_la_meme_regle(self):
"""Le test qui lie les deux : une divergence entre la portée et
cette règle laisserait sortir une colonne annoncée gelée."""
options = {
"entetes": False,
"lignes_entete": {("A", 1)},
"colonnes_intactes_par_feuille": {"A": {"ref"}},
"etiquettes": {("A", 2): "ref"},
}
self.assertFalse(noyau.cellule_en_portee("A", 2, 2, options))
self.assertTrue(noyau.cellule_en_portee("B", 2, 2, options))
def test_preparer_normalise_le_par_feuille(self):
"""Un dict {nom: [str]} traverse le sous-processus ; l'espace
autour d'une réponse tapée à la main ne doit pas la manquer."""
base = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, base, True)
source = os.path.join(base, "s.csv")
with open(source, "w", encoding="utf-8") as flux:
flux.write("etiquette,montant,date\n")
flux.write("aboulie,12,2019-01-02\n")
flux.write("acai,13,2019-01-03\n")
_f, _fe, _c, _r, options = formats._preparer(
source,
{
"colonnes_intactes_par_feuille": {
formats.NOM_FEUILLE_NEUTRE: [" montant ", ""]
}
},
)
self.assertEqual(
options["colonnes_intactes_par_feuille"],
{formats.NOM_FEUILLE_NEUTRE: {"montant"}},
)
class TestMenuEcranDePerimetre(unittest.TestCase):
"""L'ouverture de l'écran, et ses TROIS issues.
La spec porte le périmètre, `{}` demande les invites, `None`
annule. Confondre les deux dernières supprimerait le repli textuel en
silence.
"""
RAPPORT = {"feuilles": [{"nom": "F", "colonnes": []}]}
def setUp(self):
self.menu = _MenuBouchon()
self.ecran = io.StringIO()
vrai_out = sys.stdout
sys.stdout = self.ecran
self.addCleanup(setattr, sys, "stdout", vrai_out)
vrai_input = builtins.input
self.addCleanup(setattr, builtins, "input", vrai_input)
def _repondre(self, reponse):
builtins.input = lambda invite="": reponse
def test_refuser_l_ecran_rend_un_dict_VIDE(self):
"""Et non None : l'appelant enchaîne sur les invites."""
self._repondre("n")
self.assertEqual(self.menu._transform_ecran(self.RAPPORT), {})
def test_zero_annule_tout(self):
self._repondre("0")
self.assertIsNone(self.menu._transform_ecran(self.RAPPORT))
def test_un_rapport_sans_feuille_ne_pose_pas_la_question(self):
def refuse(invite=""):
raise AssertionError("rien à montrer, rien à demander")
builtins.input = refuse
self.assertEqual(self.menu._transform_ecran({"feuilles": []}), {})
def _bouchonner(self, ecran, contexte=None):
"""Remplacer la fonction SUR le vrai module.
Injecter un faux module dans `sys.modules` n'a aucun effet dès
que le vrai a été importé : `from script.todo import
transform_form` lit l'attribut du PAQUET, déjà posé. Le test
lançait alors le VRAI écran, qui attend un terminal — et la suite
se bloquait dès qu'un autre fichier avait importé le module.
"""
from script.todo import transform_form
for nom, valeur in (
("run_transform_form", ecran),
(
"contexte_depuis_rapport",
contexte or (lambda *args: {}),
),
):
vrai = getattr(transform_form, nom)
setattr(transform_form, nom, valeur)
self.addCleanup(setattr, transform_form, nom, vrai)
def test_le_defaut_est_OUI(self):
"""L'écran est la réponse aux deux questions qu'une invite ne sait
pas poser : le proposer par défaut est le sens de l'entrée."""
appels = []
self._repondre("")
def faux_ecran(ctx):
appels.append(ctx)
return {"colonnes_intactes_par_feuille": {}}
self._bouchonner(faux_ecran, lambda *args: {"vu": True})
rendu = self.menu._transform_ecran(self.RAPPORT)
self.assertEqual(appels, [{"vu": True}])
self.assertEqual(rendu, {"colonnes_intactes_par_feuille": {}})
def test_un_ecran_qui_leve_ne_perd_pas_le_travail(self):
"""Sans terminal, il ne peut pas s'ouvrir : les invites savent
tout demander, et emporter le travail serait pire."""
self._repondre("o")
def tombe(ctx):
raise RuntimeError("pas de terminal")
self._bouchonner(tombe)
self.assertEqual(self.menu._transform_ecran(self.RAPPORT), {})
self.assertIn("pas de terminal", self.ecran.getvalue())
class TestEmpanDeclareEtendu(unittest.TestCase):
"""Un tableau OOXML déclare SA ligne de champs, pas la mise en page.
`headerRowCount` ignore ce qui est posé au-dessus du tableau. Un titre
de rapport en A1 restait hors de l'empan, les statistiques de colonne
le comptaient comme une valeur, une colonne de relation cessait d'en
avoir la FORME, le plancher lâchait — et les identifiants étaient
permutés dans la copie, toutes ses relations pointant ailleurs, sans
un mot.
"""
CORPS = [[41 + rang, 100 * (rang + 1)] for rang in range(4)]
CHAMPS = ["partner_id", "montant"]
def _feuille(self, lignes, declaree):
feuille = formats.Feuille("Ventes", lignes)
feuille.entete_declaree = declaree
formats.mesurer_entetes([feuille], "xlsx")
return feuille
def test_le_titre_au_dessus_rejoint_l_empan(self):
lignes = [
["Rapport mensuel", None],
["Periode : janvier", None],
self.CHAMPS,
] + self.CORPS
feuille = self._feuille(lignes, {3})
self.assertEqual(feuille.lignes_entete, {1, 2, 3})
# La ligne de CHAMPS ne bouge pas : le tableau la déclare.
self.assertEqual(feuille.ligne_champs, 3)
def test_les_statistiques_ne_comptent_plus_le_titre(self):
"""C'est par là que la corruption passait."""
lignes = [
["Rapport mensuel", None],
["Periode : janvier", None],
self.CHAMPS,
] + self.CORPS
colonne = formats._stats_colonnes(self._feuille(lignes, {3}))[0]
self.assertEqual(colonne["etiquette"], "partner_id")
self.assertEqual(colonne["remplies"], 4)
self.assertTrue(colonne["forme_relation"])
self.assertTrue(colonne["plancher"])
def test_un_tableau_en_A1_est_inchange(self):
feuille = self._feuille([self.CHAMPS] + self.CORPS, {1})
self.assertEqual(feuille.lignes_entete, {1})
def test_une_DONNEE_au_dessus_borne_la_remontee_sans_l_annuler(self):
"""Le tableau déclare sa ligne de champs : une donnée au-dessus
ne remet pas ce fait en cause, contrairement à la mesure, qui n'a
rien qui le lui dise."""
lignes = [[45, 500], self.CHAMPS] + self.CORPS
feuille = self._feuille(lignes, {2})
self.assertEqual(feuille.lignes_entete, {2})
def test_un_empan_declare_de_deux_lignes_est_inchange(self):
lignes = [["Bloc", "Bloc"], self.CHAMPS] + self.CORPS
feuille = self._feuille(lignes, {1, 2})
self.assertEqual(feuille.lignes_entete, {1, 2})
class TestRapportResynchronise(unittest.TestCase):
"""Le rapport suit l'empan DÉSIGNÉ, non celui qui a été mesuré.
`report` mesure avant que l'opérateur réponde, et tout ce qui suit
travaille sur sa réponse. Les deux se lisaient l'un pour l'autre : les
étiquettes, les bornes, les formes et le plancher dérivent du rapport,
la portée de la réponse.
"""
def setUp(self):
self.base = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.base, True)
self.source = os.path.join(self.base, "s.csv")
with open(self.source, "w", encoding="utf-8") as flux:
flux.write("etiquette,montant,date,code\n")
flux.write("aboulie,1200,2019-01-02,A1\n")
flux.write("acai,830,2019-01-03,B2\n")
flux.write("adobe,940,2019-01-04,C3\n")
flux.write("acanthe,910,2019-01-05,D4\n")
def _prepare(self, **options):
return formats._preparer(
self.source,
dict(
{
"nombres": True,
"texte": True,
"graine": 7,
"feuilles": [],
"colonnes_intactes": [],
},
**options,
),
)
def test_la_mesure_elit_la_ligne_1(self):
"""Le point de départ, sans quoi le test suivant ne prouve rien."""
_fmt, _f, _c, rapport, _o = self._prepare()
feuille = rapport["feuilles"][0]
self.assertEqual(feuille["lignes_entete"], [1])
self.assertEqual(
[c["etiquette"] for c in feuille["colonnes"]],
["etiquette", "montant", "date", "code"],
)
def test_une_correction_refait_les_etiquettes_du_rapport(self):
"""Sinon l'aperçu annonce épargnée une colonne que la passe
anonymise : il la reconnaît par l'étiquette de la ligne mesurée,
la portée par celle de la ligne désignée."""
_fmt, _f, _c, rapport, options = self._prepare(
entetes_par_feuille={formats.NOM_FEUILLE_NEUTRE: [1, 2]}
)
feuille = rapport["feuilles"][0]
self.assertEqual(feuille["lignes_entete"], [1, 2])
self.assertEqual(feuille["ligne_champs"], 2)
# La ligne de champs est la 2 : ses valeurs nomment les colonnes.
etiquettes = [c["etiquette"] for c in feuille["colonnes"]]
self.assertEqual(etiquettes, ["aboulie", "1200", "2019-01-02", "A1"])
# Et le rapport et les options s'accordent, puisque les secondes
# en dérivent.
self.assertEqual(
options["etiquettes"][(formats.NOM_FEUILLE_NEUTRE, 1)], "aboulie"
)
def test_les_bornes_suivent_le_corps_designe(self):
"""Elles dérivent du rapport : une ligne de plus dans l'en-tête,
c'est une valeur de moins dans les bornes."""
_fmt, _f, _c, _r, mesure = self._prepare()
_fmt, _f, _c, _r, corrige = self._prepare(
entetes_par_feuille={formats.NOM_FEUILLE_NEUTRE: [1, 2]}
)
cle = (formats.NOM_FEUILLE_NEUTRE, 2)
self.assertEqual(mesure["bornes"][cle][:2], (830, 1200))
self.assertEqual(corrige["bornes"][cle][:2], (830, 940))
def test_sans_correction_le_rapport_n_est_pas_refait(self):
"""Refaire les statistiques coûte un balayage par feuille : une
feuille dont l'empan n'a pas bougé n'y passe pas."""
appels = []
vrai = formats._stats_colonnes
formats._stats_colonnes = lambda f: appels.append(f.nom) or vrai(f)
self.addCleanup(setattr, formats, "_stats_colonnes", vrai)
self._prepare()
# Un seul appel : celui de `report`, et pas un de plus.
self.assertEqual(len(appels), 1)
class TestReponseParIndex(unittest.TestCase):
"""Une case cochée à l'écran survit à une correction d'en-tête.
Corriger l'empan RENOMME les colonnes — la ligne de champs change,
donc les étiquettes aussi. Une réponse portée par l'étiquette tombait
alors sur une autre colonne, ou sur aucune, sans que rien ne le dise :
la colonne était anonymisée malgré la case.
"""
def setUp(self):
self.base = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.base, True)
self.source = os.path.join(self.base, "s.csv")
with open(self.source, "w", encoding="utf-8") as flux:
flux.write("etiquette,montant,date,code\n")
flux.write("aboulie,1200,2019-01-02,A1\n")
flux.write("acai,830,2019-01-03,B2\n")
flux.write("adobe,940,2019-01-04,C3\n")
flux.write("acanthe,910,2019-01-05,D4\n")
def _plan(self, **options):
return formats.plan(
self.source,
dict(
{
"nombres": True,
"texte": True,
"graine": 7,
"feuilles": [],
"destination": os.path.join(self.base, "o.csv"),
},
**options,
),
)
EMPAN_CORRIGE = {formats.NOM_FEUILLE_NEUTRE: [1, 2]}
def test_l_index_porte_a_travers_la_correction(self):
apercu = self._plan(
entetes_par_feuille=self.EMPAN_CORRIGE,
colonnes_intactes_index_par_feuille={
formats.NOM_FEUILLE_NEUTRE: [2]
},
)
self.assertEqual(
[c["index"] for c in apercu["colonnes_ecartees"]], [2]
)
self.assertFalse(
[c for c in apercu["apercu"] if c["cellule"].endswith("C2")]
)
def test_l_etiquette_MONTREE_ne_porte_plus_apres_la_correction(self):
"""Le comportement que l'index remplace, gardé comme repère : la
réponse ne tombe nulle part, et la colonne part.
"""
apercu = self._plan(
entetes_par_feuille=self.EMPAN_CORRIGE,
colonnes_intactes_par_feuille={
formats.NOM_FEUILLE_NEUTRE: ["montant"]
},
)
self.assertEqual(apercu["colonnes_ecartees"], [])
self.assertTrue(
[c for c in apercu["apercu"] if c["cellule"].endswith("C2")]
)
def test_sans_correction_l_index_porte_aussi(self):
apercu = self._plan(
colonnes_intactes_index_par_feuille={
formats.NOM_FEUILLE_NEUTRE: [2]
}
)
self.assertEqual(
[c["etiquette"] for c in apercu["colonnes_ecartees"]], ["montant"]
)
def test_un_index_illisible_est_saute_sans_emporter_le_travail(self):
"""La spec traverse un JSON : une clé illisible arrive."""
apercu = self._plan(
colonnes_intactes_index_par_feuille={
formats.NOM_FEUILLE_NEUTRE: [2, "x", None, 0, -3]
}
)
self.assertEqual(
[c["index"] for c in apercu["colonnes_ecartees"]], [2]
)
def test_l_index_d_une_AUTRE_feuille_ne_porte_pas(self):
apercu = self._plan(
colonnes_intactes_index_par_feuille={"Une autre": [2]}
)
self.assertEqual(apercu["colonnes_ecartees"], [])
class TestColonnesEnClair(unittest.TestCase):
"""Une colonne qui sort ENTIÈRE en clair est nommée.
Le critère est unique et ne dépend d'aucune raison : elle porte
quelque chose, et rien n'y a été remplacé. Compter par famille sur
tout le fichier disait POURQUOI sans dire OÙ, et c'est la colonne qui
se transmet, non la famille.
"""
def setUp(self):
self.base = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.base, True)
def _plan(self, contenu, **options):
source = os.path.join(self.base, "s.json")
with open(source, "w", encoding="utf-8") as flux:
json.dump(contenu, flux)
return formats.plan(
source,
dict(
{
"nombres": True,
"texte": True,
"graine": 7,
"feuilles": [],
"colonnes_intactes": [],
"destination": os.path.join(self.base, "o.json"),
},
**options,
),
)
ENREGISTREMENTS = [
{"etiquette": mot, "actif": True, "montant": 1200 + rang}
for rang, mot in enumerate(("aboulie", "acai", "adobe"))
]
def test_une_colonne_de_booleens_est_nommee(self):
"""Les booléens traversent par RÈGLE : rien ne les remplace."""
apercu = self._plan(self.ENREGISTREMENTS)
self.assertEqual(
[
(c["etiquette"], c["cellules"])
for c in apercu["colonnes_en_clair"]
],
[("actif", 3)],
)
def test_une_colonne_remplacee_n_y_figure_pas(self):
apercu = self._plan(self.ENREGISTREMENTS)
nommees = {c["etiquette"] for c in apercu["colonnes_en_clair"]}
self.assertNotIn("etiquette", nommees)
self.assertNotIn("montant", nommees)
def test_une_colonne_qu_une_REPONSE_explique_n_y_revient_pas(self):
"""Elle est déjà nommée par les colonnes écartées : la redire
apprend à ne plus lire la ligne."""
apercu = self._plan(
self.ENREGISTREMENTS, colonnes_intactes={"montant"}
)
ecartees = {c["etiquette"] for c in apercu["colonnes_ecartees"]}
nommees = {c["etiquette"] for c in apercu["colonnes_en_clair"]}
self.assertIn("montant", ecartees)
self.assertNotIn("montant", nommees)
class TestApercuDesLignesSondees(unittest.TestCase):
"""L'écran existe pour faire juger QUELLE ligne nomme les colonnes.
La tranche des trois premières cellules, posée AVANT le filtre des
vides, ne montrait rien d'une ligne dont les trois premières sont
vides : un bloc d'en-tête décalé de quelques colonnes donnait des
lignes d'aperçu toutes vides, et l'opérateur ne pouvait que parier.
"""
def setUp(self):
self.base = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.base, True)
self.source = os.path.join(self.base, "decale.csv")
with open(self.source, "w", encoding="utf-8") as flux:
flux.write(",,,Rapport interne\n")
flux.write(",,,\n")
flux.write(",,,nom,ville,total\n")
flux.write(",,,Aubel,Nord,10\n")
flux.write(",,,Bruant,Sud,20\n")
flux.write(",,,Cerdan,Est,30\n")
def _sondees(self):
rendu = formats.report(self.source)["feuilles"][0]["lignes_sondees"]
return {ligne["numero"]: ligne for ligne in rendu}
def test_un_bloc_DECALE_montre_quand_meme_ses_valeurs(self):
sondees = self._sondees()
self.assertEqual(sondees[3]["apercu"], ["nom", "ville", "total"])
self.assertEqual(sondees[1]["apercu"], ["Rapport interne"])
def test_une_ligne_vraiment_vide_ne_montre_rien(self):
self.assertEqual(self._sondees()[2]["apercu"], [])
def test_l_apercu_reste_borne_a_trois_valeurs(self):
"""Filtrer avant de borner ne doit pas dérouler la ligne."""
for ligne in self._sondees().values():
self.assertLessEqual(
len(ligne["apercu"]), formats.EXEMPLES_PAR_COLONNE
)
def test_le_compte_des_pleines_separe_le_titre_des_champs(self):
"""Il est montré à l'écran : c'est le seul signal quand les
mesures se taisent, une feuille sans en-tête retenu n'en ayant
pas."""
sondees = self._sondees()
self.assertEqual(sondees[1]["pleines"], 1)
self.assertEqual(sondees[2]["pleines"], 0)
self.assertEqual(sondees[3]["pleines"], 3)
class TestExemplesManquants(unittest.TestCase):
"""Au-delà du plafond, les colonnes n'ont pas d'exemple, et le
rapport le COMPTE.
Une colonne sans exemple se lit comme une colonne vide : l'opérateur
la laisse intacte, ou non, sans l'avoir vue. Un booléen que personne
ne lisait n'en disait pas plus que la case vide elle-même.
"""
def setUp(self):
self.base = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.base, True)
def _large(self, colonnes):
chemin = os.path.join(self.base, "large.csv")
with open(chemin, "w", encoding="utf-8") as flux:
flux.write(",".join("C%04d" % i for i in range(colonnes)) + "\n")
for rang in range(3):
flux.write(
",".join(str(rang * colonnes + i) for i in range(colonnes))
+ "\n"
)
return chemin
def test_le_depassement_est_compte(self):
large = formats.EXEMPLES_COLONNES_MAX + 5
feuille = formats.report(self._large(large))["feuilles"][0]
self.assertEqual(feuille["exemples_manquants"], 5)
self.assertEqual(feuille["colonnes"][-1]["exemples"], [])
def test_en_deca_du_plafond_le_compte_est_nul(self):
feuille = formats.report(self._large(4))["feuilles"][0]
self.assertEqual(feuille["exemples_manquants"], 0)
self.assertTrue(feuille["colonnes"][-1]["exemples"])
class TestPorteeDeLOperateur(unittest.TestCase):
"""L'écran montre plus loin que la mesure ne cherche.
La borne de la mesure dit « au-delà, je ne devine plus » ; la prendre
pour la borne de l'écran rendait INATTEIGNABLE la ligne de champs d'un
rapport mis en page, alors que le commentaire de la borne promettait
justement que l'opérateur corrige.
"""
def setUp(self):
self.base = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.base, True)
self.source = os.path.join(self.base, "rapport.csv")
with open(self.source, "w", encoding="utf-8") as flux:
for rang in range(1, formats.LIGNES_SONDEES + 2):
flux.write("Titre %d,,\n" % rang)
# La ligne de champs, hors de portée de la mesure. Sa
# première cellule est un NOMBRE écrit en toutes lettres :
# c'est ce qui révèle si ses brutes ont été gardées.
self.champs = formats.LIGNES_SONDEES + 2
flux.write("2024,Champ,Montant\n")
for rang in range(6):
flux.write("%d,Aubel,%d\n" % (2000 + rang, 100 + rang))
def _plan(self, **options):
return formats.plan(
self.source,
dict(
{
"nombres": True,
"texte": True,
"graine": 7,
"feuilles": [],
"colonnes_intactes": [],
"destination": os.path.join(self.base, "o.csv"),
},
**options,
),
)
def test_l_ecran_montre_la_ligne_que_la_mesure_n_atteint_pas(self):
sondees = formats.report(self.source)["feuilles"][0]["lignes_sondees"]
numeros = [ligne["numero"] for ligne in sondees]
self.assertIn(self.champs, numeros)
self.assertLessEqual(len(numeros), formats.LIGNES_MONTREES)
def test_au_dela_de_la_mesure_la_colonne_des_mesures_est_VIDE(self):
"""Elle dit la vérité : la mesure n'est pas allée jusque-là.
La calculer coûte un balayage de la feuille PAR ligne, et trente
balayages d'un classeur de deux millions de cellules se voient.
"""
sondees = formats.report(self.source)["feuilles"][0]["lignes_sondees"]
par_rang = {ligne["numero"]: ligne for ligne in sondees}
self.assertIsNotNone(par_rang[formats.LIGNES_SONDEES]["mesure"])
self.assertIsNone(par_rang[self.champs]["mesure"])
def test_une_ligne_designee_au_dela_garde_ses_valeurs_BRUTES(self):
"""La fenêtre des brutes suit la portée de l'écran.
Bornée à celle de la mesure, une ligne d'en-tête désignée plus bas
retrouvait ses valeurs coercées : « 2024 » redevenait le nombre
2024, et l'en-tête de la copie portait un nombre là où le fichier
porte un libellé.
"""
apercu = self._plan(
entetes_par_feuille={formats.NOM_FEUILLE_NEUTRE: [self.champs]}
)
valeurs = [c["valeur"] for c in apercu["entete_gardee"]]
self.assertIn("2024", valeurs)
self.assertNotIn(2024, valeurs)
class TestEntreeInstallation(unittest.TestCase):
"""« Installer l'environnement de lecture » dit ce qui est là.
Elle interrogeait le gestionnaire de paquets pour savoir si l'outil
existait, et rendait son ignorance comme une absence : « aucun paquet
connu » devant un `mdb-queries` installé.
"""
def setUp(self):
self.menu = _MenuBouchon()
self.sortie = io.StringIO()
vrai = sys.stdout
sys.stdout = self.sortie
self.addCleanup(setattr, sys, "stdout", vrai)
# Le venv de lecture est supposé prêt : la branche qui le BÂTIT
# n'a rien à faire dans une suite unitaire.
self._remplacer("available", lambda fmt=None: True)
def _remplacer(self, nom, valeur):
vrai = getattr(transform_setup, nom)
setattr(transform_setup, nom, valeur)
self.addCleanup(setattr, transform_setup, nom, vrai)
def test_l_outil_present_est_ANNONCE(self):
self._remplacer("requetes_access_lisibles", lambda: True)
self._remplacer(
"system_packages_cmd", lambda: ["ne-doit-pas-etre-appele"]
)
self.menu._transform_install_env()
rendu = self.sortie.getvalue()
self.assertIn(
todo_i18n.t("Access saved queries are readable here."), rendu
)
self.assertNotIn("ne-doit-pas-etre-appele", rendu)
def test_sans_outil_ni_paquet_connu_l_entree_dit_QUOI_FAIRE(self):
"""Le constat seul laissait l'opérateur sans suite."""
self._remplacer("requetes_access_lisibles", lambda: False)
self._remplacer("system_packages_cmd", lambda: None)
self.menu._transform_install_env()
rendu = self.sortie.getvalue()
self.assertIn("mdb-queries", rendu)
self.assertIn(
todo_i18n.t("Install it by hand to read Access saved queries."),
rendu,
)
class TestCapaciteDesRequetes(unittest.TestCase):
"""La présence de l'outil se demande au PATH, jamais au gestionnaire
de paquets.
Sur une distribution dont ce module ne connaît pas le paquet — Arch,
où mdbtools n'est qu'à l'AUR — l'absence de commande d'installation
était rendue comme l'absence de l'outil, et l'entrée disait « aucun
paquet connu » devant un `mdb-queries` installé.
"""
def setUp(self):
self.base = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.base, True)
self.ancien = os.environ.get("PATH", "")
self.addCleanup(os.environ.__setitem__, "PATH", self.ancien)
def _poser(self, present):
dossier = os.path.join(self.base, "bin")
os.makedirs(dossier, exist_ok=True)
if present:
chemin = os.path.join(dossier, "mdb-queries")
with open(chemin, "w", encoding="utf-8") as flux:
flux.write("#!/bin/sh\ntrue\n")
os.chmod(chemin, 0o755)
os.environ["PATH"] = dossier
def test_present_sur_le_PATH(self):
self._poser(True)
self.assertTrue(transform_setup.requetes_access_lisibles())
self.assertTrue(transform_setup.capabilities()["access queries"])
def test_absent_du_PATH(self):
self._poser(False)
self.assertFalse(transform_setup.requetes_access_lisibles())
self.assertFalse(transform_setup.capabilities()["access queries"])
def test_access_reste_lisible_sans_l_outil(self):
"""Les deux questions sont distinctes : une base Access se lit
sans mdbtools, seulement ses requêtes non."""
self._poser(False)
capacites = transform_setup.capabilities()
self.assertIn("access", capacites)
self.assertFalse(capacites["access queries"])
class TestRequetesAccess(unittest.TestCase):
"""Les requêtes enregistrées : comptées quand on sait, dites sinon.
`access-parser` n'en rend aucune — il lit les TABLES, et le catalogue
où elles vivent est écarté exprès. `mdb-queries` de mdbtools les
liste, et son absence ne se devine pas : `None` veut dire « personne
ici ne sait le dire », `[]` veut dire « cette base n'en porte aucune »,
et les confondre annonçait une absence là où il y avait ignorance.
"""
def setUp(self):
self.base = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.base, True)
self.chemin = os.path.join(self.base, "b.mdb")
with open(self.chemin, "wb") as flux:
flux.write(b"\x00" * 16)
def _bouchon(self, corps, code=0):
"""Un `mdb-queries` de substitution, posé en tête de PATH.
Aucune base Access de ce dépôt ne porte de requête, et mdbtools
ne sait pas en écrire : le bouchon est la seule façon d'éprouver
les trois branches, et il éprouve bien ce qui est à éprouver — la
lecture de la sortie, non le format JET.
"""
dossier = os.path.join(self.base, "bin")
os.makedirs(dossier, exist_ok=True)
chemin = os.path.join(dossier, "mdb-queries")
with open(chemin, "w", encoding="utf-8") as flux:
flux.write("#!/bin/sh\n%s\nexit %d\n" % (corps, code))
os.chmod(chemin, 0o755)
ancien = os.environ.get("PATH", "")
os.environ["PATH"] = dossier + os.pathsep + ancien
self.addCleanup(os.environ.__setitem__, "PATH", ancien)
def test_sans_le_binaire_on_ne_SAIT_pas(self):
ancien = os.environ.get("PATH", "")
os.environ["PATH"] = os.path.join(self.base, "vide")
self.addCleanup(os.environ.__setitem__, "PATH", ancien)
self.assertIsNone(formats.requetes_access(self.chemin))
def test_une_base_sans_requete_rend_une_liste_VIDE(self):
"""Et non None : l'aperçu n'a alors rien à annoncer."""
self._bouchon("true")
self.assertEqual(formats.requetes_access(self.chemin), [])
def test_les_noms_sont_rendus_un_par_ligne(self):
self._bouchon("printf 'Factures\\nSoldes\\n\\n Totaux \\n'")
self.assertEqual(
formats.requetes_access(self.chemin),
["Factures", "Soldes", "Totaux"],
)
def test_un_binaire_qui_echoue_vaut_une_IGNORANCE(self):
"""Non une absence : une base illisible par mdbtools n'est pas une
base sans requête."""
self._bouchon("echo 'unable to open'", code=1)
self.assertIsNone(formats.requetes_access(self.chemin))
class TestRapportDesRequetes(unittest.TestCase):
"""Ce que l'aperçu dit des requêtes, dans les trois cas."""
def setUp(self):
self.menu = _MenuBouchon()
self.sortie = io.StringIO()
vrai = sys.stdout
sys.stdout = self.sortie
self.addCleanup(setattr, sys, "stdout", vrai)
RAPPORT = {"chemin": "b.mdb", "taille": 1, "format": "access"}
def test_les_requetes_sont_NOMMEES(self):
"""Un chiffre ne dit pas ce qui manque à la copie."""
rendu = self.menu._transform_render_report(
dict(self.RAPPORT, requetes=["Factures", "Soldes"])
)
self.assertIn(todo_i18n.t("saved query(ies)"), rendu)
self.assertIn("Factures", rendu)
self.assertIn("Soldes", rendu)
def test_au_dela_de_douze_le_reste_est_COMPTE(self):
rendu = self.menu._transform_render_report(
dict(self.RAPPORT, requetes=["R%02d" % n for n in range(20)])
)
self.assertIn("R00", rendu)
self.assertNotIn("R19", rendu)
self.assertIn(todo_i18n.t("more, not listed"), rendu)
def test_sans_requete_rien_n_est_dit(self):
rendu = self.menu._transform_render_report(dict(self.RAPPORT))
self.assertNotIn(todo_i18n.t("saved query(ies)"), rendu)
class TestTableAbimee(unittest.TestCase):
"""Une table abîmée refuse en la NOMMANT.
L'exception nue remontait au filet de dernier recours, qui la rendait
sous « format non reconnu » — donc en accusant le classeur source, que
l'opérateur concluait corrompu. Le cas arrive de deux façons : une
table tronquée par une interruption, et un autre fichier de
`private/` désigné à l'invite.
"""
def setUp(self):
self.base = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.base, True)
def _table(self, contenu):
chemin = os.path.join(self.base, "t.json")
with open(chemin, "w", encoding="utf-8") as flux:
flux.write(contenu)
return chemin
ABIMEES = {
"tronquée": '{"version": 2, "mots": {"Aubel": "pomme"',
"racine liste": "[1, 2, 3]",
"racine chaîne": '"une table"',
"vide": "",
}
def test_le_refus_NOMME_la_table(self):
for genre, contenu in self.ABIMEES.items():
with self.subTest(genre=genre):
chemin = self._table(contenu)
with self.assertRaises(formats.ErreurMoteur) as capture:
noyau.Correspondance.charger(chemin)
self.assertEqual(capture.exception.cle, "table_illisible")
self.assertIn(chemin, capture.exception.detail)
self.assertTrue(capture.exception.conseil)
def test_la_cle_a_son_libelle_et_sa_traduction(self):
"""Sans quoi le message sortirait en clé brute."""
libelle = noyau.ERREURS["table_illisible"]
self.assertIn(libelle, todo_i18n.TRANSLATIONS)
def test_un_chemin_absent_rend_une_table_NEUVE(self):
"""Le cas normal du premier fichier d'un lot."""
table = noyau.Correspondance.charger(
os.path.join(self.base, "rien.json")
)
self.assertEqual(table.mots, {})
def test_le_menu_ouvre_l_ecran_malgre_une_table_abimee(self):
"""La mémoire est un confort : son absence n'empêche rien."""
for genre, contenu in self.ABIMEES.items():
with self.subTest(genre=genre):
self.assertEqual(
_tm().TransformMenuMixin._transform_memoire(
self._table(contenu)
),
{},
)
class TestMemoireJusquALEcran(unittest.TestCase):
"""La table du disque, lue par le menu, pré-cochée par l'écran.
Les tests de chaque bout passaient sur un bouchon : celui du menu
rendait une mémoire posée à la main, celui de l'écran ne montait pas
le menu. Le chemin entier n'était éprouvé par personne, alors que
c'est lui qui porte la promesse — un lot entamé se reprend sans
redire.
"""
def setUp(self):
self.base = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.base, True)
self.table = os.path.join(self.base, "lot.json")
RAPPORT = {
"fichier": "f.csv",
"format": "csv",
"feuilles": [
{
"nom": "Ventes",
"colonnes": [
{
"index": 1,
"etiquette": "a",
"type": "texte",
"remplies": 3,
"distinctes": 3,
"exemples": ["x"],
"plancher": False,
}
],
# La MESURE dit « ligne 1 » ; la table, « 1 et 2 ».
"lignes_entete": [1],
"lignes_sondees": [
{
"numero": n,
"apercu": ["v"],
"pleines": 1,
"mesure": None,
}
for n in (1, 2)
],
}
],
}
def _contexte(self):
from script.todo import transform_form
memoire = _tm().TransformMenuMixin._transform_memoire(self.table)
return transform_form.contexte_depuis_rapport(self.RAPPORT, memoire)
def test_la_table_ecrite_arrive_pre_cochee_a_l_ecran(self):
noyau.Correspondance(entetes={"Ventes": [1, 2]}).ecrire(self.table)
feuille = self._contexte()["feuilles"][0]
self.assertEqual(feuille["lignes_entete"], [1, 2])
self.assertTrue(feuille["entete_memorisee"])
def test_sans_table_la_mesure_reste(self):
feuille = self._contexte()["feuilles"][0]
self.assertEqual(feuille["lignes_entete"], [1])
self.assertFalse(feuille["entete_memorisee"])
class TestMemoireDesEntetes(unittest.TestCase):
"""La table de lot se rappelle les lignes d'en-tête corrigées.
Le deuxième fichier d'un même export porte les mêmes feuilles : la
correction n'est à faire qu'une fois. Ce qui est retenu est la
RÉPONSE de l'opérateur et rien d'autre — retenir une mesure ferait
propager son erreur à tout le lot, alors qu'elle se refait à
l'identique sur chaque fichier.
"""
def setUp(self):
self.base = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.base, True)
self.table = os.path.join(self.base, "t.json")
def _csv(self, nom):
chemin = os.path.join(self.base, nom)
with open(chemin, "w", encoding="utf-8") as flux:
flux.write("ZK204817,501,100.5\n")
flux.write("ZK204818,502,101.5\n")
flux.write("ZK204819,503,102.5\n")
return chemin
OPTIONS = {"nombres": True, "texte": True, "graine": 7, "feuilles": []}
def _ecrire(self, nom, **options):
formats.ecrire(
self._csv(nom),
os.path.join(self.base, nom + ".out"),
dict(self.OPTIONS, table_chemin=self.table, **options),
)
return noyau.Correspondance.charger(self.table).entetes
def test_la_table_se_rappelle_ce_que_l_operateur_a_CORRIGE(self):
retenu = self._ecrire(
"un.csv",
entetes_par_feuille={formats.NOM_FEUILLE_NEUTRE: [1]},
entetes_corrigees=[formats.NOM_FEUILLE_NEUTRE],
)
self.assertEqual(retenu, {formats.NOM_FEUILLE_NEUTRE: [1]})
def test_un_empan_MESURÉ_n_est_pas_retenu(self):
"""L'écran rend l'empan de CHAQUE feuille — c'est ce qui a été
montré, et c'est là-dessus que l'opérateur a consenti. Le retenir
imposerait la mesure d'un fichier à tout le lot, jusque là où la
mesure du suivant dirait autre chose : une erreur de mesure
devenait la loi du lot.
"""
retenu = self._ecrire(
"deux.csv",
entetes_par_feuille={formats.NOM_FEUILLE_NEUTRE: [1]},
entetes_corrigees=[],
)
self.assertEqual(retenu, {})
def test_sans_la_liste_des_corrections_rien_n_est_retenu(self):
"""Un appelant qui ne la fournit pas — le dialogue textuel — ne
doit rien inscrire : il ne sait pas ce qui a été corrigé."""
retenu = self._ecrire(
"trois.csv",
entetes_par_feuille={formats.NOM_FEUILLE_NEUTRE: [1]},
)
self.assertEqual(retenu, {})
def test_une_correction_qui_ne_nomme_aucune_feuille_connue_est_ignoree(
self,
):
retenu = self._ecrire(
"quatre.csv",
entetes_par_feuille={formats.NOM_FEUILLE_NEUTRE: [1]},
entetes_corrigees=["Une autre feuille"],
)
self.assertEqual(retenu, {})
def test_le_fichier_suivant_du_lot_herite_de_la_correction(self):
"""Sans qu'on redise rien : c'est tout l'objet de la mémoire."""
table = noyau.Correspondance(entetes={formats.NOM_FEUILLE_NEUTRE: [1]})
table.ecrire(self.table)
apercu = formats.plan(
self._csv("deux.csv"),
dict(
self.OPTIONS,
table_chemin=self.table,
destination=os.path.join(self.base, "b.csv"),
),
)
valeurs = [c["valeur"] for c in apercu["entete_gardee"]]
self.assertIn("ZK204817", valeurs)
def test_sans_memoire_la_mesure_tranche_et_la_ligne_1_est_en_portee(self):
apercu = formats.plan(
self._csv("trois.csv"),
dict(
self.OPTIONS,
destination=os.path.join(self.base, "c.csv"),
),
)
self.assertEqual(apercu["entete_gardee"], [])
def test_la_reponse_de_CE_passage_passe_avant_la_memoire(self):
"""L'opérateur peut toujours contredire ce que le lot a établi."""
feuille = formats.Feuille(
"F", [["a", "b"], ["x", 1], ["y", 2], ["z", 3]]
)
formats.mesurer_entetes([feuille], "csv", {"F": [2]}, {"F": [1]})
self.assertEqual(feuille.lignes_entete, {2})
def test_la_memoire_passe_avant_ce_que_le_FICHIER_declare(self):
"""C'est une réponse d'opérateur elle aussi, faite sur un autre
fichier du même lot."""
feuille = formats.Feuille(
"F", [["a", "b"], ["x", 1], ["y", 2], ["z", 3]]
)
feuille.entete_declaree = {1}
formats.mesurer_entetes([feuille], "xlsx", None, {"F": [1, 2]})
self.assertEqual(feuille.lignes_entete, {1, 2})
def test_une_memoire_qui_ne_nomme_pas_la_feuille_ne_fait_rien(self):
feuille = formats.Feuille(
"F", [["a", "b"], ["x", 1], ["y", 2], ["z", 3]]
)
feuille.entete_declaree = {1}
formats.mesurer_entetes([feuille], "xlsx", None, {"Autre": [9]})
self.assertEqual(feuille.lignes_entete, {1})
class TestTableVersion2(unittest.TestCase):
"""La table porte un troisième dictionnaire, sans casser l'ancienne."""
def setUp(self):
self.base = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.base, True)
self.chemin = os.path.join(self.base, "t.json")
def test_une_table_de_version_1_se_charge_toujours(self):
"""Sinon le deuxième fichier d'un lot commencé avant refuserait
la table du premier."""
with open(self.chemin, "w", encoding="utf-8") as flux:
json.dump(
{"version": 1, "mots": {"a": "aboulie"}, "nombres": {}}, flux
)
relue = noyau.Correspondance.charger(self.chemin)
self.assertEqual(relue.mots, {"a": "aboulie"})
self.assertEqual(relue.entetes, {})
def test_l_ecriture_reste_en_0600(self):
"""Ce fichier porte chaque valeur d'origine en clair."""
noyau.Correspondance(entetes={"F": [1]}).ecrire(self.chemin)
self.assertEqual(os.stat(self.chemin).st_mode & 0o777, 0o600)
def test_les_lignes_sont_normalisees_et_triees(self):
"""Une réponse tapée à la main, ou relue d'un JSON, arrive en
chaînes et dans n'importe quel ordre."""
table = noyau.Correspondance(entetes={"F": ["3", 1, "1", 0, -2]})
self.assertEqual(table.entetes, {"F": [1, 3]})
def test_en_dict_porte_les_trois_dictionnaires(self):
rendu = noyau.Correspondance(
mots={"a": "b"}, nombres={"i:1": 2}, entetes={"F": [1]}
).en_dict()
self.assertEqual(sorted(rendu), ["entetes", "mots", "nombres"])
json.dumps(rendu, allow_nan=False)
class TestCalibreDesChiffres(unittest.TestCase):
"""« Garder le nombre de chiffres » : 8839 tire dans 1000..9999.
L'option échange l'étendue mesurée de la colonne contre celle des
nombres de MÊME LARGEUR. Les deux ne tiennent pas ensemble — une
colonne mêle des largeurs — et c'est l'opérateur qui tranche.
"""
def setUp(self):
self.rng = random.Random(1234)
self.table = noyau.Correspondance()
def _tirer(self, valeur, bornes=(1, 999999, True)):
return noyau.nouveau_nombre(
valeur, self.rng, bornes=bornes, table=self.table, calibre=True
)
def test_la_largeur_est_conservee(self):
for valeur in (7, 42, 839, 8839, 12345, 987654):
with self.subTest(valeur=valeur):
rendu = self._tirer(valeur)
self.assertEqual(
len(str(abs(int(rendu)))), len(str(abs(valeur)))
)
def test_le_signe_l_est_aussi(self):
for valeur in (-7, -8839):
with self.subTest(valeur=valeur):
rendu = self._tirer(valeur)
self.assertLess(rendu, 0)
self.assertEqual(
len(str(abs(int(rendu)))), len(str(abs(valeur)))
)
def test_la_valeur_ne_se_rend_pas_a_elle_meme(self):
for _ in range(30):
self.assertNotEqual(self._tirer(8839), 8839)
def test_sous_l_unite_l_etendue_MESUREE_l_emporte(self):
"""« Garder le nombre de chiffres » n'a pas de sens pour 0,15 :
appliquer la règle quand même tirerait un taux entre 1 et 9, ce
que l'étendue mesurée existe pour empêcher."""
self.assertIsNone(noyau.bornes_du_meme_calibre(0.15))
for _ in range(30):
rendu = self._tirer(0.15, bornes=(0.1, 0.9, False))
self.assertLess(rendu, 1)
self.assertGreater(rendu, 0)
def test_les_bornes_du_calibre(self):
self.assertEqual(noyau.bornes_du_meme_calibre(8839), (1000, 9999))
self.assertEqual(noyau.bornes_du_meme_calibre(7), (1, 9))
self.assertEqual(noyau.bornes_du_meme_calibre(-42), (-99, -10))
# La partie ENTIÈRE décide : un montant garde sa largeur.
self.assertEqual(noyau.bornes_du_meme_calibre(1234.56), (1000, 9999))
def test_sans_l_option_l_etendue_mesuree_gouverne(self):
"""La règle par défaut ne bouge pas : c'est elle qui empêche un
taux de devenir 743."""
rng = random.Random(1234)
for _ in range(30):
rendu = noyau.nouveau_nombre(
8839, rng, bornes=(1, 20, True), table=noyau.Correspondance()
)
self.assertLessEqual(rendu, 20)
def test_l_etendue_ABANDONNEE_est_annoncee(self):
"""L'option échange une garantie contre une autre : le dire est
la moitié du travail. Sans l'avertissement, un taux qui sort de
sa plage se découvre à la réimportation."""
import tempfile
base = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, base, True)
source = os.path.join(base, "m.csv")
with open(source, "w", encoding="utf-8") as flux:
flux.write("ref,montant\nA,8839\nB,9124\nC,7733\n")
marqueur = "digit count"
for calibre in (False, True):
with self.subTest(calibre=calibre):
apercu = formats.plan(
source,
{
"nombres": True,
"texte": True,
"graine": 7,
"feuilles": [],
"colonnes_intactes": [],
"calibre_chiffres": calibre,
"destination": os.path.join(base, "o.csv"),
},
)
dits = [a for a in apercu["avertissements"] if marqueur in a]
self.assertEqual(bool(dits), calibre)
def test_l_integrite_de_la_table_tient_toujours(self):
"""Même clé, même nombre — c'est ce qui garde les jointures."""
premier = self._tirer(8839)
self.assertEqual(self._tirer(8839), premier)
def test_deux_valeurs_distinctes_ne_collisionnent_pas(self):
rendus = {self._tirer(v) for v in range(1000, 1040)}
self.assertEqual(len(rendus), 40)
class TestNombre(unittest.TestCase):
"""Le signe, le zéro, le type, et l'étendue mesurée."""
def setUp(self):
self.rng = random.Random(1234)
def test_positif_reste_positif(self):
for _ in range(50):
self.assertGreater(noyau.nouveau_nombre(7, self.rng), 0)
def test_negatif_reste_negatif(self):
for _ in range(50):
self.assertLess(noyau.nouveau_nombre(-7, self.rng), 0)
def test_zero_reste_zero(self):
# Zéro n'a pas de signe à préserver, et un zéro qui devient 743
# fabrique de la donnée là où il n'y en avait pas.
self.assertEqual(noyau.nouveau_nombre(0, self.rng), 0)
self.assertEqual(noyau.nouveau_nombre(0.0, self.rng), 0.0)
def test_entier_rend_entier(self):
valeur = noyau.nouveau_nombre(5, self.rng)
self.assertIsInstance(valeur, int)
self.assertNotIsInstance(valeur, bool)
def test_flottant_rend_flottant(self):
self.assertIsInstance(noyau.nouveau_nombre(5.5, self.rng), float)
def test_repli_zero_mille_sans_bornes(self):
for _ in range(200):
self.assertLessEqual(abs(noyau.nouveau_nombre(5, self.rng)), 1000)
def test_tirage_dans_les_bornes(self):
for _ in range(200):
valeur = noyau.nouveau_nombre(2024, self.rng, bornes=(2000, 2030))
self.assertGreaterEqual(valeur, 2000)
self.assertLessEqual(valeur, 2030)
def test_colonne_zero_un_ne_rend_jamais_743(self):
"""La leçon d'anonymize.py : un taux à 743 fait lever l'ORM."""
for _ in range(200):
valeur = noyau.nouveau_nombre(0.15, self.rng, bornes=(0.0, 1.0))
self.assertGreaterEqual(valeur, 0.0)
self.assertLessEqual(valeur, 1.0)
def test_bornes_negatives_gardent_le_signe_et_l_etendue(self):
for _ in range(200):
valeur = noyau.nouveau_nombre(-3, self.rng, bornes=(-50, 200))
self.assertLess(valeur, 0)
self.assertGreaterEqual(valeur, -50)
def test_meme_valeur_meme_sortie(self):
"""Sans table, une clé de jointure se désagrège ligne à ligne."""
table = noyau.Correspondance()
premier = noyau.nouveau_nombre(4711, self.rng, table=table)
for _ in range(20):
self.assertEqual(
noyau.nouveau_nombre(4711, self.rng, table=table),
premier,
)
def test_types_distincts_ne_se_confondent_pas(self):
table = noyau.Correspondance()
noyau.nouveau_nombre(5, self.rng, table=table)
noyau.nouveau_nombre(5.0, self.rng, table=table)
self.assertEqual(len(table.nombres), 2)
class TestPiegesDeType(unittest.TestCase):
"""Les gardes, dans l'ordre où ils doivent se déclencher."""
def setUp(self):
self.rng = random.Random(7)
self.table = noyau.Correspondance()
self.options = _options()
def _anon(self, valeur, **extra):
options = _options(**extra)
return noyau.anonymise_cellule(valeur, options, self.table, self.rng)
def test_booleen_traverse_intact(self):
# isinstance(True, int) vaut True : sans garde explicite, toute
# case à cocher deviendrait un montant.
self.assertIs(self._anon(True), noyau._INTACTE)
self.assertIs(self._anon(False), noyau._INTACTE)
def test_date_traverse_intacte(self):
for valeur in (
datetime.datetime(2020, 1, 2, 3, 4),
datetime.date(2020, 1, 2),
datetime.time(3, 4),
):
self.assertIs(self._anon(valeur), noyau._INTACTE)
def test_vide_traverse_intact(self):
self.assertIs(self._anon(None), noyau._INTACTE)
self.assertIs(self._anon(""), noyau._INTACTE)
def test_formule_traverse_intacte(self):
self.assertIs(self._anon('=IF(A1="x",1,0)'), noyau._INTACTE)
def test_les_sept_valeurs_erreur_traversent_intactes(self):
for erreur in noyau.VALEURS_ERREUR:
self.assertIs(
self._anon(erreur),
noyau._INTACTE,
f"{erreur} doit rester intacte",
)
def test_il_y_a_bien_sept_valeurs_erreur(self):
self.assertEqual(len(noyau.VALEURS_ERREUR), 7)
def test_binaire_est_vide_pas_recopie(self):
# Une colonne OLE d'Access peut porter un document entier.
self.assertIsNone(self._anon(b"\x00document"))
self.assertIsNone(self._anon(bytearray(b"\x01")))
def test_texte_refuse_ne_touche_rien(self):
self.assertIs(self._anon("Alpha", texte=False), noyau._INTACTE)
def test_nombres_refuses_ne_touchent_rien(self):
self.assertIs(self._anon(42, nombres=False), noyau._INTACTE)
class TestNormalisationXls(unittest.TestCase):
"""xlrd porte le type dans ctype, jamais dans la valeur."""
def test_code_23_rend_ref(self):
self.assertEqual(noyau.normaliser_xls(5, 23, 0), "#REF!")
def test_code_0_rend_null(self):
"""Le piège : « 0 reste 0 » en ferait un zéro légitime."""
self.assertEqual(noyau.normaliser_xls(5, 0, 0), "#NULL!")
def test_les_sept_codes_sont_couverts(self):
self.assertEqual(
set(noyau.CODES_ERREUR_XLS.values()), set(noyau.VALEURS_ERREUR)
)
def test_booleen_rend_un_vrai_booleen(self):
self.assertIs(noyau.normaliser_xls(4, 1, 0), True)
self.assertIs(noyau.normaliser_xls(4, 0, 0), False)
def test_vide_rend_none(self):
self.assertIsNone(noyau.normaliser_xls(0, "", 0))
self.assertIsNone(noyau.normaliser_xls(6, "", 0))
def test_texte_traverse(self):
self.assertEqual(noyau.normaliser_xls(1, "Alpha", 0), "Alpha")
class TestUniciteNumerique(unittest.TestCase):
"""Le tirage seul collisionne, et détruit les clés.
C'est l'intégrité que la table apporte au texte, et qu'elle refusait en
silence aux nombres : deux clés primaires distinctes recevaient le même
nombre, et la fixture ne se réimportait plus.
"""
def test_cent_valeurs_rendent_cent_sorties(self):
table = noyau.Correspondance()
rng = random.Random(1)
sorties = [
noyau.nouveau_nombre(v, rng, bornes=(1, 100), table=table)
for v in range(1, 101)
]
# Mesuré avant correctif : 66 sorties distinctes sur 100.
self.assertEqual(len(set(sorties)), 100)
def test_etendue_etroite_sans_doublon(self):
for graine in (7, 11, 42):
table = noyau.Correspondance()
rng = random.Random(graine)
sorties = [
noyau.nouveau_nombre(v, rng, bornes=(1, 6), table=table)
for v in range(1, 7)
]
self.assertEqual(len(set(sorties)), 6, graine)
def test_l_elargissement_garde_le_signe(self):
table = noyau.Correspondance()
rng = random.Random(2)
sorties = [
noyau.nouveau_nombre(-v, rng, bornes=(-50, 200), table=table)
for v in range(1, 51)
]
self.assertEqual(len(set(sorties)), 50)
self.assertTrue(all(x < 0 for x in sorties))
def test_une_plage_saturee_reste_dans_ses_bornes(self):
"""Élargir dès le premier échec sortait de la plage mesurée.
Une heure de la journée devenait 189, un taux dépassait l'unité,
alors que la plage avait encore des places libres.
"""
table = noyau.Correspondance()
rng = random.Random(1)
sorties = [
noyau.nouveau_nombre(
round(0.15 + i * 0.01, 2),
rng,
bornes=(0.15, 0.2),
table=table,
)
for i in range(6)
]
self.assertEqual(len(set(sorties)), 6)
for valeur in sorties:
self.assertGreaterEqual(valeur, 0.15)
# Six valeurs dans six places, l'identité interdite : la
# dernière place libre EST parfois l'identité. On grandit
# alors d'un PAS, pas d'un facteur dix — un taux reste un
# taux.
self.assertLessEqual(valeur, 0.25)
def test_une_heure_reste_une_heure(self):
table = noyau.Correspondance()
rng = random.Random(2)
sorties = [
noyau.nouveau_nombre(v, rng, bornes=(0, 23), table=table)
for v in range(1, 24)
]
self.assertEqual(len(set(sorties)), 23)
self.assertLessEqual(max(sorties), 23)
def test_aucun_nombre_n_est_rendu_a_lui_meme(self):
"""Il serait compté et annoncé comme remplacé sans l'être."""
table = noyau.Correspondance()
rng = random.Random(3)
for v in range(1, 7):
self.assertNotEqual(
noyau.nouveau_nombre(v, rng, bornes=(1, 6), table=table), v
)
def test_l_elargissement_ne_sert_qu_en_dernier_recours(self):
"""Plus de places que de valeurs : aucune sortie hors plage."""
table = noyau.Correspondance()
rng = random.Random(4)
sorties = [
noyau.nouveau_nombre(v, rng, bornes=(1, 10), table=table)
for v in range(1, 10)
]
self.assertTrue(all(1 <= x <= 10 for x in sorties), sorties)
def test_sans_table_le_tirage_reste_borne(self):
rng = random.Random(3)
for _ in range(50):
self.assertLessEqual(
noyau.nouveau_nombre(5, rng, bornes=(1, 10)), 10
)
def test_une_table_rechargee_garde_l_unicite(self):
"""Le lot entier, pas seulement le fichier courant."""
table = noyau.Correspondance(nombres={"i:1": 4, "i:2": 5})
self.assertEqual(table.nombres_pris, {4, 5})
rng = random.Random(4)
for v in range(3, 7):
self.assertNotIn(
noyau.nouveau_nombre(v, rng, bornes=(1, 6), table=table),
(4, 5),
)
class TestMotSansRemise(unittest.TestCase):
"""Deux valeurs distinctes ne peuvent PAS partager un mot."""
def test_le_saut_d_identite_ne_reprend_pas_un_mot_donne(self):
"""Le saut avance d'un rang : il pouvait retomber sur un mot pris.
Deux clients fusionnaient alors sur un seul mot — la RECHERCHEV
résout encore, mais sur la mauvaise ligne.
"""
table = noyau.Correspondance()
# La première valeur EST un mot du vivier : le saut se déclenche.
sorties = [
noyau.nouveau_mot(v, table, VIVIER)
for v in (VIVIER[1], "Alpha", "Beta", VIVIER[0], "Gamma")
]
self.assertEqual(len(set(sorties)), len(sorties))
for source, mot in table.mots.items():
self.assertNotEqual(source, mot)
def test_une_table_rechargee_ne_recolle_pas(self):
premiere = noyau.Correspondance()
for v in ("Alpha", "Beta"):
noyau.nouveau_mot(v, premiere, VIVIER)
seconde = noyau.Correspondance(mots=dict(premiere.mots))
for v in ("Gamma", "Delta"):
noyau.nouveau_mot(v, seconde, VIVIER)
self.assertEqual(len(set(seconde.mots.values())), len(seconde.mots))
def test_mots_pris_est_reconstruit_au_chargement(self):
table = noyau.Correspondance(mots={"Alpha": "aboulie"})
self.assertEqual(table.mots_pris, {"aboulie"})
class TestNomDeFeuilleEnConversion(unittest.TestCase):
"""Une table Access porte souvent le nom du client.
La conversion l'écrivait tel quel — nom d'onglet, clé de premier
niveau d'un JSON, nom de fichier — alors que RIEN ne le résout dans
une conversion, contrairement au classeur d'où une formule le
référence. Le tolérer aurait laissé le nom dans la copie tout en
faisant refuser le fichier au filet.
"""
def setUp(self):
self.base = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.base, True)
self.options = {"vivier": VIVIER, "feuilles": None}
def test_le_nom_passe_par_la_MEME_table_que_les_cellules(self):
table = noyau.Correspondance()
feuille = formats.Feuille("Alpha", [["client"], ["Alpha"]])
nom = formats._nom_de_feuille_anonyme(feuille, table, self.options)
self.assertNotEqual(nom, "Alpha")
# La feuille et la cellule qui la nomme reçoivent le même mot.
self.assertEqual(nom, noyau.nouveau_mot("Alpha", table, VIVIER))
def test_sans_table_le_nom_ne_bouge_pas(self):
feuille = formats.Feuille("Alpha", [["c"], ["x"]])
self.assertEqual(
formats._nom_de_feuille_anonyme(feuille, None, self.options),
"Alpha",
)
def test_la_conversion_vers_json_anonymise_la_cle(self):
table = noyau.Correspondance()
feuille = formats.Feuille("Alpha", [["c"], ["Beta"]])
sortie = os.path.join(self.base, "o.json")
formats.convertir(
os.path.join(self.base, "s.csv"),
sortie,
"json",
self.options,
feuilles=[feuille],
table=table,
)
arbre = json.load(open(sortie, encoding="utf-8"))
self.assertNotIn("Alpha", arbre)
class TestExternalIdContreLogin(unittest.TestCase):
"""Un external ID et un login pointé ont la même FORME.
« base.res_partner_7 » et « jean.tremblay » sont tous deux des jetons
minuscules pointés : la forme seule ne les sépare pas, et une colonne
« user_id » de logins passait pour une colonne de relations, donc
partait en clair. Ce qui les sépare est le PRÉFIXE : un external ID
partage son module avec ses voisins, des noms de personnes non.
"""
def setUp(self):
self.base = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.base, True)
def _ecrire(self, contenu):
chemin = os.path.join(self.base, "e.csv")
with open(chemin, "w", encoding="utf-8") as fh:
fh.write(contenu)
return chemin
def test_un_external_id_reste_au_plancher(self):
source = self._ecrire(
"id,partner_id/id,montant\n"
"7,base.res_partner_7,1200\n"
"8,base.res_partner_8,830\n"
)
sortie = os.path.join(self.base, "o.csv")
formats.ecrire(source, sortie, {"graine": "3"})
rendu = open(sortie, encoding="utf-8").read()
self.assertIn("base.res_partner_7", rendu)
def test_un_login_pointe_est_remplace(self):
source = self._ecrire(
"id,user_id,montant\n"
"7,jean.tremblay,1200\n"
"8,marie.roy,830\n"
"9,paul.gagne,410\n"
)
sortie = os.path.join(self.base, "o.csv")
formats.ecrire(source, sortie, {"graine": "3"})
rendu = open(sortie, encoding="utf-8").read()
for login in ("jean.tremblay", "marie.roy", "paul.gagne"):
self.assertNotIn(login, rendu, login)
def test_l_annonce_suit_la_portee(self):
"""L'écran annonçait une colonne écartée que le moteur remplace."""
source = self._ecrire(
"id,partner_id/id,user_id,montant\n"
"7,base.res_partner_7,jean.tremblay,1200\n"
"8,base.res_partner_8,marie.roy,830\n"
)
apercu = formats.plan(source, {"graine": "3"})
ecartees = {c["etiquette"] for c in apercu["colonnes_ecartees"]}
self.assertIn("partner_id/id", ecartees)
self.assertNotIn("user_id", ecartees)
class TestNormalisationAccess(unittest.TestCase):
"""`access-parser` rend une date et un montant en CHAÎNE.
Comme pour `.xls`, c'est le TYPE déclaré qui décide et jamais la forme
de la valeur : une colonne de texte peut légitimement porter
« 2021-12-02 00:00:00 », et la convertir la mettrait hors d'atteinte de
la règle du texte.
"""
def test_une_date_devient_une_date(self):
valeur = noyau.normaliser_access(
"2021-12-02 00:00:00", noyau.ACCESS_DATETIME
)
self.assertIsInstance(valeur, datetime.datetime)
self.assertEqual(valeur.year, 2021)
def test_la_date_invalide_est_videe(self):
"""Le marqueur d'Access ne porte aucune donnée."""
self.assertIsNone(
noyau.normaliser_access("(Invalid Date)", noyau.ACCESS_DATETIME)
)
def test_un_montant_devient_un_nombre(self):
self.assertEqual(noyau.normaliser_access("$1,995.50", 5), 1995.50)
self.assertEqual(noyau.normaliser_access("($1,995.50)", 5), -1995.50)
def test_un_montant_illisible_reste_du_texte(self):
self.assertEqual(noyau.normaliser_access("sur devis", 5), "sur devis")
def test_le_texte_qui_RESSEMBLE_a_une_date_reste_du_texte(self):
"""Le type décide, pas la forme."""
self.assertEqual(
noyau.normaliser_access("2021-12-02 00:00:00", 10),
"2021-12-02 00:00:00",
)
def test_les_types_couverts(self):
self.assertEqual(noyau.ACCESS_DATETIME, 8)
self.assertIn(5, noyau.ACCESS_MONETAIRE)
class TestFiletSurUneValeurNumerique(unittest.TestCase):
"""Une valeur de chiffres ne se cherche pas comme un mot.
Sur une base ordinaire, le filet refusait seize valeurs dont aucune
ne fuyait. Un code postal « 0512 » se retrouve dans l'identifiant
10512 ; « 1203 » est le numéro de ligne que
`<row r="1203">` porte dans toute feuille de plus de mille lignes ; et
un nombre TIRÉ pour une colonne peut égaler, chiffre pour chiffre, une
valeur texte d'une autre.
Refuser toute copie d'un fichier ordinaire rend le filet inutile aussi
sûrement que ne rien refuser.
"""
def test_une_valeur_noyee_dans_un_nombre_plus_long_ne_compte_pas(self):
motif = noyau._motif_borne("0512")
self.assertFalse(motif.search("10512"))
self.assertFalse(motif.search("105120"))
self.assertFalse(motif.search("0512.7"))
def test_une_vraie_survivance_reste_vue(self):
"""Bordée de ce qui n'est pas un chiffre, elle est toujours là."""
motif = noyau._motif_borne("0512")
for foin in ("<v>0512</v>", '"0512"', "a,0512,b", "0512", ">0512<"):
with self.subTest(foin=foin):
self.assertTrue(motif.search(foin))
def test_la_virgule_borne_un_champ_de_csv(self):
"""L'exclure aveuglait le filet sur le format le plus simple, là
où un séparateur de milliers coupe déjà la suite de chiffres."""
self.assertTrue(noyau._motif_borne("1203").search("x,1203,y"))
self.assertEqual("1,203".count("1203"), 0)
def test_une_lettre_collee_borne_aussi(self):
"""`r="A1010"` met un numéro de ligne contre une lettre de
colonne, et des chiffres collés à une lettre font un seul jeton."""
self.assertFalse(noyau._motif_borne("1010").search('r="A1010"'))
self.assertFalse(noyau._motif_borne("1203").search("SKU1203"))
def test_une_valeur_a_lettres_se_borne_par_les_seuls_mots(self):
"""Elle se borne aussi, mais pas sur le point.
« Document » vit dans l'URI `officeDocument` que tout classeur
écrit, et le socle du graveur ne l'excuse pas : il est MINIMAL, si
bien que chaque type de partie que la source a en plus apporte une
URI distincte de plus. Le point ne borne pas ici — il suit un mot
en fin de phrase sans en faire un autre mot.
"""
motif = noyau._motif_borne("Document")
self.assertIsNotNone(motif)
self.assertFalse(motif.search("officeDocument"))
self.assertFalse(motif.search("Documentation"))
self.assertTrue(motif.search("<t>Document</t>"))
self.assertTrue(motif.search("Document."))
def test_un_nom_dans_un_cache_ou_un_litteral_reste_trouvable(self):
"""Ce que le bornage NE doit pas coûter : une survivance vraie est
bordée de balisage ou de guillemets, jamais collée à un mot."""
motif = noyau._motif_borne("aboulie")
for foin in (
"<c:v>aboulie</c:v>",
'="aboulie"',
'="Pre"&"aboulie"',
"a,aboulie,b",
"aboulie-2024",
):
with self.subTest(foin=foin):
self.assertTrue(motif.search(foin))
def test_un_autre_mot_qui_contient_la_valeur_n_en_est_pas_une(self):
"""« Bellevue » n'est pas une survivance de « Belle »."""
self.assertFalse(noyau._motif_borne("Belle").search("Bellevue"))
def test_les_attributs_ne_portent_pas_de_valeur_numerique(self):
"""Un XML porte ses index dans les ATTRIBUTS et ses valeurs de
cellule dans les nœuds de texte : la matière est séparée."""
brut = '<row r="1203"><c r="A1203"><v>7</v></c></row>'
_e, _n, texte, _tn = noyau._chaines_distinctes(brut)
self.assertIn("7", texte)
self.assertNotIn("1203", texte)
self.assertNotIn("A1203", texte)
def test_une_partie_plate_reste_fouillee_ENTIERE(self):
"""Les deux derniers blocs valent les deux premiers hors XML : un
champ de csv est de la donnée où qu'il soit."""
blocs = noyau._matiere("o.csv", "a,0512,b", reductible=False)
self.assertEqual(len(blocs), 4)
self.assertEqual(blocs[0], blocs[2])
self.assertEqual(blocs[1], blocs[3])
def test_un_nombre_tire_est_tolere_comme_un_mot_l_est(self):
"""Sa présence est expliquée par le tirage, non par une
survivance : un identifiant tiré à 10785 et un code postal
« 10785 » sont les mêmes chiffres pour des raisons différentes."""
base = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, base, True)
copie = os.path.join(base, "o.csv")
with open(copie, "w", encoding="utf-8") as flux:
flux.write("aboulie,10785\n")
table = noyau.Correspondance()
table.mots["10785"] = "aboulie"
table.nombres[42] = 10785
fuites, _non = noyau.verifier_copie([copie], table)
self.assertEqual(fuites, {})
def test_l_identite_d_un_nombre_reste_un_refus(self):
"""Un nombre rendu à lui-même est annoncé remplacé et ne l'est
pas : c'est le cas que la tolérance ne doit PAS couvrir."""
base = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, base, True)
copie = os.path.join(base, "o.csv")
with open(copie, "w", encoding="utf-8") as flux:
flux.write("10785,x\n")
table = noyau.Correspondance()
table.mots["10785"] = "aboulie"
table.nombres[10785] = 10785
fuites, _non = noyau.verifier_copie([copie], table)
self.assertIn("10785", fuites)
class TestResolutionDeLaColonne(unittest.TestCase):
"""L'intégralité vient de la COLONNE, non du type d'une valeur.
`isinstance(valeur, int)` ne peut pas en répondre : le format `.xls`
ne stocke que des doubles, si bien que son lecteur rend 100 en
« 100.0 ». Toute colonne d'entiers d'un `.xls` ressortait donc
décimale, et la copie ne se réimportait plus dans un champ entier.
"""
def setUp(self):
self.rng = random.Random(7)
def test_une_colonne_entiere_rend_des_entiers(self):
"""Même quand le lecteur a rendu des flottants."""
for valeur in (100.0, 256.0, 101.0):
with self.subTest(valeur=valeur):
tire = noyau.nouveau_nombre(
valeur, self.rng, bornes=(100.0, 256.0, True)
)
self.assertTrue(float(tire).is_integer(), tire)
def test_une_colonne_decimale_garde_ses_decimales(self):
vus = [
noyau.nouveau_nombre(
v, random.Random(v), bornes=(0.02, 1007.64, False)
)
for v in (32.38, 47.42, 126.38)
]
self.assertFalse(all(float(v).is_integer() for v in vus), vus)
def test_sans_troisieme_terme_le_type_decide_encore(self):
"""Les bornes à deux termes restent valides : la fonction sert
aussi hors du parcours de colonnes."""
self.assertTrue(
float(
noyau.nouveau_nombre(5, self.rng, bornes=(1, 100))
).is_integer()
)
self.assertIsInstance(
noyau.nouveau_nombre(5.5, self.rng, bornes=(1.0, 100.0)), float
)
def test_la_cle_de_la_table_ne_depend_PAS_de_la_colonne(self):
"""Sinon un même nombre vu dans deux colonnes de résolutions
différentes recevrait deux remplacements, et la jointure qui les
relie se désagrégerait."""
table = noyau.Correspondance()
premier = noyau.nouveau_nombre(
10248, random.Random(1), bornes=(10248, 11077, True), table=table
)
second = noyau.nouveau_nombre(
10248, random.Random(2), bornes=(1, 99999, False), table=table
)
self.assertEqual(premier, second)
def test_une_seule_decimale_suffit_a_decimaliser_la_colonne(self):
"""La mesure porte sur TOUTE la colonne."""
feuille = formats.Feuille(
"F", [["montant"], [1.0], [2.0], [3.5], [4.0]]
)
colonne = formats._stats_colonnes(feuille)[0]
self.assertFalse(colonne["entiere"])
entiere = formats._stats_colonnes(
formats.Feuille("F", [["m"], [1.0], [2.0], [3.0]])
)[0]
self.assertTrue(entiere["entiere"])
def test_une_colonne_sans_nombre_n_est_pas_dite_entiere(self):
"""Vraie par vacuité, elle aurait forcé un arrondi ailleurs."""
colonne = formats._stats_colonnes(
formats.Feuille("F", [["nom"], ["aboulie"], ["acai"]])
)[0]
self.assertFalse(colonne["entiere"])
class TestEchelleMonetaireAccess(unittest.TestCase):
"""`access-parser` a DEUX sorties pour une colonne monétaire.
Reconnaît-il le format déclaré de la colonne, il place le point décimal
et rend « $14.00 ». Ne le reconnaît-il pas, il rend l'entier de
stockage TEL QUEL — et Access garde un Currency en entier multiplié par
dix mille. Les deux formes cohabitent dans un MÊME fichier : une
colonne de prix en sort juste et une colonne de fret gonflée de quatre
ordres de grandeur.
Le point décimal tranche sans deviner : toutes les branches de la
bibliothèque qui aboutissent en insèrent un, celle qui renonce n'en
met pas.
"""
def _cas(self, brut, attendu):
"""Éprouvé sur CHAQUE code monétaire, non sur un seul."""
for code in sorted(noyau.ACCESS_MONETAIRE):
with self.subTest(code=code, brut=brut):
rendu = noyau.normaliser_access(brut, code)
self.assertAlmostEqual(rendu, attendu, places=6)
def test_l_entier_de_stockage_est_divise(self):
self._cas("474200", 47.42)
self._cas("1263800", 126.38)
self._cas("54500", 5.45)
self._cas("0", 0.0)
def test_la_forme_localisee_ne_l_est_pas(self):
self._cas("$14.00", 14.0)
self._cas("$1,995.50", 1995.5)
self._cas("\u20ac5.00", 5.0)
def test_le_signe_survit_aux_deux_formes(self):
"""Access écrit un négatif entre parenthèses."""
self._cas("(474200)", -47.42)
self._cas("-474200", -47.42)
def test_l_exposant_n_est_pas_ampute(self):
"""La branche scientifique rend « 3.24e+01 » : retirer le « e » en
faisait 3,2401, soit un facteur dix sur la borne de la colonne."""
self._cas("3.24e+01", 32.4)
self._cas("3.24e-01", 0.324)
def test_le_pourcentage_garde_son_echelle(self):
"""Sa branche coupe deux chiffres, pas quatre, et pose le point."""
self._cas("12.34%", 12.34)
class TestCoercition(unittest.TestCase):
"""Un champ CSV ou un attribut XML arrive sans type."""
def test_nombres_reconnus(self):
self.assertEqual(noyau.coercer_texte("4711"), 4711)
self.assertEqual(noyau.coercer_texte("-320"), -320)
self.assertEqual(noyau.coercer_texte("0.15"), 0.15)
self.assertEqual(noyau.coercer_texte("0"), 0)
def test_zeros_de_tete_restent_du_texte(self):
# « 007 » est un code, pas une quantité.
self.assertEqual(noyau.coercer_texte("007"), "007")
def test_ce_que_float_accepterait_a_tort(self):
for texte in ("1e5", "NaN", "inf", "-inf", "1,5", " 1 2 "):
self.assertEqual(noyau.coercer_texte(texte), texte)
def test_non_chaine_traverse(self):
self.assertEqual(noyau.coercer_texte(5), 5)
self.assertIsNone(noyau.coercer_texte(None))
class TestVivierEtMots(unittest.TestCase):
"""L'attribution est indexée, jamais tirée."""
def test_vivier_mesure(self):
# 1404 mots dans randomwordfr, 1366 après translittération des
# accents et rejet de ce qui n'est pas un mot simple.
self.assertEqual(len(VIVIER), 1366)
def test_vivier_sans_accent_ni_espace(self):
for mot in VIVIER:
self.assertRegex(mot, r"^[a-z_]+$")
def test_vivier_trie_et_dedoublonne(self):
# TRIÉ : l'ordre d'un set varie d'un processus à l'autre, et une
# table réutilisée rendrait d'autres mots pour les mêmes valeurs.
self.assertEqual(list(VIVIER), sorted(VIVIER))
self.assertEqual(len(VIVIER), len(set(VIVIER)))
def test_deux_appels_rendent_la_meme_sequence(self):
self.assertEqual(noyau.vivier_de_mots(), noyau.vivier_de_mots())
def test_meme_source_meme_mot(self):
table = noyau.Correspondance()
premier = noyau.nouveau_mot("Alpha", table, VIVIER)
self.assertEqual(noyau.nouveau_mot("Alpha", table, VIVIER), premier)
def test_valeurs_distinctes_rendent_mots_distincts(self):
"""La propriété que le tirage ne donnait pas.
Sur 20 mots tirés au hasard, six valeurs distinctes ont déjà 56 %
de chance d'en partager un, et deux clients qui partagent un mot
fusionnent en une seule clé.
"""
table = noyau.Correspondance()
sorties = {
noyau.nouveau_mot(f"valeur-{i}", table, VIVIER)
for i in range(len(VIVIER) + 1)
}
self.assertEqual(len(sorties), len(VIVIER) + 1)
def test_au_dela_du_vivier_ce_sont_des_PAIRES(self):
table = noyau.Correspondance()
for i in range(len(VIVIER) + 3):
dernier = noyau.nouveau_mot(f"v{i}", table, VIVIER)
self.assertIn("_", dernier)
self.assertFalse(dernier.startswith("mot_"))
gauche, droite = dernier.split("_", 1)
self.assertIn(gauche, VIVIER)
self.assertIn(droite, VIVIER)
def test_repli_a_vingt_mots_annonce(self):
capacites = formats.capabilities()
self.assertIn("mots", capacites)
class TestGraine(unittest.TestCase):
def test_meme_graine_meme_passage(self):
sorties = []
for _ in range(2):
rng = random.Random(42)
table = noyau.Correspondance()
sorties.append(
[
noyau.anonymise_cellule(v, _options(), table, rng)
for v in (10, -10, "Alpha", 3.5, "Beta", 77)
]
)
self.assertEqual(sorties[0], sorties[1])
class TestPortee(unittest.TestCase):
"""La portée se décide sur les coordonnées, jamais sur la valeur."""
def test_ligne_un_hors_portee_par_defaut(self):
self.assertFalse(noyau.cellule_en_portee("F", 1, 1, _options()))
self.assertTrue(
noyau.cellule_en_portee("F", 1, 1, _options(entetes=True))
)
def test_feuille_non_listee_hors_portee(self):
options = _options(feuilles=["Autre"])
self.assertFalse(noyau.cellule_en_portee("F", 2, 1, options))
self.assertTrue(noyau.cellule_en_portee("Autre", 2, 1, options))
def test_colonne_exclue_sur_toutes_ses_lignes(self):
options = _options(
etiquettes={("F", 2): "montant"},
colonnes_intactes={"montant"},
)
for ligne in (2, 3, 900):
self.assertFalse(noyau.cellule_en_portee("F", ligne, 2, options))
def test_colonne_par_index_en_repli(self):
options = _options(colonnes_intactes={"3"})
self.assertFalse(noyau.cellule_en_portee("F", 2, 3, options))
class TestPlancherStructurel(unittest.TestCase):
"""anonymize.py refuse par le NOM avant de lire une valeur."""
def test_id_nu(self):
self.assertTrue(noyau.colonne_plancher("id"))
self.assertTrue(noyau.colonne_plancher("ID"))
def test_suffixes_structurels_au_nom_seul(self):
"""« /id » et « /.id » ne portent JAMAIS de texte libre."""
for etiquette in ("partner_id/id", "partner_id/.id"):
self.assertTrue(noyau.colonne_plancher(etiquette), etiquette)
def test_suffixe_de_relation_exige_la_forme_d_une_CIBLE(self):
"""Un export import-compatible met un NOM dans « partner_id ».
Et un mot en minuscules — un login, un nom de service — n'est pas
la cible d'une relation : l'exiger seulement « identifiant »
recopiait « jtremblay » et « comptabilite » en clair.
"""
for etiquette in ("partner_id", "partner_ids"):
self.assertFalse(noyau.colonne_plancher(etiquette), etiquette)
self.assertFalse(
noyau.colonne_plancher(etiquette, forme_identifiant=True),
etiquette,
)
self.assertTrue(
noyau.colonne_plancher(etiquette, forme_relation=True),
etiquette,
)
def test_champs_structurels_au_nom_seul(self):
for etiquette in ("sequence", "active", "create_uid"):
self.assertTrue(noyau.colonne_plancher(etiquette), etiquette)
def test_une_selection_exige_AUSSI_peu_de_valeurs(self):
"""La forme seule accepte n'importe quel mot minuscule.
Une colonne de provinces, ou de créneaux nommés par des
personnes, passait pour une sélection sur ce seul fait.
"""
self.assertFalse(
noyau.colonne_plancher("state", forme_identifiant=True)
)
self.assertTrue(
noyau.colonne_plancher(
"state", forme_identifiant=True, selection=True
)
)
def test_une_etiquette_pointee_exige_la_preuve_d_un_external_id(self):
"""« key », « model », « res_model » portent un jeton pointé.
La forme d'un mot minuscule ne suffit pas : une colonne « Key » de
identifiants de personnes en minuscules partait en clair.
"""
for etiquette in ("key", "model", "res_model", "arch_db"):
self.assertFalse(
noyau.colonne_plancher(etiquette, forme_identifiant=True),
etiquette,
)
self.assertTrue(
noyau.colonne_plancher(etiquette, forme_relation=True),
etiquette,
)
def test_un_external_id_se_PROUVE(self):
"""Compter les préfixes communs ne tranchait pas.
Une équipe entière de logins partage son domaine, et une colonne à
une seule valeur n'a aucun préfixe à comparer. Ce qui prouve un
external ID est le NUMÉRO de son local, ou le module sentinelle de
l'export.
"""
for valeur in (
"base.res_partner_7",
"__export__.res_partner_42",
"__import__.sale_order_1",
):
self.assertTrue(noyau.valeur_forme_relation(valeur), valeur)
for valeur in (
"jean.tremblay",
"tremblay.jean",
"clinique.exemple.com",
"account.move",
):
self.assertFalse(noyau.valeur_forme_relation(valeur), valeur)
def test_une_liste_de_relations_prouve_chaque_membre(self):
self.assertTrue(noyau.valeur_forme_relation("base.tag_1,base.tag_2"))
self.assertFalse(
noyau.valeur_forme_relation("base.tag_1,jean.tremblay")
)
def test_display_name_n_est_JAMAIS_au_plancher(self):
"""Dans un fichier plat, cette colonne EST la donnée.
Le serveur la recalcule depuis `name`, ce qui la rend structurelle
dans une base ; un fichier ne recalcule rien. Aucune forme mesurée
ne doit la sauver.
"""
for forme in (False, True):
self.assertFalse(
noyau.colonne_plancher(
"display_name",
forme_identifiant=forme,
forme_relation=forme,
),
forme,
)
def test_forme_relation_est_plus_etroite(self):
"""Un login ou un nom de service n'est pas une cible de relation."""
for valeur in (7, 7.0, "", "42/7/", "base.res_partner_7"):
self.assertTrue(noyau.valeur_forme_relation(valeur), repr(valeur))
for valeur in ("jtremblay", "comptabilite", "Paie_Zeta.pdf"):
self.assertFalse(noyau.valeur_forme_relation(valeur), repr(valeur))
# Et le nom de fichier n'est pas non plus un identifiant.
self.assertFalse(noyau.valeur_forme_identifiant("Paie_Zeta.pdf"))
def test_forme_identifiant(self):
for valeur in (
7,
7.0,
"",
"base.res_partner_7",
"sale",
"1/2/",
"base.p1,base.p2",
):
self.assertTrue(
noyau.valeur_forme_identifiant(valeur), repr(valeur)
)
for valeur in (
"Boulangerie Tremblay inc.",
"Jean Tremblay",
"Quebec",
"Freightliner M2 106",
7.5,
):
self.assertFalse(
noyau.valeur_forme_identifiant(valeur), repr(valeur)
)
def test_une_colonne_ordinaire_passe(self):
for etiquette in ("montant", "nom", "identifiant_client", ""):
self.assertFalse(noyau.colonne_plancher(etiquette), etiquette)
def test_plancher_actif_avec_les_reponses_par_defaut(self):
"""Appuyer sur Entrée ne doit pas détruire partner_id/id."""
options = _options(etiquettes={("F", 1): "partner_id/id"})
self.assertFalse(noyau.cellule_en_portee("F", 2, 1, options))
class TestSerialisationHorsTableur(unittest.TestCase):
def test_dates_en_iso(self):
self.assertEqual(
noyau.valeur_hors_tableur(datetime.date(2020, 1, 2)),
"2020-01-02",
)
def test_none_reste_none(self):
self.assertIsNone(noyau.valeur_hors_tableur(None))
def test_objet_a_texte_rend_son_texte(self):
"""Un ArrayFormula ne définit pas __str__ : un csv.writer naïf
graverait son adresse mémoire dans le fichier."""
class FausseFormule:
text = "=SUM(A1:A2)"
self.assertEqual(
noyau.valeur_hors_tableur(FausseFormule()), "=SUM(A1:A2)"
)
def test_booleen_et_erreur_traversent(self):
self.assertIs(noyau.valeur_hors_tableur(True), True)
self.assertEqual(noyau.valeur_hors_tableur("#REF!"), "#REF!")
class TestFormatDeNombre(unittest.TestCase):
"""Le texte libre d'un format, séparé du motif qui l'entoure.
Excel porte du texte dans un format de quatre façons, dont une SANS
guillemets ni barre oblique, et traduit ses marques de position dans
la langue du classeur. Reconnaître le libellé sans abîmer le motif
n'a donc rien d'un test sur les guillemets : chaque forme ci-dessous
tranche un cas où l'une des deux moitiés a été perdue.
"""
@staticmethod
def _mot_si_long(interieur):
"""Le contrat de mot_si_long : None sous le seuil du filet."""
if len(interieur.strip()) < noyau.LONGUEUR_VERIFIABLE:
return None
return "MOT"
def _rendu(self, fmt):
return formats._parcourir_format(fmt, self._mot_si_long)
def test_motifs_traversent_intacts(self):
"""Un motif ne porte pas de donnée : le toucher abîme la copie.
Les formes en lettres de locale — « jj/mm/aaaa » en français,
« tt.mm.jjjj » en allemand, « gg/mm/aaaa » en italien — sont des
dates au même titre que « dd/mm/yyyy ». Celles sans séparateur
atteignent le seuil du filet à elles seules.
"""
for fmt in (
"0.00%",
"General",
"@",
"0.00E+00",
"# ??/??",
"dd/mm/yyyy hh:mm:ss",
"jj/mm/aaaa",
"tt.mm.jjjj",
"aaaa-mm-jj",
"gg/mm/aaaa",
"yyyymmdd",
"aaaammjj",
"hhmm",
"mmss",
"h:mm AM/PM",
"mmm-yy",
"jjjj jj mmmm aaaa",
"0.00_);[Red](0.00)",
"[Red]#,##0;-#,##0",
"[h]:mm:ss",
"[<=9999999]000-0000;000-000-0000",
"_-* #,##0.00_-;-* #,##0.00_-",
"0.00;;",
"\u00a5#,##0.00",
):
with self.subTest(fmt=fmt):
self.assertEqual(self._rendu(fmt), fmt)
def test_balise_de_locale_traverse_intacte(self):
"""Une section de devise dont TOUT est de convention.
Le seul LCID hexadécimal ne les couvre pas, et les remplacer
détruit le symbole monétaire de la copie ou la forme de ses
dates.
"""
for fmt in (
"[$-en-US]jj/mm/aaaa",
"[$-x-sysdate]",
"[$\u20ac-x-euro2]#,##0",
"[$-409]#,##0",
"[$R$-pt-BR]#,##0.00",
):
with self.subTest(fmt=fmt):
self.assertEqual(self._rendu(fmt), fmt)
def test_libelles_partent_par_leurs_quatre_ecritures(self):
"""Guillemets, barre oblique, section de devise, et texte nu."""
for fmt, libelle in (
('#,##0" aboulie"', "aboulie"),
("#,##0.00\\a\\b\\o\\u\\l", "aboul"),
("[$aboulie-409]#,##0", "aboulie"),
("#,##0 aboulie", "aboulie"),
):
with self.subTest(fmt=fmt):
rendu = self._rendu(fmt)
self.assertNotIn(libelle, rendu)
self.assertIn("MOT", rendu)
def test_le_motif_survit_au_libelle_nu(self):
"""Le remplacement porte sur le LIBELLÉ, pas sur toute la suite.
Un libellé nu se lit dans la même course de texte que le motif
qui le précède : remplacer la course entière rendait « "MOT" »
seul, et la copie perdait sa forme numérique.
"""
self.assertEqual(self._rendu("#,##0 aboulie"), '#,##0 "MOT"')
self.assertEqual(self._rendu("0.0 aboulie %"), '0.0 "MOT" %')
self.assertEqual(
self._rendu("#,##0 aboulie;-#,##0 aboulie"),
'#,##0 "MOT";-#,##0 "MOT"',
)
self.assertEqual(
self._rendu("aboulie jj/mm/aaaa aboulie"),
'"MOT" jj/mm/aaaa "MOT"',
)
self.assertEqual(
self._rendu("aboulie 0.00E+00 aboulie"),
'"MOT" 0.00E+00 "MOT"',
)
def test_mots_joints_par_un_espace_font_un_libelle(self):
"""« Nom du client » est UN libellé, pas trois mots à remplacer
un à un : chacun pris seul retombe sous le seuil du filet."""
self.assertEqual(self._rendu("#,##0 Nom du client"), '#,##0 "MOT"')
def test_mot_et_marque_se_distinguent_par_la_repetition(self):
"""Une marque vient par groupes d'une même lettre, un mot non."""
for marque in ("aaaa", "mm", "jjjj", "hhmm", "yyyymmdd", "General"):
with self.subTest(marque=marque):
self.assertFalse(formats._est_un_mot(marque))
for mot in ("aboulie", "Nom", "client", "Total", "ZQXNU"):
with self.subTest(mot=mot):
self.assertTrue(formats._est_un_mot(mot))
def test_les_jetons_entre_crochets_traversent_intacts(self):
"""La grammaire d'Excel ÉNUMÈRE ce qu'un crochet peut porter.
C'est une spécification, non une devinette : l'énumération est
légitime ici là où l'alphabet des marques de date a été rejeté.
Chaque forme documentée est reprise, car en oublier une casse le
code de format de la copie.
"""
for fmt in (
"[Red]#,##0",
"[Green]0.00",
"[Black]0",
"[White]0",
"[Blue]0",
"[Cyan]0",
"[Magenta]0",
"[Yellow]0",
"[Color12]0",
"[COLOR 3]0",
"[color56]0",
"[DBNum1]0",
"[NatNum12]0",
"[h]:mm",
"[mm]:ss",
"[<100]0;[>=100]0.0",
"[<>1]0",
"[ENG]jj/mm/aaaa",
"[THAI]0",
"[t]0",
):
with self.subTest(fmt=fmt):
self.assertEqual(self._rendu(fmt), fmt)
def test_une_section_hors_grammaire_porte_du_texte_libre(self):
"""Excel n'écrit pas une telle section, un producteur tiers si.
Le remplacement reste NU : un crochet ne porte pas de guillemets,
et la section était déjà hors grammaire avant qu'on y touche.
"""
self.assertEqual(self._rendu("[aboulie]#,##0"), "[MOT]#,##0")
self.assertEqual(self._rendu("[Nom du client]0.00"), "[MOT]0.00")
def test_un_crochet_non_ferme_ne_boucle_pas(self):
"""`find` rend -1, et l'index repartait à zéro."""
self.assertEqual(self._rendu("#,##0[aboulie"), "#,##0[aboulie")
def test_libelle_de_devise_non_conventionnel_reste_un_libelle(self):
"""« [$Cabinet-Lav] » ressemble à une balise de locale et n'en
est pas : son texte est libre, et un nom y tient."""
rendu = self._rendu("[$aboulie-Lav]#,##0")
self.assertNotIn("aboulie", rendu)
class TestNomDeFichier(unittest.TestCase):
def test_separateur_remplace(self):
pris = set()
self.assertNotIn("/", noyau.nom_de_fichier_sur("Ventes/2024", pris))
def test_nom_vide_numerote(self):
pris = set()
self.assertTrue(
noyau.nom_de_fichier_sur("///", pris).startswith("feuille_")
)
def test_deux_noms_reduits_au_meme_se_distinguent(self):
pris = set()
premier = noyau.nom_de_fichier_sur("a/b", pris)
second = noyau.nom_de_fichier_sur("a:b", pris)
self.assertNotEqual(premier, second)
class TestPorteEntree(unittest.TestCase):
"""Refuser avant qu'une bibliothèque rapporte une exception."""
def setUp(self):
self.base = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.base, True)
def _fichier(self, nom, octets=b"x"):
chemin = os.path.join(self.base, nom)
with open(chemin, "wb") as fh:
fh.write(octets)
return chemin
def test_repertoire_refuse(self):
self.assertEqual(noyau.verifier_source(self.base), "pas_un_fichier")
def test_absent_refuse(self):
self.assertEqual(
noyau.verifier_source(os.path.join(self.base, "rien")),
"pas_un_fichier",
)
def test_taille_nulle_refusee(self):
self.assertEqual(
noyau.verifier_source(self._fichier("vide.csv", b"")), "vide"
)
def test_fichier_ordinaire_accepte(self):
self.assertIsNone(
noyau.verifier_source(self._fichier("bon.csv", b"a,b\n"))
)
def test_detect_format_par_les_octets(self):
ole2 = self._fichier(
"menteur.xlsx", b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1rest"
)
# Un OLE2 sous une extension OOXML : protégé ou non conforme,
# jamais passé à openpyxl, qui rendrait le même BadZipFile pour un
# classeur chiffré et pour un fichier corrompu.
self.assertEqual(noyau.detect_format(ole2), "protege")
vrai_xls = self._fichier(
"vieux.xls", b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1rest"
)
self.assertEqual(noyau.detect_format(vrai_xls), "xls")
def test_divergence_signalee(self):
html = self._fichier("export.xls", b"<html><body>t</body></html>")
self.assertTrue(
noyau.format_divergent(html, noyau.detect_format(html))
)
def test_has_macros_ne_leve_sur_rien(self):
""".xlsb passe par cette fonction et par elle seule."""
for nom, octets in (
("ole.xlsb", b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"),
("tronque.xlsx", b"PK\x03\x04tronque"),
("texte.xlsx", b"pas un zip"),
):
self.assertFalse(noyau.has_macros(self._fichier(nom, octets)), nom)
def test_has_macros_vrai_sur_un_zip_qui_en_porte(self):
chemin = os.path.join(self.base, "avec.xlsm")
with zipfile.ZipFile(chemin, "w") as z:
z.writestr("xl/vbaProject.bin", b"\x00")
self.assertTrue(noyau.has_macros(chemin))
class TestBoutEnBoutStdlib(unittest.TestCase):
"""CSV, JSON et XML : aucun openpyxl, donc lançables partout."""
def setUp(self):
self.base = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.base, True)
def _ecrire(self, nom, contenu):
chemin = os.path.join(self.base, nom)
with open(chemin, "w", encoding="utf-8") as fh:
fh.write(contenu)
return chemin
def test_csv_bout_en_bout(self):
source = self._ecrire(
"clients.csv",
"id,nom,montant,taux\n1,Alpha,4711,0.15\n"
"2,Beta,-320,0.20\n3,Alpha,4711,0.15\n",
)
sortie = os.path.join(self.base, "out.csv")
bilan = formats.ecrire(source, sortie, {"graine": "7"})
lignes = [
l.split(",")
for l in open(sortie, encoding="utf-8").read().splitlines()
]
# L'en-tête est intact par défaut.
self.assertEqual(lignes[0], ["id", "nom", "montant", "taux"])
# Le plancher protège « id ».
self.assertEqual([l[0] for l in lignes[1:]], ["1", "2", "3"])
# Le même client rend le même mot dans les deux lignes.
self.assertEqual(lignes[1][1], lignes[3][1])
self.assertNotEqual(lignes[1][1], lignes[2][1])
# AUCUNE valeur texte de la source ne survit. Sans cette
# assertion, un graveur inerte garde toutes les précédentes :
# elles sont vraies des valeurs SOURCE aussi.
texte = open(sortie, encoding="utf-8").read()
self.assertNotIn("Alpha", texte)
self.assertNotIn("Beta", texte)
# Un montant reste un nombre, du même signe.
self.assertGreater(float(lignes[1][2]), 0)
self.assertLess(float(lignes[2][2]), 0)
# Le taux reste dans l'étendue mesurée de sa colonne.
for ligne in lignes[1:]:
self.assertLessEqual(float(ligne[3]), 0.20)
self.assertGreaterEqual(float(ligne[3]), 0.15)
self.assertGreater(bilan["remplacees"], 0)
def test_csv_la_source_n_est_pas_modifiee(self):
source = self._ecrire("s.csv", "a,b\n1,Alpha\n")
avant = (os.path.getmtime(source), os.path.getsize(source))
formats.ecrire(
source, os.path.join(self.base, "o.csv"), {"graine": "1"}
)
self.assertEqual(
avant, (os.path.getmtime(source), os.path.getsize(source))
)
def test_csv_une_seule_colonne_ne_leve_pas(self):
"""csv.Sniffer lève sur cette forme, la plus courante ici."""
source = self._ecrire("noms.csv", "nom\nAlpha\nBeta\nGamma\n")
rapport = formats.report(source)
self.assertEqual(rapport["delimiteur"], ",")
self.assertEqual(rapport["delimiteur_source"], "repli")
def test_csv_dents_de_scie_ne_leve_pas(self):
source = self._ecrire("scie.csv", "a,b,c\n1,2\n3,4,5,6\n")
rapport = formats.report(source)
self.assertTrue(rapport["feuilles"])
def test_csv_encodage_sans_chardet(self):
chemin = os.path.join(self.base, "bom.csv")
with open(chemin, "wb") as fh:
fh.write("a,b\n1,Alpha\n".encode("utf-8"))
rapport = formats.report(chemin)
self.assertEqual(rapport["encodage_source"], "bom")
self.assertEqual(rapport["encodage"], "utf-8-sig")
def test_json_cles_preservees_valeurs_anonymisees(self):
source = self._ecrire(
"d.json",
json.dumps(
[
{"id": 1, "nom": "Alpha", "montant": 4711},
{"id": 2, "nom": "Beta", "montant": -320},
]
),
)
sortie = os.path.join(self.base, "o.json")
formats.ecrire(source, sortie, {"graine": "3"})
arbre = json.load(open(sortie, encoding="utf-8"))
self.assertEqual(sorted(arbre[0].keys()), ["id", "montant", "nom"])
self.assertNotEqual(arbre[0]["nom"], "Alpha")
self.assertGreater(arbre[0]["montant"], 0)
self.assertLess(arbre[1]["montant"], 0)
def test_xml_texte_et_attributs_par_la_meme_table(self):
source = self._ecrire(
"d.xml",
'<racine><ligne ref="Alpha"><nom>Alpha</nom>'
"<n>4711</n></ligne></racine>",
)
sortie = os.path.join(self.base, "o.xml")
formats.ecrire(source, sortie, {"graine": "5"})
import xml.etree.ElementTree as ET
racine = ET.parse(sortie).getroot()
ligne = racine.find("ligne")
# Balises et noms d'attribut sont de la structure et restent.
self.assertEqual(racine.tag, "racine")
self.assertIn("ref", ligne.attrib)
# Le MÊME identifiant en attribut et en texte rend le même mot,
# sinon la jointure entre les deux casse.
self.assertEqual(ligne.attrib["ref"], ligne.find("nom").text)
self.assertNotEqual(ligne.attrib["ref"], "Alpha")
def test_plan_n_ecrit_rien(self):
source = self._ecrire("p.csv", "a,b\n1,Alpha\n")
avant = (os.path.getmtime(source), os.path.getsize(source))
apercu = formats.plan(source, {"graine": "1"})
self.assertEqual(
avant, (os.path.getmtime(source), os.path.getsize(source))
)
self.assertIn("remplacees", apercu)
self.assertIn("colonnes_ecartees", apercu)
def test_destination_egale_source_refusee(self):
source = self._ecrire("s.csv", "a,b\n1,Alpha\n")
avant = open(source, encoding="utf-8").read()
with self.assertRaises(formats.ErreurMoteur) as capture:
formats.ecrire(source, source, {"graine": "1"})
self.assertEqual(capture.exception.cle, "destination_source")
self.assertEqual(open(source, encoding="utf-8").read(), avant)
def test_destination_lien_vers_la_source_refusee(self):
source = self._ecrire("s.csv", "a,b\n1,Alpha\n")
lien = os.path.join(self.base, "lien.csv")
os.symlink(source, lien)
with self.assertRaises(formats.ErreurMoteur) as capture:
formats.ecrire(source, lien, {"graine": "1"})
self.assertEqual(capture.exception.cle, "destination_source")
def test_feuille_inconnue_refusee(self):
source = self._ecrire("s.csv", "a,b\n1,Alpha\n")
with self.assertRaises(formats.ErreurMoteur) as capture:
formats.ecrire(
source,
os.path.join(self.base, "o.csv"),
{"feuilles": ["Introuvable"]},
)
self.assertEqual(capture.exception.cle, "aucune_feuille")
def test_aucun_temporaire_ne_subsiste_apres_un_echec(self):
cible = os.path.join(self.base, "sortie", "o.csv")
def graveur_qui_echoue(_chemin):
raise OSError("disque plein")
with self.assertRaises(OSError):
formats._ecrire_atomique(cible, graveur_qui_echoue)
restes = [
n
for n in os.listdir(os.path.dirname(cible))
if n.startswith(".transform-")
]
self.assertEqual(restes, [])
def test_repertoire_de_destination_non_vide_refuse(self):
plein = os.path.join(self.base, "plein")
os.makedirs(plein)
open(os.path.join(plein, "deja"), "w").close()
with self.assertRaises(formats.ErreurMoteur) as capture:
formats._preparer_repertoire(plein)
self.assertEqual(capture.exception.cle, "repertoire_non_vide")
def test_table_portable_entre_deux_fichiers(self):
"""Le même client doit rendre le même mot dans tout un lot.
L'attribution étant indexée par ordre de première rencontre, deux
fichiers listant les mêmes clients dans un ORDRE DIFFÉRENT leur
donneraient des mots différents sans table partagée.
"""
premier = self._ecrire("a.csv", "nom\nAlpha\nBeta\n")
second = self._ecrire("b.csv", "nom\nBeta\nAlpha\n")
table = os.path.join(self.base, "t.json")
formats.ecrire(
premier,
os.path.join(self.base, "a.out.csv"),
{"table_chemin": table},
)
formats.ecrire(
second,
os.path.join(self.base, "b.out.csv"),
{"table_chemin": table},
)
lus = {}
for nom, fichier in (
("a", "a.out.csv"),
("b", "b.out.csv"),
):
lignes = (
open(os.path.join(self.base, fichier), encoding="utf-8")
.read()
.splitlines()[1:]
)
lus[nom] = lignes
# a.csv liste Alpha puis Beta ; b.csv l'inverse.
self.assertEqual(lus["a"][0], lus["b"][1])
self.assertEqual(lus["a"][1], lus["b"][0])
def test_la_table_est_en_0600(self):
source = self._ecrire("s.csv", "nom\nAlpha\n")
table = os.path.join(self.base, "t.json")
formats.ecrire(
source,
os.path.join(self.base, "o.csv"),
{"table_chemin": table},
)
self.assertEqual(os.stat(table).st_mode & 0o777, 0o600)
def test_conversion_csv_vers_json(self):
source = self._ecrire("s.csv", "nom,n\nAlpha,5\n")
sortie = os.path.join(self.base, "o.json")
formats.ecrire(source, sortie, {"conversion": "json", "graine": "1"})
# `assertTrue(arbre)` passait sur une recopie verbatim.
self.assertNotIn("Alpha", open(sortie, encoding="utf-8").read())
def test_aucune_ligne_ne_se_perd_a_la_conversion(self):
"""Le compte de « remplacées » et le compte d'enregistrements
doivent parler du même fichier.
La mesure ne place jamais l'en-tête sous une ligne de données ;
l'opérateur, lui, peut le DÉSIGNER, et sa réponse ne se remesure
pas. La ligne 1 est alors hors de l'empan, et partir de la
dernière ligne d'en-tête la faisait disparaître, après l'avoir
anonymisée et comptée.
"""
source = self._ecrire(
"d.csv",
"Bruant,599\n"
"N° ville,N° magasin\n"
"Aubel,501\n"
"Aubel,502\n"
"Aubel,503\n"
"Aubel,504\n",
)
sortie = os.path.join(self.base, "d.json")
formats.ecrire(
source,
sortie,
{
"conversion": "json",
"graine": "1",
"entetes_par_feuille": {formats.NOM_FEUILLE_NEUTRE: [2]},
},
)
arbre = json.load(open(sortie, encoding="utf-8"))
(enregistrements,) = arbre.values()
self.assertEqual(len(enregistrements), 5)
def test_le_nom_du_fichier_source_ne_sort_pas(self):
"""Le dialogue promet que le nom du fichier n'est pas anonymisé.
Le recracher comme clé de premier niveau du JSON injectait dans la
copie un identifiant qui n'était même pas dans la grille.
"""
source = self._ecrire("Client_Tremblay_2024.csv", "nom\nAlpha\n")
sortie = os.path.join(self.base, "o.json")
formats.ecrire(source, sortie, {"conversion": "json", "graine": "1"})
self.assertNotIn(
"Client_Tremblay", open(sortie, encoding="utf-8").read()
)
def test_json_les_cles_sont_de_la_structure_et_sont_dites(self):
source = self._ecrire(
"k.json", '{"Alpha": {"responsable": "Beta", "solde": 1200}}'
)
apercu = formats.plan(source, {"graine": "3"})
for exemple in apercu["apercu"]:
self.assertNotEqual(exemple["avant"], "Alpha")
self.assertIn(
"Object keys are kept as structure; they may identify.",
apercu["avertissements"],
)
def test_xml_la_queue_est_anonymisee(self):
"""Un export d'ERP nommé « .xls » qui est du HTML arrive ici.
La moitié d'une cellule vit dans la QUEUE d'un élément : « Client
<b>X</b> Nom » porte « Nom » après la balise fermante.
"""
source = self._ecrire(
"h.xml", "<t><td>Client <b>ABC</b> Alpha</td></t>"
)
sortie = os.path.join(self.base, "o.xml")
formats.ecrire(source, sortie, {"graine": "3"})
rendu = open(sortie, encoding="utf-8").read()
self.assertNotIn("Alpha", rendu)
# L'espace d'encadrement survit, sinon deux mots se collent.
self.assertRegex(rendu, r"\w <b>")
def test_la_portee_gouverne_aussi_le_json(self):
"""L'écriture passait par un SECOND parcours de l'arbre.
Il ignorait le plancher et les colonnes intactes, au point de
détruire l'external ID que le plancher venait de protéger.
"""
source = self._ecrire(
"r.json",
json.dumps(
[
{
"id": 7,
"partner_id/id": "base.p7",
"nom": "Alpha",
"ville": "Beta",
}
]
),
)
sortie = os.path.join(self.base, "o.json")
formats.ecrire(
source, sortie, {"graine": "3", "colonnes_intactes": ["nom"]}
)
arbre = json.load(open(sortie, encoding="utf-8"))
self.assertEqual(arbre[0]["id"], 7)
self.assertEqual(arbre[0]["partner_id/id"], "base.p7")
self.assertEqual(arbre[0]["nom"], "Alpha")
# Et la colonne qui EST en portée bouge, sinon le test passerait
# sur une recopie verbatim de tout le fichier.
self.assertNotEqual(arbre[0]["ville"], "Beta")
def test_un_csv_SANS_en_tete_voit_sa_ligne_1_anonymisee(self):
"""C'était la fuite : la ligne 1 était PRÉSUMÉE d'en-tête, donc
recopiée en clair, alors qu'elle porte un enregistrement complet.
La mesure conclut qu'il n'y a pas d'en-tête, et la ligne entre en
portée. Rien n'est alors gardé, donc rien n'est à nommer — c'est
ce que l'ancien avertissement compensait de son mieux.
"""
source = self._ecrire("sans.csv", "aboulie,1200\nacai,830\n")
apercu = formats.plan(source, {"graine": "3"})
self.assertEqual(apercu["entete_gardee"], [])
avant = [c["avant"] for c in apercu["apercu"]]
self.assertIn("aboulie", avant)
def test_un_csv_AVEC_en_tete_le_garde_et_le_nomme(self):
"""L'autre sens : la mesure ne doit pas anonymiser un vrai
en-tête, qui rendrait la copie illisible.
La grille porte des types MÊLÉS, comme un export réel : c'est le
régime où la mesure tranche. Une grille étroite et tout-
alphabétique ne se décide pas, et le test suivant le dit.
"""
source = self._ecrire(
"avec.csv",
"etiquette,montant,date,code\n"
"aboulie,1200,2019-01-02,A1\n"
"acai,830,2019-01-03,B2\n"
"adobe,940,2019-01-04,C3\n",
)
apercu = formats.plan(source, {"graine": "3"})
valeurs = [c["valeur"] for c in apercu["entete_gardee"]]
self.assertIn("etiquette", valeurs)
def test_au_dela_du_plafond_ce_qui_n_est_pas_liste_est_COMPTE(self):
"""Le plafond borne la liste, jamais le compte.
Le taire faisait consentir sur un extrait pris pour le tout :
douze cellules montrées d'un empan qui en garde quinze, et les
trois autres partaient en clair sans que rien ne le dise.
"""
largeur = formats.PLAFOND_GARDEES_LISTEES + 3
source = self._ecrire(
"plafond.csv",
",".join("Champ%02d" % i for i in range(largeur))
+ "\n"
+ "".join(
",".join(str(100 + rang * largeur + i) for i in range(largeur))
+ "\n"
for rang in range(6)
),
)
apercu = formats.plan(source, {"graine": "3"})
self.assertEqual(
len(apercu["entete_gardee"]), formats.PLAFOND_GARDEES_LISTEES
)
self.assertEqual(
apercu["entete_gardee_omises"], {formats.NOM_FEUILLE_NEUTRE: 3}
)
def test_en_deca_du_plafond_rien_n_est_annonce_comme_omis(self):
source = self._ecrire(
"court.csv",
"etiquette,montant\naboulie,1200\nacai,830\nadobe,940\n",
)
apercu = formats.plan(source, {"graine": "3"})
self.assertEqual(apercu["entete_gardee_omises"], {})
def test_une_grille_tout_alphabetique_ne_se_decide_pas(self):
"""La limite, ÉNONCÉE plutôt que masquée par un seuil ajusté.
Rien de structurel ne sépare « nom » de « aboulie » : seul un
vocabulaire le ferait, et une liste de noms de champs connus est
une classe ouverte. Le verdict est donc « pas d'en-tête », ce qui
ANONYMISE la ligne — le côté sur lequel pencher — et l'opérateur
corrige.
"""
source = self._ecrire(
"mots.csv",
"nom,ville\naboulie,acai\nacanthe,acai\nadelphique,acai\n",
)
apercu = formats.plan(source, {"graine": "3"})
self.assertEqual(apercu["entete_gardee"], [])
def test_la_table_ne_peut_pas_ecraser_la_source(self):
source = self._ecrire("t.json", '{"client": "Alpha"}')
avant = open(source, encoding="utf-8").read()
with self.assertRaises(formats.ErreurMoteur) as capture:
formats.ecrire(
source,
os.path.join(self.base, "o.json"),
{"table_chemin": source},
)
self.assertEqual(capture.exception.cle, "table_source")
self.assertEqual(open(source, encoding="utf-8").read(), avant)
def test_la_table_ne_peut_pas_remplacer_la_copie(self):
source = self._ecrire("s.csv", "nom\nAlpha\n")
sortie = os.path.join(self.base, "o.csv")
with self.assertRaises(formats.ErreurMoteur) as capture:
formats.ecrire(source, sortie, {"table_chemin": sortie})
self.assertEqual(capture.exception.cle, "table_source")
self.assertFalse(os.path.exists(sortie))
def test_la_table_existante_repasse_en_0600(self):
"""`os.open` n'applique son mode QU'À la création.
Une table arrivée en 0644 par un clone, un `cp` ou un `tar -x` le
resterait — et c'est le cas normal du flux prévu.
"""
source = self._ecrire("s.csv", "nom\nAlpha\n")
table = os.path.join(self.base, "t.json")
with open(table, "w", encoding="utf-8") as fh:
fh.write("{}")
os.chmod(table, 0o644)
formats.ecrire(
source,
os.path.join(self.base, "o.csv"),
{"table_chemin": table},
)
self.assertEqual(os.stat(table).st_mode & 0o777, 0o600)
def test_le_plancher_ne_recopie_plus_les_noms(self):
"""La fuite qui a motivé tout ce bloc.
Cinq colonnes sur six d'un export import-compatible étaient
recopiées mot pour mot, et l'écran l'annonçait comme une
protection.
"""
source = self._ecrire(
"export.csv",
"id,partner_id,user_id,display_name,state,montant\n"
"7,Alpha,Beta,Gamma,Delta,1200.50\n",
)
sortie = os.path.join(self.base, "o.csv")
formats.ecrire(source, sortie, {"graine": "3"})
rendu = open(sortie, encoding="utf-8").read()
for valeur in ("Alpha", "Beta", "Gamma", "Delta"):
self.assertNotIn(valeur, rendu, valeur)
# « id » reste, lui : son contenu a la forme d'un identifiant.
self.assertIn("7,", rendu)
def _cles_du_menu(traduites_seulement=True):
"""Les clés littérales passées à `t()` dans le module du menu."""
import ast
chemin = os.path.join(
os.path.dirname(__file__),
"..",
"script",
"todo",
"transform_menu.py",
)
arbre = ast.parse(open(chemin, encoding="utf-8").read())
cles = []
for noeud in ast.walk(arbre):
if (
isinstance(noeud, ast.Call)
and isinstance(noeud.func, ast.Name)
and noeud.func.id == "t"
and noeud.args
and isinstance(noeud.args[0], ast.Constant)
and isinstance(noeud.args[0].value, str)
):
valeur = noeud.args[0].value
if valeur in cles:
continue
if traduites_seulement and valeur not in (todo_i18n.TRANSLATIONS):
continue
cles.append(valeur)
return cles
class TestConseilDuRefus(unittest.TestCase):
"""Un refus dont l'opérateur ne devinerait pas le remède.
Une macro cite couramment la valeur d'une cellule, et le projet VBA est
recopié tel quel : garder les macros rend alors la copie irrecevable au
filet. Le refus est juste — la valeur sortirait en clair — mais le
détail nomme une partie du format sans dire quoi en faire, alors que
l'option qui le cause vient d'être choisie un écran plus tôt.
"""
def test_le_conseil_est_une_cle_a_part(self):
"""Mêler le remède au détail les rendait intraduisibles tous les
deux : le détail porte un chemin, le conseil une phrase."""
exc = formats.ErreurMoteur("fuite_detectee", "1 — x", "conseil")
self.assertEqual(exc.detail, "1 — x")
self.assertEqual(exc.conseil, "conseil")
def test_sans_conseil_l_attribut_existe_quand_meme(self):
"""Le CLI le lit sur toute erreur, pas sur celles qui en ont un."""
self.assertEqual(formats.ErreurMoteur("format_inconnu").conseil, "")
def test_le_conseil_est_traduit_dans_les_deux_langues(self):
avis = (
"The kept VBA project quotes a source value;"
" answer no to the macro question to write the copy."
)
self.assertIn(avis, todo_i18n.TRANSLATIONS)
for langue in ("fr", "en"):
with self.subTest(langue=langue):
self.assertTrue(todo_i18n.TRANSLATIONS[avis][langue].strip())
class TestPolices(unittest.TestCase):
"""Aucune forme ne sépare une fonte courante d'une fonte de marque.
Le renommage porte donc sur toutes celles que la liste ne nomme pas, et
l'erreur penche du côté du dégât cosmétique. La liste est le confort de
la copie, jamais la propriété de sûreté.
"""
def test_les_courantes_gardent_leur_nom(self):
for nom in (
"Calibri",
"Liberation Sans",
"DejaVu Sans",
"Consolas",
"Georgia",
"Wingdings",
"Noto Sans",
):
with self.subTest(nom=nom):
self.assertIn(nom, formats.POLICES_COURANTES)
def test_une_fonte_inconnue_est_renommee(self):
self.assertNotIn("Aboulie Sans", formats.POLICES_COURANTES)
class TestAvertissementEncodage(unittest.TestCase):
"""Le délimiteur de la source est repris, son encodage NON.
Le rapport annonce l'encodage détecté, ce qui laissait croire que la
copie le garde. Elle sort en UTF-8 : un mot du vivier ou un en-tête
gardé peut ne pas s'encoder dans le jeu d'origine, et l'écriture
échouerait APRÈS la question du consentement.
"""
AVIS = "The copy is written in UTF-8, whatever the source was."
def test_un_encodage_autre_est_annonce(self):
for encodage in ("cp1252", "latin-1", "ISO-8859-15"):
with self.subTest(encodage=encodage):
dits = formats._avertissements(
{"format": "csv", "encodage": encodage}, {}
)
self.assertIn(self.AVIS, dits)
def test_utf8_ne_dit_rien(self):
"""Rien ne change, donc rien à dire : un avis sans objet use
l'attention qu'il faudra ailleurs."""
for encodage in ("utf-8", "UTF_8", "ascii", None, ""):
with self.subTest(encodage=encodage):
dits = formats._avertissements(
{"format": "csv", "encodage": encodage}, {}
)
self.assertNotIn(self.AVIS, dits)
class TestFichiersPrevus(unittest.TestCase):
"""Les chemins annoncés doivent être ceux qui seront écrits.
Ce n'est pas qu'un affichage : cette liste est ce que le contrôle
d'écrasement et celui de la table de correspondance examinent. Prédite
d'après le nom d'ORIGINE d'une feuille, elle annonçait des chemins qui
n'existeraient jamais, montrait le nom du client à l'écran, et laissait
les fichiers réels échapper aux deux contrôles.
"""
def setUp(self):
self.feuilles = [
formats.Feuille("Cabinet Lavigne", [["c"], ["x"]]),
formats.Feuille("Fournisseurs 2024", [["c"], ["y"]]),
]
self.options = {
"vivier": VIVIER,
"feuilles": [],
"destination": "/tmp/sortie",
"conversion": "csv",
}
def test_les_chemins_portent_le_nom_ANONYMISE(self):
table = noyau.Correspondance()
prevus = formats._fichiers_prevus(
"s.xlsx", self.feuilles, self.options, table
)
self.assertEqual(len(prevus), 2)
for chemin in prevus:
self.assertNotIn("Lavigne", chemin)
self.assertNotIn("Fournisseurs", chemin)
def test_la_prediction_fige_ce_que_la_conversion_retrouvera(self):
"""La table est une correspondance stable : le nom réservé par la
prédiction est celui que la conversion lira ensuite."""
table = noyau.Correspondance()
prevus = formats._fichiers_prevus(
"s.xlsx", self.feuilles, self.options, table
)
attendus = [
"%s.csv" % formats._nom_de_feuille_anonyme(f, table, self.options)
for f in self.feuilles
]
self.assertEqual([os.path.basename(c) for c in prevus], attendus)
def test_sans_table_le_nom_d_origine_reste(self):
"""Rien n'est anonymisé quand rien ne l'est : la prédiction ne
doit pas inventer un nom que l'écriture ne produira pas."""
prevus = formats._fichiers_prevus(
"s.xlsx", self.feuilles, self.options, None
)
self.assertIn(
"Cabinet_Lavigne.csv", [os.path.basename(c) for c in prevus]
)
def test_une_seule_feuille_garde_la_destination_telle_quelle(self):
prevus = formats._fichiers_prevus(
"s.csv", self.feuilles[:1], self.options, noyau.Correspondance()
)
self.assertEqual(prevus, ["/tmp/sortie"])
class TestClesDistinctesEnJson(unittest.TestCase):
"""Un objet JSON écrase la clé qu'il répète, un tableur non.
L'en-tête d'un tableur n'est qu'une ligne : rien ne l'empêche de
porter deux fois « montant », ni de laisser deux colonnes sans titre.
Rendu tel quel en clés d'objet, cela perdait des colonnes ENTIÈRES
dans la copie, sans qu'une ligne du rapport ne le dise.
"""
def test_etiquettes_repetees_se_distinguent(self):
self.assertEqual(
formats._cles_distinctes(["montant", "montant", "montant"]),
["montant", "montant_2", "montant_3"],
)
def test_etiquette_vide_n_est_pas_none(self):
"""La chaîne vide et l'espace ne passaient pas par le repli."""
self.assertEqual(
formats._cles_distinctes([None, "", " ", "x"]),
["c1", "c2", "c3", "x"],
)
def test_l_etiquette_garde_ses_espaces(self):
"""Le test porte sur l'étiquette dépouillée, la clé la garde
telle quelle : la dépouiller altérerait la copie en silence."""
self.assertEqual(formats._cles_distinctes([" Nom "]), [" Nom "])
def test_le_repli_ne_collisionne_pas_avec_une_etiquette(self):
"""Une colonne littéralement intitulée « c2 » existe."""
self.assertEqual(
formats._cles_distinctes(["c2", None]), ["c2", "c2_2"]
)
def test_autant_de_cles_que_de_colonnes(self):
for etiquettes in (
["a", "a", None, "", "a"],
[None] * 5,
["x"],
[],
):
with self.subTest(etiquettes=etiquettes):
cles = formats._cles_distinctes(etiquettes)
self.assertEqual(len(cles), len(etiquettes))
self.assertEqual(len(set(cles)), len(etiquettes))
class TestConversion(unittest.TestCase):
"""Les cibles de conversion, qu'aucun test n'exerçait.
C'est ce trou qui a laissé passer, tour à tour : le nom du fichier
source recraché comme clé de premier niveau, une date rendue en texte
ISO dans un classeur, et un nom de balise XML illégal écrit sans
broncher puis annoncé comme écrit.
"""
def setUp(self):
self.base = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.base, True)
def _csv(self, nom, contenu):
chemin = os.path.join(self.base, nom)
with open(chemin, "w", encoding="utf-8") as fh:
fh.write(contenu)
return chemin
def test_xml_etiquette_qui_commence_par_un_chiffre(self):
"""XML interdit à un nom d'élément de commencer par un chiffre.
« 2024 » produisait `<2024>`, écrit, annoncé comme écrit, et refusé
par tout analyseur.
"""
import xml.etree.ElementTree as ET
source = self._csv("mois.csv", "2024,nom\n1200,Alpha\n")
sortie = os.path.join(self.base, "o.xml")
formats.ecrire(source, sortie, {"conversion": "xml", "graine": "3"})
# La seule assertion qui compte : le fichier se relit.
ET.parse(sortie)
def test_nom_de_balise_sur(self):
self.assertEqual(formats._nom_de_balise_sur("nom", 1), "nom")
self.assertTrue(formats._nom_de_balise_sur("2024", 1)[0].isalpha())
self.assertEqual(formats._nom_de_balise_sur("", 3), "c3")
self.assertEqual(formats._nom_de_balise_sur("a b", 1), "a_b")
def test_un_fichier_par_feuille_au_dela_d_une_seule(self):
self.assertFalse(
formats._un_fichier_par_feuille(
"csv", [formats.Feuille("a", [["x"]])], {"feuilles": None}
)
)
self.assertTrue(
formats._un_fichier_par_feuille(
"csv",
[
formats.Feuille("a", [["x"]]),
formats.Feuille("b", [["y"]]),
],
{"feuilles": None},
)
)
def test_repertoire_un_fichier_par_feuille(self):
feuilles = [
formats.Feuille("Ventes", [["nom"], ["Alpha"]]),
formats.Feuille("Achats", [["nom"], ["Beta"]]),
]
cible = os.path.join(self.base, "lot")
ecrits = formats._convertir_vers_repertoire(cible, feuilles, {})
self.assertEqual(len(ecrits), 2)
self.assertEqual(os.stat(cible).st_mode & 0o777, 0o700)
def test_conversion_vers_xml_refuse_plusieurs_feuilles(self):
feuilles = [
formats.Feuille("a", [["nom"], ["Alpha"]]),
formats.Feuille("b", [["nom"], ["Beta"]]),
]
with self.assertRaises(formats.ErreurMoteur):
formats._convertir_vers_xml(
os.path.join(self.base, "o.xml"), feuilles
)
def test_le_delimiteur_detecte_survit_a_l_ecriture(self):
"""Il servait à LIRE et pas à écrire.
Un fichier à point-virgule revenait en virgule, et le tableur du
destinataire le rendait en une seule colonne — après que l'écran
avait imprimé « délimiteur détecté : ; ».
"""
source = self._csv("pv.csv", "nom;ville\nAlpha;Beta\n")
sortie = os.path.join(self.base, "o.csv")
formats.ecrire(source, sortie, {"graine": "3"})
rendu = open(sortie, encoding="utf-8").read()
self.assertIn(";", rendu)
self.assertNotIn(",", rendu)
def test_une_cible_impossible_refuse_avant_l_APERCU(self):
"""Le refus venait du graveur, après le consentement.
Et sous une clé qui accusait le format au lieu de nommer la
contrainte. `_verifier_conversion` est appelée par `_preparer`,
donc par `plan` comme par `ecrire` : le même refus aux deux.
"""
feuilles = [
formats.Feuille("Une", [["nom"], ["Alpha"]]),
formats.Feuille("Deux", [["nom"], ["Beta"]]),
]
with self.assertRaises(formats.ErreurMoteur) as capture:
formats._verifier_conversion(
"xlsx", feuilles, {"conversion": "xml"}
)
self.assertEqual(capture.exception.cle, "conversion_impossible")
def test_une_seule_feuille_vers_xml_passe(self):
formats._verifier_conversion(
"xlsx",
[formats.Feuille("Une", [["nom"], ["Alpha"]])],
{"conversion": "xml"},
)
def test_la_selection_compte_dans_le_refus(self):
"""Deux feuilles, une seule retenue : la cible redevient possible."""
feuilles = [
formats.Feuille("Une", [["nom"], ["Alpha"]]),
formats.Feuille("Deux", [["nom"], ["Beta"]]),
]
formats._verifier_conversion(
"xlsx", feuilles, {"conversion": "xml", "feuilles": ["Une"]}
)
def test_un_conteneur_ne_fait_pas_lever_openpyxl(self):
"""L'erreur brute ressortait sous « format non reconnu »."""
rendu = formats._valeur_pour_xlsx({"nom": "aboulie"})
self.assertIsInstance(rendu, str)
self.assertIn("aboulie", rendu)
self.assertIsInstance(formats._valeur_pour_xlsx([1, 2]), str)
def test_valeur_pour_xlsx_garde_les_types(self):
"""openpyxl porte nativement datetime, int, float et bool.
`valeur_hors_tableur` est écrite pour csv, json et xml, trois
formats SANS types : elle rend une date en chaîne ISO, et la copie
portait du texte là où une date était attendue.
"""
quand = datetime.datetime(2024, 3, 1)
self.assertIs(formats._valeur_pour_xlsx(quand), quand)
self.assertIs(formats._valeur_pour_xlsx(True), True)
self.assertEqual(formats._valeur_pour_xlsx(5), 5)
self.assertIsNone(formats._valeur_pour_xlsx(b"\x00"))
class FausseFormule:
text = "=SUM(A1:A2)"
self.assertEqual(
formats._valeur_pour_xlsx(FausseFormule()), "=SUM(A1:A2)"
)
def test_bornes_ignorent_les_flottants_non_finis(self):
feuille = formats.Feuille(
"f",
[["montant"], [1200.0], [float("nan")], [float("inf")]],
)
colonne = formats._stats_colonnes(feuille)[0]
self.assertEqual((colonne["min"], colonne["max"]), (1200.0, 1200.0))
class TestGardeApresEcriture(unittest.TestCase):
"""Le filet : relire les octets écrits.
Sa valeur est de ne dépendre d'AUCUNE énumération de vecteurs. Un
endroit du format que personne n'a pensé à nettoyer produit un refus,
là où une liste de parties à vérifier produirait un silence. Ce sont
ces tests qui prouvent qu'il tire ; sans eux il pourrait être neutralisé
sans qu'une ligne ne rougisse.
"""
def setUp(self):
self.base = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.base, True)
def _table(self, *valeurs):
table = noyau.Correspondance()
for valeur in valeurs:
noyau.nouveau_mot(valeur, table, VIVIER)
return table
def test_ne_regarde_que_ce_qui_a_ete_remplace(self):
"""Ce qui est hors portée reste par DÉCISION, et est annoncé.
Le mêler ici rendrait la garde bruyante au point d'être
désactivée, ce qui est la seule manière de la rendre inutile.
"""
table = self._table("Alpha")
self.assertEqual(noyau.valeurs_a_verifier(table), {"Alpha"})
def test_ignore_les_chaines_trop_courtes(self):
table = self._table("ok", "abc", "Alpha")
self.assertEqual(noyau.valeurs_a_verifier(table), {"Alpha"})
def test_detecte_une_valeur_survivante(self):
cible = os.path.join(self.base, "copie.txt")
with open(cible, "w", encoding="utf-8") as fh:
fh.write("rien ici sauf Alpha qui ne devrait pas y etre")
fuites, ecartees = noyau.verifier_copie([cible], self._table("Alpha"))
self.assertIn("Alpha", fuites)
self.assertEqual(ecartees, 0)
def test_tolere_ce_qui_est_garde_sciemment(self):
cible = os.path.join(self.base, "copie.txt")
with open(cible, "w", encoding="utf-8") as fh:
fh.write("Alpha reste, il est annonce")
fuites, _ = noyau.verifier_copie(
[cible], self._table("Alpha"), gardees=["Alpha"]
)
self.assertEqual(fuites, {})
def test_balaie_chaque_partie_d_un_zip(self):
"""Nommer les parties une à une est ce qui a laissé passer, tour à
tour, un cache de graphique, un titre d'axe, un hyperlien de
cellule et le nom d'une colonne de tableau."""
cible = os.path.join(self.base, "copie.xlsx")
with zipfile.ZipFile(cible, "w") as archive:
archive.writestr("xl/worksheets/sheet1.xml", "<c>propre</c>")
archive.writestr("xl/un/coin/inattendu.xml", "<c>Alpha</c>")
fuites, _ = noyau.verifier_copie([cible], self._table("Alpha"))
self.assertEqual(
fuites["Alpha"], ["copie.xlsx:xl/un/coin/inattendu.xml"]
)
def test_la_couverture_ne_suit_pas_l_alphabet(self):
"""Le plafond tronquait une liste TRIÉE.
La couverture était donc un préfixe lexicographique et non un
échantillon : sur plusieurs colonnes de texte, seule la première
était relue, à chaque exécution — une reprise n'y changeait rien,
et l'écran affichait une écriture propre.
"""
table = noyau.Correspondance()
for index in range(20005):
noyau.nouveau_mot(f"aaa-{index:06d}", table, VIVIER)
# Cette valeur trie APRÈS les vingt mille autres.
noyau.nouveau_mot("zzz_survivante", table, VIVIER)
cible = os.path.join(self.base, "copie.txt")
with open(cible, "w", encoding="utf-8") as fh:
fh.write("il reste zzz_survivante dans la copie")
fuites, ecartees = noyau.verifier_copie([cible], table)
self.assertIn("zzz_survivante", fuites)
self.assertEqual(ecartees, 0)
def test_une_copie_plate_est_fouillee_ENTIERE(self):
"""La nature de la partie décide, pas ce que les regex capturent.
Un seul couple « > … < » — un fragment HTML dans une colonne
gardée suffit — ramenait le balayage à ce qui les sépare, et toute
valeur hors de cet intervalle sortait sans refus.
"""
for nom, contenu in (
("plat.csv", "nom,note\naboulie,<b>gras</b>\nzzz_leak,ici\n"),
("plat.json", '{"a": "<i>x</i>", "b": "zzz_leak"}'),
("plat.txt", "<html>rien</html>\nzzz_leak\n"),
):
cible = os.path.join(self.base, nom)
with open(cible, "w", encoding="utf-8") as fh:
fh.write(contenu)
fuites, _ = noyau.verifier_copie([cible], self._table("zzz_leak"))
self.assertIn("zzz_leak", fuites, nom)
def test_une_partie_binaire_est_fouillee_ENTIERE(self):
"""Un projet VBA n'a ni nœud ni attribut."""
cible = os.path.join(self.base, "avec.xlsm")
with zipfile.ZipFile(cible, "w") as archive:
archive.writestr(
"xl/worksheets/sheet1.xml", "<c><v>aboulie</v></c>"
)
archive.writestr("xl/vbaProject.bin", b"\x00\x01 zzz_leak \x02")
fuites, _ = noyau.verifier_copie([cible], self._table("zzz_leak"))
self.assertEqual(fuites["zzz_leak"], ["avec.xlsm:xl/vbaProject.bin"])
def test_une_copie_propre_ne_produit_aucun_refus(self):
cible = os.path.join(self.base, "propre.csv")
with open(cible, "w", encoding="utf-8") as fh:
fh.write("nom\naboulie\nacai\n")
fuites, _ = noyau.verifier_copie([cible], self._table("Alpha", "Beta"))
self.assertEqual(fuites, {})
def test_est_xml_tranche_sur_la_nature(self):
self.assertTrue(noyau._est_xml("xl/styles.xml", "nimporte"))
self.assertTrue(noyau._est_xml("_rels/.rels", "x"))
self.assertTrue(noyau._est_xml("sans_extension", "<?xml v?><a/>"))
self.assertTrue(noyau._est_xml("bom", "\ufeff<a/>"))
self.assertFalse(noyau._est_xml("x.bin", "\x00 pas du xml"))
self.assertFalse(
noyau._est_xml("plat.csv", "nom\nun <b>gras</b> ici\n")
)
def test_les_deux_formes_ne_se_comptent_pas_deux_fois(self):
"""Mêlées dans un ensemble, une chaîne portant « & » pesait deux.
Le total dépassait alors la tolérance annoncée, et le filet
refusait du travail légitime.
"""
ecrites, nues, _t_e, _t_n = noyau._chaines_distinctes(
"<a>Roy &amp; Fils</a><b>simple</b>"
)
self.assertIn("Roy &amp; Fils", ecrites)
self.assertIn("Roy & Fils", nues)
# Une valeur gardée qui porte « & » n'est pas refusée.
cible = os.path.join(self.base, "amp.xml")
with open(cible, "w", encoding="utf-8") as fh:
fh.write("<r><a>Roy &amp; Fils</a></r>")
fuites, _ = noyau.verifier_copie(
[cible],
self._table("Roy & Fils"),
gardees=["Roy & Fils"],
)
self.assertEqual(fuites, {})
def test_la_tolerance_ne_couvre_que_l_EGALITE(self):
"""L'appartenance à un bloc joint est un test de sous-chaîne.
Toute chaîne tolérée qui CONTIENT la valeur la tolérait, y compris
là où la valeur fuit — le grain le plus large possible.
"""
cible = os.path.join(self.base, "c.csv")
with open(cible, "w", encoding="utf-8") as fh:
fh.write("nom\naboulie\nRoy et Fils SA\nRoy et Fils\n")
fuites, _ = noyau.verifier_copie(
[cible],
self._table("Roy et Fils"),
gardees=["Roy et Fils SA"],
)
self.assertIn("Roy et Fils", fuites)
def test_une_valeur_toleree_a_l_identique_peut_paraitre_deux_fois(self):
"""Le cas ordinaire d'une colonne laissée intacte."""
cible = os.path.join(self.base, "d.csv")
with open(cible, "w", encoding="utf-8") as fh:
fh.write("ville\nSainte-Lambda\nSainte-Lambda\n")
fuites, _ = noyau.verifier_copie(
[cible],
self._table("Sainte-Lambda"),
gardees=["Sainte-Lambda"],
)
self.assertEqual(fuites, {})
def test_le_filet_voit_ce_que_le_graveur_a_ECHAPPE(self):
"""`csv` double le guillemet, `json.dump` le préfixe.
Chercher les octets bruts d'un nom portant un guillemet n'y
trouvait alors rien, et la copie partait avec.
"""
for nom, contenu in (
("e.csv", 'nom\naboulie\n"Roy ""et"" Fils"\n'),
("f.json", json.dumps({"a": 'Roy "et" Fils'})),
):
cible = os.path.join(self.base, nom)
with open(cible, "w", encoding="utf-8") as fh:
fh.write(contenu)
fuites, _ = noyau.verifier_copie(
[cible], self._table('Roy "et" Fils')
)
self.assertIn('Roy "et" Fils', fuites, nom)
def test_le_prefiltre_ne_rend_aucun_faux_negatif(self):
"""La propriété sur laquelle tout le balayage repose.
Le préfiltre n'existe que pour écarter une valeur sans la
chercher : s'il pouvait écarter une valeur PRÉSENTE, le filet
deviendrait aveugle en silence, ce qui est exactement le mode de
défaillance qu'il est là pour empêcher.
"""
bloc = noyau._joindre(
{f"chaine_{i}_avec_du_texte" for i in range(500)}
)
bits = noyau._prefiltre(bloc)
for i in range(500):
for valeur in (
f"chaine_{i}_avec_du_texte",
f"aine_{i}_avec",
"avec_du_texte",
):
self.assertTrue(
noyau._peut_contenir(bits, valeur),
f"faux négatif sur {valeur!r}",
)
def test_le_prefiltre_ecarte_vraiment(self):
bloc = noyau._joindre({"aboulie", "acai", "acanthe"})
bits = noyau._prefiltre(bloc)
ecartees = sum(
0 if noyau._peut_contenir(bits, f"valeur_{i}_absente") else 1
for i in range(200)
)
self.assertGreater(ecartees, 150)
def test_une_valeur_plus_courte_que_le_ngramme_passe_toujours(self):
bits = noyau._prefiltre(noyau._joindre({"aboulie"}))
self.assertTrue(noyau._peut_contenir(bits, "ab"))
def test_le_prefiltre_ne_change_pas_le_verdict(self):
"""Au-dessus et en dessous du seuil, le même résultat.
C'est la seule façon de garder le préfiltre honnête : il accélère,
il ne décide pas.
"""
cible = os.path.join(self.base, "copie.txt")
with open(cible, "w", encoding="utf-8") as fh:
fh.write("il reste zzz_survivante ici, et rien d'autre")
table = noyau.Correspondance()
for i in range(noyau.SEUIL_PREFILTRE + 50):
noyau.nouveau_mot(f"absente_{i:05d}", table, VIVIER)
noyau.nouveau_mot("zzz_survivante", table, VIVIER)
avec, _ = noyau.verifier_copie([cible], table)
petite = noyau.Correspondance()
noyau.nouveau_mot("zzz_survivante", petite, VIVIER)
sans, _ = noyau.verifier_copie([cible], petite)
self.assertIn("zzz_survivante", avec)
self.assertEqual(set(avec), set(sans))
def test_l_octet_nul_empeche_une_valeur_a_cheval(self):
"""Sans séparateur, deux chaînes voisines en fabriqueraient une."""
bloc = noyau._joindre({"aaabbb", "cccddd"})
self.assertEqual(bloc.count("bbbccc"), 0)
self.assertIn("\x00", bloc)
def test_l_ecriture_refuse_et_n_laisse_aucun_fichier(self):
"""Le refus doit être total : une copie partielle serait livrée."""
source = os.path.join(self.base, "s.csv")
with open(source, "w", encoding="utf-8") as fh:
fh.write("nom\nAlpha\n")
sortie = os.path.join(self.base, "o.csv")
# Un graveur qui recopie la source telle quelle : exactement le
# mutant que la suite laissait passer avant cette garde.
def graveur_inerte(destination, feuille, options):
with open(destination, "w", encoding="utf-8") as fh:
fh.write("nom\nAlpha\n")
return [destination]
vrai = formats._ecrire_csv
formats._ecrire_csv = graveur_inerte
try:
with self.assertRaises(formats.ErreurMoteur) as capture:
formats.ecrire(source, sortie, {"graine": "3"})
finally:
formats._ecrire_csv = vrai
self.assertEqual(capture.exception.cle, "fuite_detectee")
self.assertFalse(os.path.exists(sortie))
class TestI18n(unittest.TestCase):
"""Aucune clé ne doit s'afficher en anglais faute de traduction."""
def test_chaque_cle_erreur_du_moteur_est_traduite(self):
"""Le garde AST ne peut pas couvrir le moteur.
Les clés du moteur sont des constantes dans un dict JSON, jamais
passées à `t()` chez lui — c'est le menu qui traduit. Et `t()`
rend la clé quand elle manque, sans lever : sans cette
vérification, un francophone lirait de l'anglais et aucun test ne
tomberait.
"""
manquantes = [
valeur
for valeur in noyau.ERREURS.values()
if valeur not in todo_i18n.TRANSLATIONS
]
self.assertEqual(manquantes, [])
def test_chaque_cle_du_menu_est_traduite(self):
manquantes = [
cle
for cle in _cles_du_menu(traduites_seulement=False)
if cle not in todo_i18n.TRANSLATIONS
]
self.assertEqual(manquantes, [])
def test_les_deux_langues_sont_remplies(self):
for cle in list(noyau.ERREURS.values()):
entree = todo_i18n.TRANSLATIONS[cle]
self.assertTrue(entree.get("fr"), cle)
self.assertTrue(entree.get("en"), cle)
def test_les_libelles_correspondent_a_leur_parseur(self):
"""Un « (O/n) » lu par _is_yes promet oui et vaut non.
La convention du dépôt : `(Y/n)` / `(O/n)` se lit par
`_is_yes_default_yes`, `(y/N)` / `(o/N)` par `_is_yes`.
"""
# SEULEMENT les clés de ce module : le dépôt en compte des
# milliers, et d'autres menus formulent légitimement autrement.
verifiees = 0
for cle in _cles_du_menu():
entree = todo_i18n.TRANSLATIONS[cle]
if "(Y/n)" in cle:
self.assertIn("(O/n)", entree["fr"], cle)
verifiees += 1
if "(y/N)" in cle:
self.assertIn("(o/N)", entree["fr"], cle)
verifiees += 1
self.assertGreater(verifiees, 4, "le test ne vérifie rien")
class TestEnvironnement(unittest.TestCase):
def test_formats_stdlib_disponibles_sans_venv(self):
for fmt in ("csv", "json", "xml", "macros"):
self.assertTrue(transform_setup.available(fmt), fmt)
def test_interpreteur_toujours_executable(self):
for fmt in ("csv", "xlsx", None):
self.assertTrue(
os.path.isfile(transform_setup.engine_python(fmt)), fmt
)
def test_paquet_systeme_delegue_a_todo_install(self):
"""Aucune cascade apt/dnf/pacman/zypper de plus ici.
Et pas d'entrée `pacman` : mdbtools n'est pas dans les dépôts
officiels d'Arch, seulement l'AUR. `install_command` doit alors
rendre None plutôt qu'une commande qui échoue APRÈS le mot de
passe sudo.
"""
self.assertNotIn("pacman", transform_setup.PAQUETS_ACCESS)
commande = transform_setup.system_packages_cmd()
self.assertTrue(commande is None or "mdbtools" in commande)
def test_creation_refusee_n_installe_rien(self):
lances = []
fait = transform_setup.create(
ask=lambda _: "n", executeur=lances.append
)
self.assertFalse(fait)
self.assertEqual(lances, [])
def test_capabilities_nomme_xlsb_illisible(self):
self.assertFalse(transform_setup.capabilities()["xlsb"])
# ----------------------------------------------------------------------
# Ce qui exige openpyxl. Ignoré et DIT quand il manque.
# ----------------------------------------------------------------------
MARQUEURS = {
"props_creator": "ZQXCREATOR",
"props_modif": "ZQXMODIF",
"props_title": "ZQXTITLE",
"props_keywords": "ZQXKEYWORD",
"custom_prop": "ZQXCUSTOM",
"comment_text": "ZQXCOMTEXT",
"comment_author": "ZQXCOMAUTH",
"hyperlink": "ZQXHYPER",
"header": "ZQXHEADER",
"footer": "ZQXFOOTER",
"validation": "ZQXVALID",
"condformat": "ZQXCONDF",
"chart_title": "ZQXCHTITLE",
"axis_x_title": "ZQXAXISX",
"axis_y_title": "ZQXAXISY",
"series_name": "ZQXSERIES",
"cat_cache": "ZQXCATCACHE",
"cell_value": "ZQXCELL",
"defined_value": "ZQXNAMEVAL",
# Les trois vecteurs qui portent une COPIE ENTIÈRE de la source. La
# fixture ne les portait pas, si bien que les assertions d'absence
# portaient sur des parties JAMAIS présentes : `ws._pivots = []`,
# `ws._images = []` et `keep_links=False` pouvaient chacun disparaître
# sans qu'une ligne ne rougisse.
"pivot_cache": "ZQXPIVOT",
"lien_externe": "ZQXEXTLINK",
"image": "ZQXIMAGE",
"filtre": "ZQXFILTRE",
# Une feuille GRAPHIQUE n'a aucune cellule : la passe sur la grille ne
# la voit pas, `worksheets` l'exclut par construction, et son titre
# comme son en-tête ne passent par aucune règle.
"titre_feuille_graph": "ZQXCHSHEET",
"entete_feuille_graph": "ZQXCHHEAD",
# Le littéral d'un format de nombre personnalisé et le nom d'un style
# nommé : tous deux vivent dans xl/styles.xml, hors de toute cellule.
"format_nombre": "ZQXNUMFMT",
"style_nomme": "ZQXSTYLE",
# Un axe et une étiquette de données portent leur PROPRE format, dans
# la partie graphique. Vider les titres et les caches les laisse, et
# le filet ne les rattrape pas : il ne refuse que ce qui a été annoncé
# remplacé, et un libellé qui n'a jamais été lu d'une cellule n'est
# annoncé par personne.
"format_axe": "ZQXAXISFMT",
"format_etiquette": "ZQXLBLFMT",
# Un style différentiel porte sa police EN LIGNE : elle n'est pas dans
# la liste des polices du classeur, que la passe de renommage
# parcourt. Une fonte de marque nommée par une seule mise en forme
# conditionnelle traversait.
"police_dxf": "ZQXFONTDXF",
}
# Les quatre familles référencées par une formule. On ne peut pas les
# supprimer sans casser ce que la règle de la formule vient de préserver :
# elles sont RAPPORTÉES, pas effacées.
MARQUEURS_CLASSE_B = {
"defined_global": "ZQXNAMEGLOB",
"defined_local": "ZQXNAMELOC",
"table_name": "ZQXTABLE",
"formula_literal": "ZQXFORMULA",
"sheet_name": "ZQXSHEET",
}
TOUS_MARQUEURS = dict(MARQUEURS)
TOUS_MARQUEURS.update(MARQUEURS_CLASSE_B)
def _fabriquer_fixture(chemin):
"""Un classeur portant un marqueur inventé dans chaque vecteur.
Le graphique DOIT être bâti par `add_data()` + `set_categories()` : un
`Series()` construit à la main puis appendu n'écrit ni `<cat>`, ni
`<val>`, ni `strRef` : les balises de `<ser>` se limitent alors à
`idx`, `order`, `tx`, `spPr`. Une fixture bâtie ainsi ne porte pas le
vecteur de cache, et le test rapporterait « effacé » sur un marqueur
qui n'a jamais été écrit.
"""
from openpyxl import Workbook
from openpyxl.chart import BarChart, Reference, Series
from openpyxl.chart.label import DataLabelList
from openpyxl.comments import Comment
from openpyxl.formatting.rule import CellIsRule
from openpyxl.packaging.custom import (
CustomPropertyList,
StringProperty,
)
from openpyxl.styles import Font, PatternFill
from openpyxl.workbook.defined_name import DefinedName
from openpyxl.worksheet.datavalidation import DataValidation
from openpyxl.worksheet.table import Table, TableStyleInfo
M = TOUS_MARQUEURS
classeur = Workbook()
onglet = classeur.active
onglet.title = M["sheet_name"]
onglet["A1"] = "etiquette"
onglet["B1"] = "montant"
onglet["A2"] = M["cell_value"]
onglet["B2"] = 4711
onglet["A3"] = "autre"
onglet["B3"] = 12
onglet["C2"] = f'=IF(A2="{M["formula_literal"]}",1,0)'
classeur.properties.creator = M["props_creator"]
classeur.properties.lastModifiedBy = M["props_modif"]
classeur.properties.title = M["props_title"]
classeur.properties.keywords = M["props_keywords"]
proprietes = CustomPropertyList()
proprietes.append(StringProperty(name="client", value=M["custom_prop"]))
classeur.custom_doc_props = proprietes
onglet["A2"].comment = Comment(M["comment_text"], M["comment_author"])
onglet["A3"].hyperlink = f"https://{M['hyperlink']}.example/rapport.xlsx"
onglet.oddHeader.center.text = M["header"]
onglet.oddFooter.left.text = M["footer"]
validation = DataValidation(
type="list", formula1=f'"{M["validation"]},autre"'
)
onglet.add_data_validation(validation)
validation.add("D2:D10")
onglet.conditional_formatting.add(
"E2:E10",
CellIsRule(
operator="equal",
formula=[f'"{M["condformat"]}"'],
fill=PatternFill(start_color="FFEE1111", end_color="FFEE1111"),
font=Font(name=M["police_dxf"]),
),
)
onglet.auto_filter.ref = "A1:B3"
classeur.defined_names.add(
DefinedName(M["defined_global"], attr_text=f'"{M["defined_value"]}"')
)
onglet.defined_names.add(
DefinedName(M["defined_local"], attr_text=f"'{onglet.title}'!$A$1")
)
onglet["G1"] = M["table_name"]
onglet["G2"] = "x"
tableau = Table(displayName=M["table_name"], ref="G1:G2")
tableau.tableStyleInfo = TableStyleInfo(name="TableStyleMedium9")
onglet.add_table(tableau)
graphique = BarChart()
graphique.title = M["chart_title"]
graphique.x_axis.title = M["axis_x_title"]
graphique.y_axis.title = M["axis_y_title"]
graphique.add_data(
Reference(onglet, min_col=2, min_row=1, max_row=3),
titles_from_data=True,
)
graphique.set_categories(
Reference(onglet, min_col=1, min_row=2, max_row=3)
)
graphique.series.append(
Series(
Reference(onglet, min_col=2, min_row=2, max_row=3),
title=M["series_name"],
)
)
graphique.y_axis.numFmt = '#,##0" %s"' % M["format_axe"]
if graphique.dLbls is None:
graphique.dLbls = DataLabelList()
graphique.dLbls.numFmt = '#,##0" %s"' % M["format_etiquette"]
onglet.add_chart(graphique, "J2")
# Un filtre automatique AVEC une valeur : `auto_filter.ref` seul ne
# pose aucun `filterColumn`, donc l'effacement n'était pas exercé.
onglet.auto_filter.add_filter_column(
0, [M["filtre"], "autre"], blank=False
)
# Une image dont le marqueur vit dans un chunk PNG tEXt : openpyxl ne
# recopie xl/media/ que si Pillow est là, et c'est le vecteur le plus
# dense qu'un classeur puisse porter.
from openpyxl.drawing.image import Image as XLImage
from PIL import Image as PILImage
from PIL import PngImagePlugin
png = os.path.join(os.path.dirname(chemin), "img.png")
info = PngImagePlugin.PngInfo()
info.add_text("Comment", M["image"])
PILImage.new("RGB", (4, 4), (200, 10, 10)).save(png, pnginfo=info)
onglet.add_image(XLImage(png), "L2")
onglet["B2"].number_format = '#,##0" %s"' % M["format_nombre"]
onglet["B3"].number_format = '#,##0" kg"'
from openpyxl.styles import Font, NamedStyle
style = NamedStyle(name=M["style_nomme"], font=Font(bold=True))
classeur.add_named_style(style)
onglet["A3"].style = M["style_nomme"]
feuille_graph = classeur.create_chartsheet(M["titre_feuille_graph"])
feuille_graph.oddHeader.center.text = M["entete_feuille_graph"]
# Un graphique attaché, comme Excel en produit toujours : openpyxl
# 3.1.2 ne RELIT pas une feuille graphique qui n'en a pas — son
# lecteur de relations lève `AttributeError`. Une fixture sans
# graphique éprouverait ce défaut de la bibliothèque, pas le nôtre.
graphique_feuille = BarChart()
graphique_feuille.add_data(
Reference(onglet, min_col=2, min_row=1, max_row=3),
titles_from_data=True,
)
feuille_graph.add_chart(graphique_feuille)
classeur.save(chemin)
_injecter_cache(chemin)
_injecter_parties_de_copie(chemin)
return chemin
def _injecter_parties_de_copie(chemin):
"""Poser un cache de tableau croisé et un lien externe.
openpyxl sait les LIRE et non les écrire : sans injection au niveau du
zip, la fixture ne porte pas les deux parties dont le message de
commit dit qu'elles contiennent « une copie entière » de la source.
"""
temporaire = chemin + ".tmp"
parties = {
"xl/pivotCache/pivotCacheRecords1.xml": (
'<?xml version="1.0"?><pivotCacheRecords count="1">'
f"<r><s v=\"{TOUS_MARQUEURS['pivot_cache']}\"/></r>"
"</pivotCacheRecords>"
),
"xl/externalLinks/externalLink1.xml": (
'<?xml version="1.0"?><externalLink><externalBook>'
f"<sheetNames><sheetName val=\"{TOUS_MARQUEURS['lien_externe']}\"/>"
"</sheetNames></externalBook></externalLink>"
),
}
with zipfile.ZipFile(chemin) as entree, zipfile.ZipFile(
temporaire, "w", zipfile.ZIP_DEFLATED
) as sortie:
for item in entree.infolist():
# Les membres NON XML passent en octets : décoder
# xl/media/image1.png lèverait UnicodeDecodeError.
sortie.writestr(item, entree.read(item.filename))
for nom, contenu in parties.items():
sortie.writestr(nom, contenu)
os.replace(temporaire, chemin)
def _injecter_cache(chemin):
"""Poser un cache de catégories, qu'openpyxl n'écrit pas lui-même.
C'est Excel qui remplit `strCache`, et les balises du graphique sont
écrites SANS préfixe « c: » — mesuré. Viser `<c:cat>` ne trouverait
rien, et le vecteur resterait absent de la fixture.
"""
temporaire = chemin + ".tmp"
cache = (
"<cat><strRef><f>ref</f><strCache>"
'<ptCount val="1"/><pt idx="0"><v>'
+ TOUS_MARQUEURS["cat_cache"]
+ "</v></pt></strCache></strRef></cat>"
)
injecte = False
with zipfile.ZipFile(chemin) as entree, zipfile.ZipFile(
temporaire, "w", zipfile.ZIP_DEFLATED
) as sortie:
for item in entree.infolist():
octets = entree.read(item.filename)
# Le classeur porte plusieurs graphiques : viser le PREMIER qui
# a des catégories. Exiger « <cat> » dans chacun ferait tomber
# la fabrication sur le graphique de la feuille graphique, qui
# n'en a pas.
if (
not injecte
and item.filename.startswith("xl/charts/chart")
and b"<cat>" in octets
):
texte = octets.decode("utf-8")
debut = texte.index("<cat>")
fin = texte.index("</cat>") + len("</cat>")
octets = (texte[:debut] + cache + texte[fin:]).encode("utf-8")
injecte = True
sortie.writestr(item, octets)
# Sans cache injecté, la mesure serait creuse : le test rapporterait
# « effacé » sur un marqueur jamais écrit.
assert injecte, "aucun graphique ne porte <cat>"
os.replace(temporaire, chemin)
# Le processus de test tourne sous `.venv.erplibre`, qui n'a PAS openpyxl :
# c'est la contrainte de `run_unit_test.sh`. La fabrication de la fixture et
# l'écriture passent donc par le venv dédié, en SOUS-PROCESSUS — ce qui
# éprouve du même coup le protocole JSON du moteur. Le balayage, lui, reste
# ici : il ne demande que `zipfile`.
_AMORCE = (
"import importlib.util, sys;"
"s = importlib.util.spec_from_file_location('fx', sys.argv[1]);"
"m = importlib.util.module_from_spec(s);"
"s.loader.exec_module(m);"
"m._fabriquer_fixture(sys.argv[2])"
)
def _racine():
return os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
def _sous_processus(arguments):
"""Lancer sous l'interpréteur du venv dédié. Rend le CompletedProcess."""
environnement = dict(os.environ)
environnement["PYTHONPATH"] = _racine()
return subprocess.run(
[transform_setup.engine_python("xlsx")] + list(arguments),
capture_output=True,
text=True,
cwd=_racine(),
env=environnement,
)
def _fabriquer_par_sous_processus(chemin):
acheve = _sous_processus(
["-c", _AMORCE, os.path.abspath(__file__), chemin]
)
if acheve.returncode:
raise AssertionError(
"la fixture n'a pas pu être fabriquée :\n" + acheve.stderr
)
return chemin
def _ecrire_par_sous_processus(source, destination, options):
acheve = _sous_processus(
[
os.path.join("script", "data", "external_file.py"),
"--apply",
source,
"--out",
destination,
"--options",
json.dumps(options),
]
)
try:
resultat = json.loads(acheve.stdout or "")
except ValueError:
raise AssertionError(
"stdout ne porte pas de JSON :\n"
+ (acheve.stdout or "")[:400]
+ "\n"
+ acheve.stderr[-800:]
)
if "erreur" in resultat:
raise AssertionError(f"{resultat['erreur']} {resultat.get('detail')}")
return resultat
def _balayer(chemin):
"""{clé de marqueur: [parties du zip]} — TOUTES les parties."""
trouves = {}
with zipfile.ZipFile(chemin) as archive:
for nom in archive.namelist():
texte = archive.read(nom).decode("utf-8", "ignore")
for cle, marqueur in TOUS_MARQUEURS.items():
if marqueur in texte:
trouves.setdefault(cle, []).append(nom)
return trouves
@unittest.skipUnless(
transform_setup.available("xlsx"),
"openpyxl absent : bâtir .venv.todo.external_data"
" (TODO › Transform data › Install the reading environment)",
)
class TestFuiteXlsx(unittest.TestCase):
"""Le test qui garde toute la fonctionnalité.
Son résultat est MESURÉ, pas espéré. La version précédente de ce
nettoyage était annoncée « vérifiée » et laissait passer trois
vecteurs sur quatre : les caches de graphique, les titres d'axes et
les hyperliens de cellule.
"""
def setUp(self):
self.base = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.base, True)
self.source = _fabriquer_par_sous_processus(
os.path.join(self.base, "source.xlsx")
)
def test_la_fixture_porte_bien_tous_les_marqueurs(self):
"""Sans cette garde, un « effacé » peut ne rien prouver."""
presents = set(_balayer(self.source))
manquants = sorted(set(TOUS_MARQUEURS) - presents)
self.assertEqual(manquants, [])
def _anonymiser(self, **options):
sortie = os.path.join(self.base, "sortie.xlsx")
_ecrire_par_sous_processus(
self.source, sortie, {"graine": "11", **options}
)
return sortie
def test_les_seuls_survivants_sont_les_quatre_familles(self):
survivants = set(_balayer(self._anonymiser()))
self.assertEqual(
survivants,
set(MARQUEURS_CLASSE_B),
"la liste EXACTE : un vecteur rouvert doit faire tomber ce"
" test, pas passer inaperçu",
)
def test_le_compte_des_effaces(self):
efface = set(TOUS_MARQUEURS) - set(_balayer(self._anonymiser()))
self.assertEqual(len(TOUS_MARQUEURS), 35)
self.assertEqual(len(efface), 30)
def test_la_constante_d_une_plage_nommee_passe_par_la_table(self):
"""Le NOM survit par nécessité, la VALEUR doit partir.
Mesurée comme survivante tant qu'on ne remplace pas la constante :
c'est ce que la classe (b) exige, et le nom reste résolvable.
"""
survivants = _balayer(self._anonymiser())
self.assertNotIn("defined_value", survivants)
self.assertIn("defined_global", survivants)
def test_les_proprietes_du_document_sont_videes(self):
sortie = self._anonymiser()
with zipfile.ZipFile(sortie) as archive:
coeur = archive.read("docProps/core.xml").decode("utf-8")
# `creator` vaut « openpyxl » par DÉFAUT : sans creator=None,
# l'élément ne disparaît pas, il est rempli.
self.assertNotIn("dc:creator", coeur)
self.assertNotIn("cp:lastModifiedBy", coeur)
def test_les_parties_qui_portent_une_copie_disparaissent(self):
with zipfile.ZipFile(self._anonymiser()) as archive:
noms = archive.namelist()
for prefixe in (
"xl/pivotCache",
"xl/externalLinks",
"xl/comments/",
"xl/media/",
):
self.assertFalse(
[n for n in noms if n.startswith(prefixe)], prefixe
)
def test_la_feuille_graphique_part_toujours(self):
"""Rien ne peut l'anonymiser : elle n'a pas de cellule.
La retirer seulement quand une sélection de feuilles existe faisait
mentir l'avertissement dans tous les autres cas.
"""
survivants = _balayer(self._anonymiser())
self.assertNotIn("titre_feuille_graph", survivants)
self.assertNotIn("entete_feuille_graph", survivants)
def test_les_graphiques_partent_par_defaut(self):
with zipfile.ZipFile(self._anonymiser()) as archive:
noms = archive.namelist()
self.assertFalse([n for n in noms if n.startswith("xl/charts")])
def test_graphiques_gardes_le_nom_de_serie_part_quand_meme(self):
"""`s.tx = None` est indispensable : le nom a DEUX formes.
`<tx><v>littéral</v>` quand il est tapé,
`<tx><strRef><f>réf</f>` quand il vient des données. Ni l'une ni
l'autre n'est un cache : vider strCache/numCache les laisserait
toutes deux en place.
"""
sortie = self._anonymiser(garder_graphiques=True)
survivants = _balayer(sortie)
self.assertNotIn("series_name", survivants)
self.assertNotIn("cat_cache", survivants)
self.assertNotIn("chart_title", survivants)
self.assertNotIn("axis_x_title", survivants)
def test_graphiques_gardes_le_nom_de_feuille_fuit_en_plus(self):
"""La référence <f> d'une série porte 'feuille'!$B$1.
Le chemin par défaut l'évite : c'est une raison de plus d'en
faire le défaut.
"""
sortie = self._anonymiser(garder_graphiques=True)
parties = _balayer(sortie).get("sheet_name", [])
self.assertTrue([p for p in parties if p.startswith("xl/charts")])
def test_les_formules_survivent_et_les_valeurs_changent(self):
"""Lu par zipfile : le processus de test n'a pas openpyxl."""
with zipfile.ZipFile(self._anonymiser()) as archive:
feuille = archive.read("xl/worksheets/sheet1.xml").decode("utf-8")
# La formule est conservée telle quelle par la règle.
self.assertIn(TOUS_MARQUEURS["formula_literal"], feuille)
# La valeur de cellule, elle, a changé — les chaînes vivent dans
# sharedStrings.xml, qui est balayé par _balayer().
self.assertNotIn("cell_value", _balayer(self._anonymiser()))
def test_le_littéral_d_un_format_de_nombre_est_assaini(self):
"""Excel laisse suffixer un nombre d'un libellé.
Ce libellé vit dans `xl/styles.xml`, hors de toute cellule : aucune
règle ne le voyait, et le filet refusait le classeur sans jamais
l'assainir. Un littéral COURT — une devise, une unité — reste : il
ne porte aucune donnée et le remplacer abîmerait le classeur.
"""
sortie = self._anonymiser()
with zipfile.ZipFile(sortie) as archive:
styles = archive.read("xl/styles.xml").decode("utf-8")
self.assertNotIn(TOUS_MARQUEURS["format_nombre"], styles)
self.assertNotIn(TOUS_MARQUEURS["style_nomme"], styles)
# L'unité survit, et la structure du format avec elle.
self.assertIn("kg", styles)
self.assertIn("#,##0", styles)
def test_la_source_n_est_pas_modifiee(self):
avant = (
os.path.getmtime(self.source),
os.path.getsize(self.source),
)
self._anonymiser()
self.assertEqual(
avant,
(
os.path.getmtime(self.source),
os.path.getsize(self.source),
),
)
def _openpyxl_ici():
"""Vrai si openpyxl s'importe dans CET interpréteur.
Pas `transform_setup.available("xlsx")` : il répond vrai dès que le venv
dédié l'a, alors que ces tests écrivent leur classeur ici même.
"""
try:
import openpyxl # noqa: F401
except ImportError:
return False
return True
@unittest.skipUnless(
_openpyxl_ici(),
"openpyxl absent de cet interpréteur : pip install -r"
" requirement/erplibre_require-ments.txt",
)
class TestHorodatageDeLaCopie(unittest.TestCase):
"""La copie ne porte pas l'instant où elle a été tirée.
`dcterms:created` dit la séance de l'opérateur, que rien n'oblige à
transmettre. Il rend surtout le refus du filet ALÉATOIRE : une
cellule qui porte le mois courant — un libellé de période, ordinaire
dans un export comptable — se retrouve dans l'horodatage, et le
compte en surplus du socle bascule d'une exécution à l'autre selon
que `created` et `modified` tombent sur la même seconde. Une entrée
identique est alors refusée une fois sur deux ou trois.
"""
def setUp(self):
self.base = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.base, True)
def _source(self):
"""Un classeur dont une cellule porte le mois COURANT.
Calculé et non écrit en dur : un littéral rendrait le test caduc
au mois suivant, et c'est justement la coïncidence avec l'instant
de l'écriture qui est éprouvée.
"""
from openpyxl import Workbook
chemin = os.path.join(self.base, "periode.xlsx")
classeur = Workbook()
onglet = classeur.active
onglet.append(["periode", "montant"])
mois = datetime.datetime.now().strftime("%Y-%m")
for rang in range(4):
onglet.append([mois, 100 + rang])
classeur.save(chemin)
return chemin, mois
def test_le_mois_courant_dans_une_cellule_n_empeche_pas_l_ecriture(self):
"""Trois passes : sans le gel, une entrée identique est
refusée une fois sur deux."""
source, _mois = self._source()
for passe in range(3):
with self.subTest(passe=passe):
sortie = os.path.join(self.base, "c%d.xlsx" % passe)
_ecrire_par_sous_processus(source, sortie, {"graine": "5"})
self.assertTrue(os.path.isfile(sortie))
def test_la_copie_porte_une_date_de_creation_FIGEE(self):
source, _mois = self._source()
sortie = os.path.join(self.base, "c.xlsx")
_ecrire_par_sous_processus(source, sortie, {"graine": "5"})
with zipfile.ZipFile(sortie) as archive:
core = archive.read("docProps/core.xml").decode("utf-8")
self.assertIn(formats.EPOQUE_FIGEE.strftime("%Y-%m-%d"), core)
def test_le_createur_ne_dit_pas_openpyxl(self):
"""Sans `creator=None`, l'élément n'est pas retiré mais REMPLI."""
source, _mois = self._source()
sortie = os.path.join(self.base, "c.xlsx")
_ecrire_par_sous_processus(source, sortie, {"graine": "5"})
with zipfile.ZipFile(sortie) as archive:
core = archive.read("docProps/core.xml").decode("utf-8")
self.assertNotIn("openpyxl", core)
class TestLaBandeDuCalibreNeSElargitPas(unittest.TestCase):
"""Garder la largeur : le vivier sature avant la bande.
Sans remise, neuf valeurs à un chiffre se disputent les neuf places
1..9 et l'identité est interdite : l'attribution bute. Hors calibre,
trois recours élargissent alors la plage — d'un pas, par paliers de
dix, puis en rendant « valeur + 1 » — et chacun rend un nombre d'une
AUTRE largeur, la seule chose que l'option promette. Sous calibre les
trois cèdent la place à un doublon dans la bande.
Aucun avertissement ne couvre ce cas : les colonnes saturées se
mesurent sur l'étendue de la COLONNE, pas sur la bande du calibre.
"""
VALEURS = list(range(1, 10)) + [500000]
BORNES = (1, 500000, True)
def _largeur(self, n):
return len(str(abs(int(n))))
def _lot(self, graine, valeurs=None, bornes=None, calibre=True):
rng = random.Random(graine)
table = noyau.Correspondance()
return {
v: noyau.nouveau_nombre(
v,
rng,
bornes=bornes or self.BORNES,
table=table,
calibre=calibre,
)
for v in (valeurs or self.VALEURS)
}
def test_aucune_graine_ne_change_une_largeur(self):
"""Sans la garde, 24 graines sur 300 en changent une."""
fautifs = []
for graine in range(300):
for source, sortie in self._lot(graine).items():
if self._largeur(sortie) != self._largeur(source):
fautifs.append((graine, source, sortie))
self.assertEqual([], fautifs[:5])
def test_la_bande_saturee_rend_un_DOUBLON_et_non_une_largeur(self):
"""Un doublon ne fait rien fuir ; une largeur changée se voit."""
sorties = self._lot(1)
chiffres = [s for v, s in sorties.items() if v < 10]
self.assertTrue(all(1 <= s <= 9 for s in chiffres), chiffres)
self.assertLess(len(set(chiffres)), len(chiffres))
def test_jamais_l_identite_meme_saturee(self):
"""La rendre laisserait l'original dans la copie en le comptant
comme remplacé — une fuite annoncée propre."""
for graine in range(200):
for source, sortie in self._lot(graine).items():
self.assertNotEqual(source, sortie, (graine, source))
def test_l_unicite_tient_encore_quand_la_bande_a_de_la_place(self):
"""Le compromis ne se paie que sur une bande pleine."""
valeurs = [1000, 2000, 3000, 4000]
sorties = self._lot(7, valeurs=valeurs, bornes=(1, 500000, True))
self.assertEqual(len(valeurs), len(set(sorties.values())))
for source, sortie in sorties.items():
self.assertEqual(4, self._largeur(sortie), (source, sortie))
def _saturer(self, valeurs):
"""Une table dont TOUTE la bande de ces valeurs est déjà prise.
C'est la seule façon d'atteindre le recours : sans saturation, le
tirage trouve une place libre et le test passe aussi bien sur le
code d'avant.
"""
table = noyau.Correspondance()
for valeur in valeurs:
bas, haut = noyau.bornes_du_meme_calibre(valeur)
table.nombres_pris.update(range(bas, haut + 1))
return table
def test_le_signe_survit_a_la_bande_VRAIMENT_saturee(self):
table = self._saturer([-5])
rng = random.Random(3)
for tour in range(50):
sortie = noyau.nouveau_nombre(
-5, rng, bornes=(-500000, -1, True), table=table, calibre=True
)
self.assertLessEqual(sortie, -1, tour)
self.assertGreaterEqual(sortie, -9, tour)
self.assertNotEqual(-5, sortie, tour)
def test_la_bande_saturee_ne_rend_JAMAIS_l_identite(self):
"""La rendre laisserait l'original dans la copie en le comptant
pour remplacé — une fuite annoncée propre."""
for valeur in (1, 5, 9, -9, 42, -1234, 987654):
with self.subTest(valeur=valeur):
table = self._saturer([valeur])
rng = random.Random(valeur)
for _ in range(30):
sortie = noyau.nouveau_nombre(
valeur,
rng,
bornes=(-(10**7), 10**7, True),
table=table,
calibre=True,
)
self.assertNotEqual(valeur, sortie)
self.assertEqual(
self._largeur(valeur), self._largeur(sortie)
)
def test_la_sonde_a_trois_candidats_tranche_quand_le_tirage_boucle(self):
"""Un tirage peut retomber dix fois sur l'origine : les bornes
tranchent alors à coup sûr, sans parcourir la bande."""
rng = random.Random(0)
rng.randint = lambda bas, haut: 5 # tout tirage rend l'origine
sortie = noyau._doublon_du_meme_calibre(5, rng, 1, 9, True, 2)
self.assertNotEqual(5, sortie)
self.assertIn(sortie, (1, 9, 2))
def test_sous_l_unite_l_elargissement_reste_PERMIS(self):
"""Aucune bande ne s'applique sous l'unité : la règle générale
garde son dernier recours, et il SORT de l'étendue.
L'étendue 0,10..0,12 à deux décimales ne compte que trois places :
cinq valeurs distinctes la saturent. La résolution se lit sur les
valeurs, si bien qu'un 0,153 en porterait trois et ouvrirait
cinquante-et-une places — la plage ne saturerait plus, et le test
passerait sans rien éprouver.
"""
self.assertIsNone(noyau.bornes_du_meme_calibre(0.15))
table = noyau.Correspondance()
rng = random.Random(5)
sorties = [
noyau.nouveau_nombre(
valeur,
rng,
bornes=(0.10, 0.12),
table=table,
calibre=True,
)
for valeur in (0.10, 0.11, 0.12, 0.13, 0.14)
]
self.assertTrue(
any(s < 0.10 or s > 0.12 for s in sorties),
"aucune sortie n'a quitté l'étendue : rien n'a été élargi",
)
def test_sans_calibre_l_elargissement_SORT_de_la_plage(self):
"""L'option seule interdit d'élargir ; la règle générale, non.
Il faut PLUS de valeurs distinctes que de places : redemander une
valeur déjà vue rend la sortie mémorisée, et neuf valeurs pour
neuf places se rangent en permutation sans jamais saturer.
"""
rng = random.Random(11)
table = noyau.Correspondance()
sorties = [
noyau.nouveau_nombre(
v, rng, bornes=(1, 9, True), table=table, calibre=False
)
for v in range(1, 15)
]
self.assertTrue(
any(s > 9 or s < 1 for s in sorties),
"la plage 1..9 est saturée : le recours général doit en sortir",
)
def test_une_bande_compte_toujours_au_moins_neuf_places(self):
"""L'invariant sur lequel repose la sonde à trois candidats.
La borne monte à vingt chiffres : c'est à seize que `float()`
cessait d'être exact, et s'arrêter à quinze laissait justement le
défaut hors du test.
"""
for largeur in range(1, 21):
valeur = 10 ** (largeur - 1)
bas, haut = noyau.bornes_du_meme_calibre(valeur)
self.assertGreaterEqual(haut - bas + 1, 9, largeur)
self.assertEqual(largeur, len(str(haut)), largeur)
self.assertEqual(largeur, len(str(bas)), largeur)
def test_la_borne_haute_de_la_bande_reste_EXACTE(self):
"""`float(10**16 - 1)` remonte à 10**16, et `int()` le garde : la
bande gagnait un chiffre.
Le tirage est FORCÉ à sa borne haute. Un tirage ordinaire ne
rencontrerait l'écart qu'une fois sur neuf mille milliards, si
bien qu'un test statistique passerait des deux côtés sans rien
éprouver — et les bornes vues sont assertées, non la seule sortie.
"""
for largeur in range(14, 21):
with self.subTest(largeur=largeur):
valeur = 10 ** (largeur - 1) + 7
vues = []
rng = random.Random(0)
rng.randint = (
lambda bas, haut: vues.append((bas, haut)) or haut
)
sortie = noyau.nouveau_nombre(
valeur,
rng,
bornes=(1, 10**22, True),
table=noyau.Correspondance(),
calibre=True,
)
self.assertEqual(
[(10 ** (largeur - 1), 10**largeur - 1)], vues
)
self.assertEqual(largeur, self._largeur(sortie), sortie)
class TestUnEntierPlusGrandQuUnFlottant(unittest.TestCase):
"""Un `.json` porte des entiers exacts, sans plafond de tableur.
`json.loads` rend un entier de plusieurs centaines de chiffres
intact, et `float()` le refuse au-delà de 1,8e308. Le tirage levait
alors `OverflowError`, que le rattrapage général annonce comme un
format inconnu : le fichier n'était pas anonymisé, et la valeur
repartait en clair.
Excel s'arrête à 9,99e307, donc aucun classeur n'y mène — mais
`json.loads` n'a pas de plafond, et c'est un format que l'entrée
accepte.
"""
ENORME = int("9" * 400)
def _nombre(self, valeur, bornes, **kw):
return noyau.nouveau_nombre(
valeur,
random.Random(0),
bornes=bornes,
table=noyau.Correspondance(),
**kw,
)
def test_un_json_porte_bien_un_entier_exact_de_400_chiffres(self):
"""La prémisse : sans elle le test ne prouve rien."""
lu = json.loads('{"x": %s}' % ("9" * 400))["x"]
self.assertIsInstance(lu, int)
self.assertEqual(400, len(str(lu)))
with self.assertRaises(OverflowError):
float(lu)
def test_une_etendue_trop_grande_ne_leve_plus(self):
sortie = self._nombre(12345, (1, self.ENORME, True))
self.assertNotEqual(12345, sortie)
def test_la_valeur_elle_meme_est_REMPLACEE(self):
"""Lever laissait la valeur en clair dans la copie."""
sortie = self._nombre(self.ENORME, (1, self.ENORME, True))
self.assertNotEqual(self.ENORME, sortie)
def test_le_calibre_garde_les_quatre_cents_chiffres(self):
"""Sa bande est calculée en entiers exacts : elle ne passe pas
par le ramenage, et la largeur survit."""
sortie = self._nombre(
self.ENORME, (1, self.ENORME, True), calibre=True
)
self.assertEqual(400, len(str(abs(int(sortie)))))
self.assertNotEqual(self.ENORME, sortie)
def test_un_decimal_dans_une_telle_colonne_ne_leve_pas(self):
sortie = self._nombre(1.5, (0.5, self.ENORME))
self.assertNotEqual(1.5, sortie)
def test_la_borne_ramenee_garde_son_SIGNE(self):
self.assertEqual(sys.float_info.max, noyau._en_flottant(self.ENORME))
self.assertEqual(-sys.float_info.max, noyau._en_flottant(-self.ENORME))
self.assertEqual(12.5, noyau._en_flottant(12.5))
def test_un_entier_hors_flottant_n_a_pas_de_decimale(self):
self.assertEqual(0, noyau._decimales(self.ENORME))
self.assertEqual(2, noyau._decimales(0.15))
def test_une_bande_entiere_trop_large_se_compte_comme_infinie(self):
"""Le parcours des places est alors hors de portée, comme pour
une étendue qui rendait déjà `inf`."""
sortie = noyau._tirer_libre(
self.ENORME,
random.Random(0),
10**399,
10**400 - 1,
True,
set(),
True,
)
self.assertEqual(400, len(str(sortie)))
self.assertNotEqual(self.ENORME, sortie)
if __name__ == "__main__":
unittest.main()