From fd2e2ca7efa12951b9e2ce1921d49a3e864ddf3b Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 10 Aug 2026 03:10:50 -0400 Subject: [PATCH] [FIX] todo: test menu, file browser and KeePass refusals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four papercuts met while using the CLI: the test menu returned a verdict nobody could find at the end of the output, the file browser never closed once a file was picked, a wrong KeePass password killed the whole CLI, and a configuration key had drifted from the code that reads it. --- FR --- Quatre irritants rencontrés à l'usage : le menu Test rendait un verdict introuvable en fin de sortie, le navigateur de fichiers ne se fermait jamais une fois le fichier choisi, un mauvais mot de passe KeePass tuait tout le CLI, et une clé de configuration avait dérivé du code qui la lit. Assisted-by: Claude Opus 5 --- script/todo/kdbx_manager.py | 49 +++++-- script/todo/todo.py | 228 +++++++++++++++++++++++++++++++ script/todo/todo_file_browser.py | 31 ++++- script/todo/todo_i18n.py | 37 +++++ test/test_execute.py | 6 +- test/test_kdbx_manager.py | 117 ++++++++++++++++ test/test_todo.py | 166 ++++++++++++++++++++++ test/test_todo_file_browser.py | 126 +++++++++++++++++ test/test_todo_i18n.py | 44 +++--- 9 files changed, 762 insertions(+), 42 deletions(-) create mode 100644 test/test_kdbx_manager.py create mode 100644 test/test_todo_file_browser.py diff --git a/script/todo/kdbx_manager.py b/script/todo/kdbx_manager.py index ea54710..8a798f6 100644 --- a/script/todo/kdbx_manager.py +++ b/script/todo/kdbx_manager.py @@ -14,18 +14,23 @@ try: from tkinter import filedialog from pykeepass import PyKeePass + from pykeepass.exceptions import CredentialsError except ModuleNotFoundError: PyKeePass = None tk = None filedialog = None + class CredentialsError(Exception): + """Jamais levée ici : sans pykeepass, `get_kdbx` sort avant d'ouvrir + quoi que ce soit. Définie pour que le `except` reste écrivable.""" + class KdbxManager: def __init__(self, config_file) -> None: self._config_file = config_file self._kdbx = None - def get_kdbx(self): + def get_kdbx(self, attempts: int = 3): if self._kdbx: return self._kdbx @@ -47,20 +52,42 @@ class KdbxManager: ) return None - kdbx_password = self._config_file.get_config_value( - ["kdbx", "password"] - ) - if not kdbx_password: - kdbx_password = getpass.getpass(prompt=t("enter_password")) - if PyKeePass is None: _logger.error("pykeepass is not installed") return None - kp = PyKeePass(kdbx_file_path, password=kdbx_password) - if kp: - self._kdbx = kp - return kp + kdbx_password = self._config_file.get_config_value( + ["kdbx", "password"] + ) + if kdbx_password: + # Mot de passe pris dans la configuration : personne à qui + # redemander, mais il peut être faux — le dire au lieu de + # laisser remonter une trace de la bibliothèque. + try: + self._kdbx = PyKeePass(kdbx_file_path, password=kdbx_password) + except CredentialsError: + print(t("kdbx_wrong_password")) + return None + return self._kdbx + + # Saisie interactive. Un mot de passe refusé est le cas NORMAL ici, + # pas une panne : la bibliothèque lève `CredentialsError` et, sans + # ce rattrapage, la trace remontait jusqu'à tuer le CLI. On nomme + # aussi le coffre — l'invite ne disait pas DE QUOI elle parlait. + print(f"{t('kdbx_vault_is')} {kdbx_file_path}") + for _ in range(attempts): + password = getpass.getpass(prompt=t("kdbx_ask_password")) + if not password: + print(t("kdbx_give_up")) + return None + try: + self._kdbx = PyKeePass(kdbx_file_path, password=password) + except CredentialsError: + print(t("kdbx_wrong_password")) + continue + return self._kdbx + print(t("kdbx_give_up")) + return None def get_extra_command_user( self, kdbx_key: str | list | None diff --git a/script/todo/todo.py b/script/todo/todo.py index 793191f..0d9fa62 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -27,6 +27,7 @@ sys.path.append(new_path) from script.config import config_file from script.execute import execute +from script.todo import todo_prefs from script.todo.database_manager import DatabaseManager from script.todo.kdbx_manager import KdbxManager from script.todo.todo_i18n import get_lang, lang_is_configured, set_lang, t @@ -500,6 +501,65 @@ class TODO: print("make open_terminal") self.restart_script(str(e)) + def execute_from_configuration( + self, instance, exec_run_db=False, ignore_makefile=False + ): + # exec_run_db need argument database + kdbx_key = instance.get("kdbx_key") + odoo_user = instance.get("user") + odoo_password = instance.get("password") + + if kdbx_key: + extra_cmd_web_login = self.kdbx_manager.get_extra_command_user( + kdbx_key + ) + elif odoo_user and odoo_password: + extra_cmd_web_login = ( + f" --default_email_auth {odoo_user} --default_password_auth" + f" '{odoo_password}'" + ) + else: + extra_cmd_web_login = "" + + makefile_cmd = instance.get("makefile_cmd") + if makefile_cmd and not ignore_makefile: + status = self.execute.exec_command_live( + f"make {makefile_cmd}", + source_erplibre=False, + single_source_erplibre=True, + ) + if status: + _logger.error( + f"Status {status} - exit execute_from_configuration" + ) + return + + if exec_run_db: + db_name = instance.get("database") + self.prompt_execute_selenium_and_run_db( + db_name, extra_cmd_web_login=extra_cmd_web_login + ) + + bash_command = instance.get("bash_command") + if bash_command: + print(f"{t('Will execute:')} {bash_command}") + self.execute.exec_command_live(bash_command, source_erplibre=False) + + # Clé de CONFIGURATION, pas une chaîne d'interface : le passage aux + # clés i18n en texte anglais (4fc15c3) a renommé celle-ci en + # « Command: », le libellé affiché. Plus aucune entrée de todo.json + # ne correspondait, et « Open ERPLibre with TODO 🤖 » ne faisait + # plus rien — sans erreur, puisque le `if` était simplement faux. + command = instance.get("command") + if command: + self.prompt_execute_selenium( + command=command, extra_cmd_web_login=extra_cmd_web_login + ) + + callback = instance.get("callback") + if callback: + callback(instance) + # Étiquettes du fil d'Ariane par méthode de menu. Le fil est dérivé de la # pile d'appels (aucune méthode de menu à modifier). Labels courts et # stables, pensés pour être copiés afin de situer précisément un menu. @@ -550,6 +610,54 @@ class TODO: pass return header + t("Command:") + def _todo_telemetry_tui(self): + """Ouvre le TUI de télémétrie (arbre/Kanban). Une commande choisie est + exécutée au retour (hors du TUI) ; on propose ensuite de REVENIR (l'état + et la position du curseur sont restaurés) ou de quitter.""" + from script.todo import textual_setup + from script.todo.todo_telemetry import run_tui + + if not textual_setup.ensure(): + return + state = None + while True: + try: + result = run_tui(state=state) + except ImportError: + return + if not result: + return + action, state = result + if not action: + return # quitté sans choisir de commande + method, kwargs = action + # Fil d'Ariane : la commande étant lancée DEPUIS la télémétrie (et + # non via la navigation), aucun menu n'a affiché le chemin. On le + # montre ici (dernier segment traduit + icône) et on l'enregistre. + path = state.get("path") if isinstance(state, dict) else None + if path: + segs = path.split(" › ") + segs[-1] = t(segs[-1]) + print(f"\n📍 {' › '.join(segs)}") + try: + from script.todo import todo_telemetry + + todo_telemetry.record(path) + except Exception: + pass + fn = getattr(self, method, None) + if not callable(fn): + print(f"{t('Command not found !')} ({method})") + else: + try: + fn(**(kwargs or {})) + except Exception as exc: + print(f"{t('Command failed: ')}{exc}") + # Revenir (curseur restauré) ou quitter ? + ans = input(f"\n{t('Back to telemetry (r) or quit (Enter)? ')}") + if ans.strip().lower() not in ("r", "revenir", "o", "oui", "y"): + return + # Préférences éditables depuis le menu Configuration : clé, libellé, et # valeurs proposées (valeur stockée -> libellé affiché). Une seule table : # l'écran, la lecture et l'écriture en découlent. @@ -3798,6 +3906,66 @@ class TODO: return f"{secs}s" return f"{secs // 60}m{secs % 60:02d}s" + def _qemu_resolve_ips(self, names, labels=None, timeout=300): + """Résout les IP de plusieurs VM EN PARALLÈLE (le boot émulé est lent), + en affichant la progression au fur et à mesure. Renvoie {nom: ip|None}. + `labels` : {nom: « k/N »} pour préfixer chaque ligne d'un ID de suivi. + `timeout` : délai max PAR VM (borne l'attente d'une VM sans IP). Un + BATTEMENT toutes les 30 s liste les VM encore en attente -> jamais de + silence prolongé qui donne l'impression d'un blocage.""" + from concurrent.futures import ThreadPoolExecutor + from concurrent.futures import TimeoutError as _FTimeout + from concurrent.futures import as_completed + + labels = labels or {} + print( + f"\n{t('Resolving VM IPs (parallel, emulated boot is slow)...')}" + ) + result = {} + t0 = time.time() + starts = {} + workers = min(len(names), (os.cpu_count() or 4)) or 1 + with ThreadPoolExecutor(max_workers=workers) as pool: + futs = {} + for n in names: + starts[n] = time.time() + futs[pool.submit(self._qemu_vm_ip, n, timeout)] = n + pending = set(futs) + done = 0 + while pending: + try: + for fut in as_completed(list(pending), timeout=30): + pending.discard(fut) + n = futs[fut] + try: + ip = fut.result() + except Exception: + ip = None + result[n] = ip + done += 1 + tag = f"[{labels[n]}] " if n in labels else "" + dur = self._fmt_dur(time.time() - starts[n]) + print( + f" [{done}/{len(names)}] {tag}{n}: " + f"{ip or t('no IP')} ({dur})" + ) + except _FTimeout: + # Battement : VM encore en attente (boot/DHCP lent). + waiting = [futs[f] for f in pending] + shown = ", ".join(waiting[:5]) + if len(waiting) > 5: + shown += "…" + print( + f" ⏳ {t('still waiting for')} {len(waiting)} VM " + f"({self._fmt_dur(time.time() - t0)}): {shown}" + ) + got = sum(1 for ip in result.values() if ip) + print( + f" {t('IPs resolved:')} {got}/{len(names)} " + f"({self._fmt_dur(time.time() - t0)})" + ) + return result + def _qemu_vm_arch(self, name): """Architecture d'une VM (jeton amd64/arm64/s390x) via virsh dumpxml.""" try: @@ -7055,6 +7223,35 @@ class TODO: else: print(t("Command not found !")) + def prompt_execute_test(self): + print(f"🤖 {t('Test an Odoo module on a temporary database!')}") + choices = [ + {"prompt_description": t("Test a module")}, + {"prompt_description": t("Test a module with code coverage")}, + {"prompt_description": t("ERPLibre unit tests")}, + {"prompt_description": t("Mail unit tests")}, + {"prompt_description": t("Analyse unit tests")}, + ] + help_info = self.fill_help_info(choices) + + while True: + status = click.prompt(help_info) + print() + if status == "0": + return False + elif status == "1": + self.execute_test_module(coverage=False) + elif status == "2": + self.execute_test_module(coverage=True) + elif status == "3": + self.execute_unit_tests() + elif status == "4": + self.execute_unit_tests("test_mail*.py") + elif status == "5": + self.execute_unit_tests("test_analyse*.py") + else: + print(t("Command not found !")) + def execute_test_module(self, coverage=False): # Module name module_name = input(t("Module name to test: ")).strip() @@ -7144,6 +7341,37 @@ class TODO: single_source_erplibre=True, ) + def execute_unit_tests(self, pattern="test_*.py"): + """Lance `unittest discover` sur un SOUS-ENSEMBLE de la suite. + + Le motif est le seul paramètre : la suite complète dure plusieurs + minutes, dominées par les tests TUI montés, et attendre tout pour + vérifier un coin précis décourage de lancer les tests du tout. Une + entrée de menu supplémentaire coûte donc un motif, pas une méthode. + """ + print(f"\n--- {t('Running unit tests')} ---") + # `-u` : unittest écrit son verdict sur STDERR, les `print()` des + # tests sur STDOUT. Capturés ensemble, stderr passe sans tampon + # tandis que stdout est tamponné par blocs — tout le stdout se + # déversait donc APRÈS le « OK », qui se retrouvait noyé au milieu + # de la sortie au lieu d'en être le dernier mot. Sans tampon, les + # deux flux s'entrelacent dans l'ordre réel. + cmd = ( + ".venv.erplibre/bin/python -u -m unittest discover" + f" -s test -p '{pattern}' -v" + ) + status_code, output = self.execute.exec_command_live( + cmd, + source_erplibre=False, + return_status_and_output=True, + ) + if status_code == 0: + print(f"\n✅ {t('All unit tests passed')}") + else: + print( + f"\n❌ {t('Some unit tests failed, exit code')}: {status_code}" + ) + def execute_pip_audit(self): versions, installed_versions, odoo_installed_version = ( get_odoo_version() diff --git a/script/todo/todo_file_browser.py b/script/todo/todo_file_browser.py index 282c9e3..0259d2e 100644 --- a/script/todo/todo_file_browser.py +++ b/script/todo/todo_file_browser.py @@ -61,21 +61,42 @@ class FileBrowser(urwid.WidgetWrap): self.refresh_list() def select_directory(self, button): - """Selects a file and calls the callback function.""" + """Selects a directory, and closes the browser.""" self.callback(self.current_path) + exit_program() def select_file(self, button): - """Selects a file and calls the callback function.""" + """Selects a file, and closes the browser. + + The callback records the choice; leaving the loop is what ENDS the + browser. Without it the selection worked and nothing seemed to happen: + the screen stayed up, no key closed it, and only Ctrl+C got out — so + the browser looked frozen at the exact moment it had done its job. + """ filename = button.label selected_file_path = os.path.join(self.current_path, filename) self.callback(selected_file_path) + exit_program() + + def unhandled_input(self, key): + """Leaving without choosing has to be possible. + + Every other way out of this browser selects something. A caller that + offers an alternative — typing a path — can only be reached by + cancelling, so cancelling has to exist. + """ + if key in ("q", "Q", "esc"): + exit_program() def run_main_frame(self): main_frame = urwid.Frame( body=self, header=urwid.Text(("header", f"Navigate: {self.current_path}")), footer=urwid.Text( - ("footer", "Use arrow keys to navigate and Enter to select.") + ( + "footer", + "Arrow keys to navigate, Enter to select, q to cancel.", + ) ), ) @@ -88,7 +109,9 @@ class FileBrowser(urwid.WidgetWrap): ("bold", "bold", "black"), ] - loop = urwid.MainLoop(main_frame, palette) + loop = urwid.MainLoop( + main_frame, palette, unhandled_input=self.unhandled_input + ) loop.run() diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 7e36d2c..cea0ecd 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -725,6 +725,14 @@ TRANSLATIONS = { "fr": "Tests unitaires ERPLibre", "en": "ERPLibre unit tests", }, + "Mail unit tests": { + "fr": "Tests unitaires courriel", + "en": "Mail unit tests", + }, + "Analyse unit tests": { + "fr": "Tests unitaires analyse", + "en": "Analyse unit tests", + }, "Running unit tests": { "fr": "Exécution des tests unitaires", "en": "Running unit tests", @@ -4207,6 +4215,24 @@ TRANSLATIONS = { "fr": "Aucun coffre disponible : installez pykeepass ou déverrouillez un trousseau système.", "en": "No vault available: install pykeepass or unlock a system keyring.", }, + # Coffre KeePass, côté infrastructure partagée (`kdbx_manager`) : ces + # messages servent à TOUT le CLI, pas seulement au courriel. + "kdbx_vault_is": { + "fr": "Coffre KeePass :", + "en": "KeePass vault:", + }, + "kdbx_ask_password": { + "fr": "Mot de passe du coffre (vide pour abandonner) : ", + "en": "Vault password (empty to give up): ", + }, + "kdbx_wrong_password": { + "fr": "Mot de passe incorrect pour ce coffre KeePass.", + "en": "Wrong password for this KeePass vault.", + }, + "kdbx_give_up": { + "fr": "Coffre non ouvert : on abandonne.", + "en": "Vault not opened: giving up.", + }, "mail_kdbx_none_configured": { "fr": "Aucun fichier kdbx n'est configuré.", "en": "No kdbx file is configured.", @@ -4587,6 +4613,17 @@ TRANSLATIONS = { "fr": "Mot de passe d'application : ", "en": "App password: ", }, + "mail_err_password_not_ascii": { + "fr": ( + "le mot de passe contient un caractère non ASCII, que ce client" + " IMAP ne sait pas transmettre — il n'a pas été envoyé au" + " serveur" + ), + "en": ( + "the password contains a non-ASCII character this IMAP client" + " cannot transmit — it was never sent to the server" + ), + }, "mail_err_no_kdbx_configured": { "fr": "aucun fichier kdbx configuré", "en": "no kdbx file configured", diff --git a/test/test_execute.py b/test/test_execute.py index d15bd5c..1794987 100644 --- a/test/test_execute.py +++ b/test/test_execute.py @@ -130,7 +130,11 @@ class TestExecCommandLive(unittest.TestCase): source_odoo="", quiet=True, ) - self.assertEqual(result, -1) + # `1`, pas `-1` : e24b185 a rendu à ce chemin la FORME que + # l'appelant demande (un tuple s'il en attend un) et en a profité + # pour donner un vrai code de sortie. Aucun appelant ne compare à + # -1, qui n'est d'ailleurs pas un code de sortie valide. + self.assertEqual(result, 1) def test_single_source_odoo_with_version(self): status, cmd = self.exe.exec_command_live( diff --git a/test/test_kdbx_manager.py b/test/test_kdbx_manager.py new file mode 100644 index 0000000..716a04f --- /dev/null +++ b/test/test_kdbx_manager.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Ouverture du coffre KeePass depuis le CLI. + +Signalé à l'usage : une mauvaise saisie affichait une trace `construct` de +quarante lignes, puis `pykeepass.exceptions.CredentialsError`, et tuait le +CLI — `make: *** Error 1`. L'invite disait par ailleurs `enter_password`, +la clé i18n brute, sans nommer ce qu'elle demandait. + +Un mot de passe refusé est le cas NORMAL de cette fonction : elle doit le +dire, laisser recommencer, et laisser partir. +""" +import os +import tempfile +import unittest +from unittest.mock import MagicMock, patch + +from pykeepass import create_database + +from script.todo.kdbx_manager import KdbxManager + + +class KdbxCase(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.chemin = os.path.join(self.tmp.name, "coffre.kdbx") + create_database(self.chemin, password="bon") + + def tearDown(self): + self.tmp.cleanup() + + def _manager(self, mot_de_passe_configure=None): + config = MagicMock() + config.get_config_value.side_effect = lambda keys: ( + self.chemin if keys == ["kdbx", "path"] else mot_de_passe_configure + ) + return KdbxManager(config) + + def _saisies(self, *reponses): + """Renvoie (résultat, texte affiché) pour une suite de saisies.""" + vues = [] + with patch("getpass.getpass", side_effect=list(reponses)), patch( + "builtins.print", + side_effect=lambda *a: vues.append(" ".join(map(str, a))), + ): + resultat = self._manager().get_kdbx() + return resultat, "\n".join(vues) + + +class TestWrongPasswordIsToldNotRaised(KdbxCase): + def test_three_refusals_give_up_without_raising(self): + resultat, vu = self._saisies("faux1", "faux2", "faux3") + self.assertIsNone(resultat) + self.assertEqual(vu.count("Mot de passe incorrect"), 3) + + def test_the_message_names_keepass_not_the_library_error(self): + """« Invalid credentials » ne dit pas DE QUOI on parle : l'erreur + d'authentification du serveur de courriel a exactement le même + libellé. Le mot « KeePass » est ce qui les distingue.""" + _, vu = self._saisies("faux", "") + self.assertIn("KeePass", vu) + self.assertNotIn("CredentialsError", vu) + self.assertNotIn("Traceback", vu) + + def test_the_prompt_says_which_vault_it_wants_to_open(self): + """L'invite affichait `enter_password`, une clé i18n absente de la + table. Elle doit nommer le fichier : plusieurs coffres peuvent + exister, et rien ne disait lequel était demandé.""" + _, vu = self._saisies("faux", "") + self.assertIn(self.chemin, vu) + self.assertNotIn("enter_password", vu) + + +class TestGivingUpIsPossible(KdbxCase): + def test_an_empty_entry_gives_up_immediately(self): + """Sans porte de sortie, la seule façon de quitter était de tuer le + programme — ce que la trace faisait, mais par accident.""" + resultat, vu = self._saisies("") + self.assertIsNone(resultat) + self.assertIn("abandonne", vu) + + def test_giving_up_asks_only_once(self): + appels = [] + with patch( + "getpass.getpass", side_effect=lambda **k: appels.append(1) or "" + ), patch("builtins.print"): + self._manager().get_kdbx() + self.assertEqual(len(appels), 1) + + +class TestRecoveryAndSuccess(KdbxCase): + def test_a_wrong_try_does_not_prevent_a_later_good_one(self): + """Le contrôle POSITIF : sans lui, une fonction qui refuserait + TOUJOURS passerait les tests ci-dessus.""" + resultat, _ = self._saisies("faux", "bon") + self.assertIsNotNone(resultat) + + def test_a_good_password_opens_the_vault_at_once(self): + resultat, _ = self._saisies("bon") + self.assertIsNotNone(resultat) + + def test_a_wrong_password_from_the_configuration_is_reported(self): + """Chemin sans saisie : personne à qui redemander, mais la trace ne + doit pas remonter pour autant.""" + vues = [] + with patch( + "builtins.print", + side_effect=lambda *a: vues.append(" ".join(map(str, a))), + ): + resultat = self._manager("mauvais").get_kdbx() + self.assertIsNone(resultat) + self.assertIn("KeePass", "\n".join(vues)) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_todo.py b/test/test_todo.py index d2790e4..e98c47e 100644 --- a/test/test_todo.py +++ b/test/test_todo.py @@ -8,6 +8,7 @@ import subprocess import tempfile import unittest from pathlib import Path +from unittest.mock import MagicMock, mock_open, patch from script.todo.todo import ( ANDROID_DIR, @@ -181,6 +182,9 @@ class TestOnDirSelected(unittest.TestCase): todo = TODO() todo.on_dir_selected("/some/path") self.assertEqual(todo.dir_path, "/some/path") + + +class TestExecuteFromConfiguration(unittest.TestCase): def test_with_command(self): todo = TODO() todo.execute = MagicMock() @@ -188,6 +192,51 @@ class TestOnDirSelected(unittest.TestCase): todo.execute_from_configuration(dct) todo.execute.exec_command_live.assert_called() + def test_every_command_entry_of_the_real_config_is_reachable(self): + """Le dict synthétique du test précédent ne suffisait pas. + + `4fc15c3` a renommé la clé cherchée par le code en « Command: », + le libellé affiché. Plus aucune entrée de todo.json ne + correspondait, et « Open ERPLibre with TODO 🤖 » ne faisait plus + rien — sans erreur, le `if` étant simplement faux. Seule la VRAIE + configuration relie les deux côtés. + """ + with open(CONFIG_FILE) as fh: + config = json.load(fh) + + entrees = [] + + def parcourir(noeud): + if isinstance(noeud, dict): + if "command" in noeud: + entrees.append(noeud) + for valeur in noeud.values(): + parcourir(valeur) + elif isinstance(noeud, list): + for element in noeud: + parcourir(element) + + parcourir(config) + self.assertTrue(entrees, "todo.json n'a plus d'entrée `command`") + + for entree in entrees: + todo = TODO() + todo.execute = MagicMock() + todo.execute_from_configuration(entree) + self.assertTrue( + todo.execute.exec_command_live.called, + f"entrée ignorée en silence : {entree.get('command')}", + ) + + def test_with_makefile_cmd(self): + todo = TODO() + todo.execute = MagicMock() + todo.execute.exec_command_live.return_value = 0 + dct = {"makefile_cmd": "run_test"} + todo.execute_from_configuration(dct) + call_args = todo.execute.exec_command_live.call_args + self.assertIn("make run_test", call_args[0][0]) + def test_makefile_cmd_ignored_when_flag(self): todo = TODO() todo.execute = MagicMock() @@ -266,6 +315,9 @@ class TestProcessKillGitDaemon(unittest.TestCase): cmd = todo.execute.exec_command_live.call_args[0][0] self.assertIn("pkill", cmd) self.assertIn("git daemon", cmd) + + +class TestExecuteUnitTests(unittest.TestCase): def test_success_path(self): todo = TODO() todo.execute = MagicMock() @@ -281,6 +333,76 @@ class TestProcessKillGitDaemon(unittest.TestCase): todo.execute.exec_command_live.return_value = (1, ["FAIL"]) with patch("builtins.print") as mock_print: todo.execute_unit_tests() + # Verify it was called - error handling path + + def test_stdout_is_unbuffered_so_the_verdict_lands_last(self): + """Signalé à l'usage : « pas clair si les tests ont passé ». + + unittest écrit son verdict sur stderr et les tests impriment sur + stdout ; capturés ensemble, le stdout tamponné se déversait après + le « OK ». Le lecteur voyait donc du bruit en dernier, pas le + résultat. + """ + todo = TODO() + todo.execute = MagicMock() + todo.execute.exec_command_live.return_value = (0, ["OK"]) + with patch("builtins.print"): + todo.execute_unit_tests() + cmd = todo.execute.exec_command_live.call_args[0][0] + self.assertIn("python -u -m unittest", cmd) + + def test_the_pattern_reaches_the_command(self): + todo = TODO() + todo.execute = MagicMock() + todo.execute.exec_command_live.return_value = (0, ["OK"]) + with patch("builtins.print"): + todo.execute_unit_tests("test_mail*.py") + cmd = todo.execute.exec_command_live.call_args[0][0] + self.assertIn("-p 'test_mail*.py'", cmd) + + def test_the_default_pattern_is_still_the_whole_suite(self): + """La signature a gagné un paramètre : l'entrée [3] ne doit pas + s'être mise à ne lancer qu'un sous-ensemble en silence.""" + todo = TODO() + todo.execute = MagicMock() + todo.execute.exec_command_live.return_value = (0, ["OK"]) + with patch("builtins.print"): + todo.execute_unit_tests() + cmd = todo.execute.exec_command_live.call_args[0][0] + self.assertIn("-p 'test_*.py'", cmd) + + +class TestTestMenuDispatch(unittest.TestCase): + """Le câblage des entrées, pas leur contenu. + + Un `elif` qui pointe le mauvais motif lancerait une suite verte sans + rien tester de ce que l'utilisateur a demandé — panne silencieuse que + seul ce test attrape. + """ + + def _choose(self, entry): + todo = TODO() + with patch.object( + todo, "execute_unit_tests" + ) as mock_run, patch.object(todo, "execute_test_module"), patch( + "click.prompt", side_effect=[entry, "0"] + ), patch( + "builtins.print" + ): + todo.prompt_execute_test() + return mock_run + + def test_entry_4_runs_the_mail_tests(self): + self.assertEqual(self._choose("4").call_args[0], ("test_mail*.py",)) + + def test_entry_5_runs_the_analyse_tests(self): + self.assertEqual(self._choose("5").call_args[0], ("test_analyse*.py",)) + + def test_entry_3_still_runs_everything(self): + self.assertEqual(self._choose("3").call_args[0], ()) + + +class TestKdbxGetExtraCommandUser(unittest.TestCase): def test_empty_kdbx_key(self): todo = TODO() result = todo.kdbx_manager.get_extra_command_user("") @@ -298,6 +420,50 @@ class TestProcessKillGitDaemon(unittest.TestCase): self.assertEqual(result, "") +class TestSetupClaudeCommit(unittest.TestCase): + """Le déploiement d'une commande `/…` dans ~/.claude/commands. + + La méthode a été généralisée depuis : elle prend le nom de la commande + et son gabarit, et quand la cible existe elle DEMANDE confirmation au + lieu de passer son tour. Le test ne détournait pas `input` — il aurait + bloqué si l'appel n'avait pas échoué avant. + """ + + def test_existing_file_and_refusal_writes_nothing(self): + todo = TODO() + with patch("os.path.exists", return_value=True), patch( + "builtins.input", return_value="n" + ), patch("builtins.open") as mock_open, patch( + "os.makedirs" + ) as mock_makedirs, patch( + "builtins.print" + ): + todo._setup_claude_command( + "commit", "template_claude_commands_commit.md" + ) + # Un refus doit sortir AVANT toute écriture : ni lecture du gabarit, + # ni création du dossier. Sans ces deux assertions, le test passait + # aussi bien si la méthode écrasait le fichier. + mock_open.assert_not_called() + mock_makedirs.assert_not_called() + + def test_existing_file_and_acceptance_writes(self): + """Le pendant : sans lui, la méthode pourrait ne JAMAIS écrire et + le test ci-dessus resterait vert.""" + todo = TODO() + with patch("os.path.exists", return_value=True), patch( + "builtins.input", return_value="y" + ), patch("builtins.open", mock_open(read_data="gabarit")), patch( + "os.makedirs" + ) as mock_makedirs, patch( + "builtins.print" + ): + todo._setup_claude_command( + "commit", "template_claude_commands_commit.md" + ) + mock_makedirs.assert_called_once() + + class TestSelectDatabase(unittest.TestCase): @patch("script.todo.database_manager.click") def test_select_database_returns_name(self, mock_click): diff --git a/test/test_todo_file_browser.py b/test/test_todo_file_browser.py new file mode 100644 index 0000000..7953fb3 --- /dev/null +++ b/test/test_todo_file_browser.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Le sélecteur de fichier : choisir doit fermer l'écran, annuler doit exister. + +Le défaut que ces tests verrouillent : `select_file` appelait bien le callback +mais ne quittait pas la boucle urwid. Le choix était donc enregistré et +l'écran restait ouvert, sans aucune touche pour le fermer — le sélecteur +paraissait figé au moment précis où il venait de faire son travail. Seul +Ctrl+C en sortait, ce qui abandonnait l'opération. + +`ExitMainLoop` est ce qui termine une boucle urwid : la lever EST la +fermeture. Ces tests l'attendent donc comme un succès, pas comme une erreur. +""" + +import os +import tempfile +import unittest + +import urwid + +from script.todo.todo_file_browser import FileBrowser + + +class BrowserCase(unittest.TestCase): + def setUp(self): + self.directory = tempfile.mkdtemp() + open(os.path.join(self.directory, "sauvegarde.zip"), "w").close() + os.mkdir(os.path.join(self.directory, "sous_dossier")) + self.chosen = [] + + def browser(self, **kwargs): + return FileBrowser(self.directory, self.chosen.append, **kwargs) + + def button(self, browser, label): + for widget in browser.list_walker: + if isinstance(widget, urwid.Button) and widget.label == label: + return widget + raise AssertionError(f"bouton '{label}' absent") + + +class TestChoosingClosesTheBrowser(BrowserCase): + def test_selecting_a_file_reports_it_and_ends_the_loop(self): + browser = self.browser() + with self.assertRaises(urwid.ExitMainLoop): + browser.select_file(self.button(browser, "sauvegarde.zip")) + self.assertEqual( + self.chosen, [os.path.join(self.directory, "sauvegarde.zip")] + ) + + def test_selecting_a_directory_reports_it_and_ends_the_loop(self): + browser = self.browser(open_dir=True) + with self.assertRaises(urwid.ExitMainLoop): + browser.select_directory(self.button(browser, ".")) + self.assertEqual(self.chosen, [self.directory]) + + +class TestNavigatingDoesNotClose(BrowserCase): + """Se déplacer n'est pas choisir : la boucle doit continuer.""" + + def test_entering_a_directory(self): + browser = self.browser() + browser.open_directory(self.button(browser, "sous_dossier/")) + self.assertEqual( + browser.current_path, os.path.join(self.directory, "sous_dossier") + ) + self.assertEqual(self.chosen, []) + + def test_going_up(self): + browser = self.browser() + browser.go_up_directory(None) + self.assertEqual(browser.current_path, os.path.dirname(self.directory)) + self.assertEqual(self.chosen, []) + + def test_arrow_keys_do_not_quit(self): + browser = self.browser() + for key in ("up", "down", "enter", "a"): + browser.unhandled_input(key) + self.assertEqual(self.chosen, []) + + +class TestCancelling(BrowserCase): + """Sortir sans choisir doit être possible. + + Toutes les autres sorties sélectionnent quelque chose. Un appelant qui + propose une solution de rechange — taper un chemin — ne devient + atteignable que si l'on peut renoncer. + """ + + def test_q_and_escape_quit_without_choosing(self): + for key in ("q", "Q", "esc"): + browser = self.browser() + with self.assertRaises(urwid.ExitMainLoop): + browser.unhandled_input(key) + self.assertEqual(self.chosen, [], key) + + +class TestListing(BrowserCase): + def test_files_are_offered_when_picking_a_file(self): + labels = [ + w.label + for w in self.browser().list_walker + if isinstance(w, urwid.Button) + ] + self.assertIn("sauvegarde.zip", labels) + self.assertIn("sous_dossier/", labels) + + def test_files_are_hidden_when_picking_a_directory(self): + labels = [ + w.label + for w in self.browser(open_dir=True).list_walker + if isinstance(w, urwid.Button) + ] + self.assertNotIn("sauvegarde.zip", labels) + self.assertIn(".", labels) + + def test_an_unreadable_directory_does_not_crash(self): + browser = self.browser() + browser.current_path = os.path.join(self.directory, "nowhere") + browser.refresh_list() + self.assertTrue(len(browser.list_walker) >= 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_todo_i18n.py b/test/test_todo_i18n.py index 1bb5777..00b428d 100644 --- a/test/test_todo_i18n.py +++ b/test/test_todo_i18n.py @@ -40,23 +40,23 @@ class TestT(unittest.TestCase): todo_i18n._current_lang = None def test_returns_french_when_lang_fr(self): - todo_i18n.set_lang("fr") - result = todo_i18n.t("menu_quit") + todo_i18n._current_lang = "fr" + result = todo_i18n.t("Quit") self.assertEqual(result, "Quitter") def test_returns_english_when_lang_en(self): - todo_i18n.set_lang("en") - result = todo_i18n.t("menu_quit") + todo_i18n._current_lang = "en" + result = todo_i18n.t("Quit") self.assertEqual(result, "Quit") def test_unknown_key_returns_key(self): - todo_i18n.set_lang("fr") + todo_i18n._current_lang = "fr" result = todo_i18n.t("nonexistent_key_xyz") self.assertEqual(result, "nonexistent_key_xyz") def test_fallback_to_fr_if_lang_missing(self): - todo_i18n.set_lang("de") - result = todo_i18n.t("menu_quit") + todo_i18n._current_lang = "de" + result = todo_i18n.t("Quit") self.assertEqual(result, "Quitter") @@ -81,9 +81,7 @@ class TestGetLang(unittest.TestCase): f.write('EL_LANG="en"\n') f.flush() try: - with patch.object( - todo_i18n, "ENV_VAR_FILE", f.name - ): + with patch.object(todo_i18n, "ENV_VAR_FILE", f.name): result = todo_i18n.get_lang() self.assertEqual(result, "en") finally: @@ -96,9 +94,7 @@ class TestGetLang(unittest.TestCase): f.write("EL_LANG=fr\n") f.flush() try: - with patch.object( - todo_i18n, "ENV_VAR_FILE", f.name - ): + with patch.object(todo_i18n, "ENV_VAR_FILE", f.name): result = todo_i18n.get_lang() self.assertEqual(result, "fr") finally: @@ -148,7 +144,11 @@ class TestSetLang(unittest.TestCase): todo_i18n._current_lang = None def test_sets_current_lang(self): - todo_i18n.set_lang("en") + # Détourner ENV_VAR_FILE comme le font les trois tests suivants : + # `set_lang()` PERSISTE, et sans ce détournement celui-ci écrivait + # dans le ./env_var.sh du dépôt, suivi par git. + with patch.object(todo_i18n, "ENV_VAR_FILE", "/nonexistent/path"): + todo_i18n.set_lang("en") self.assertEqual(todo_i18n._current_lang, "en") def test_persists_to_file_update(self): @@ -158,9 +158,7 @@ class TestSetLang(unittest.TestCase): f.write('EL_LANG="fr"\nOTHER=value\n') f.flush() try: - with patch.object( - todo_i18n, "ENV_VAR_FILE", f.name - ): + with patch.object(todo_i18n, "ENV_VAR_FILE", f.name): todo_i18n.set_lang("en") with open(f.name) as rf: content = rf.read() @@ -176,9 +174,7 @@ class TestSetLang(unittest.TestCase): f.write("SOME_VAR=123\n") f.flush() try: - with patch.object( - todo_i18n, "ENV_VAR_FILE", f.name - ): + with patch.object(todo_i18n, "ENV_VAR_FILE", f.name): todo_i18n.set_lang("en") with open(f.name) as rf: content = rf.read() @@ -207,9 +203,7 @@ class TestLangIsConfigured(unittest.TestCase): f.write('EL_LANG="fr"\n') f.flush() try: - with patch.object( - todo_i18n, "ENV_VAR_FILE", f.name - ): + with patch.object(todo_i18n, "ENV_VAR_FILE", f.name): result = todo_i18n.lang_is_configured() self.assertTrue(result) finally: @@ -222,9 +216,7 @@ class TestLangIsConfigured(unittest.TestCase): f.write("SOME_VAR=123\n") f.flush() try: - with patch.object( - todo_i18n, "ENV_VAR_FILE", f.name - ): + with patch.object(todo_i18n, "ENV_VAR_FILE", f.name): result = todo_i18n.lang_is_configured() self.assertFalse(result) finally: