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: