2026-03-10 02:49:03 -04:00
|
|
|
#!/usr/bin/env python3
|
2026-03-11 23:15:07 -04:00
|
|
|
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
2026-03-10 02:49:03 -04:00
|
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
import os
|
2026-08-10 03:10:50 -04:00
|
|
|
import subprocess
|
2026-03-10 02:49:03 -04:00
|
|
|
import tempfile
|
|
|
|
|
import unittest
|
2026-08-10 03:10:50 -04:00
|
|
|
from pathlib import Path
|
2026-08-10 03:10:50 -04:00
|
|
|
from unittest.mock import MagicMock, mock_open, patch
|
2026-03-10 02:49:03 -04:00
|
|
|
|
|
|
|
|
from script.todo.todo import (
|
|
|
|
|
ANDROID_DIR,
|
|
|
|
|
CONFIG_FILE,
|
|
|
|
|
CONFIG_OVERRIDE_FILE,
|
|
|
|
|
ENABLE_CRASH,
|
2026-03-10 03:06:07 -04:00
|
|
|
ERROR_LOG_PATH,
|
2026-03-10 02:49:03 -04:00
|
|
|
GRADLE_FILE,
|
|
|
|
|
LOGO_ASCII_FILE,
|
|
|
|
|
MOBILE_HOME_PATH,
|
|
|
|
|
STRINGS_FILE,
|
|
|
|
|
TODO,
|
2026-03-10 03:06:07 -04:00
|
|
|
VENV_ERPLIBRE,
|
2026-03-10 03:59:35 -04:00
|
|
|
)
|
|
|
|
|
from script.todo.version_manager import (
|
|
|
|
|
INSTALLED_ODOO_VERSION_FILE,
|
|
|
|
|
ODOO_VERSION_FILE,
|
2026-03-10 02:49:03 -04:00
|
|
|
VERSION_DATA_FILE,
|
2026-03-10 03:59:35 -04:00
|
|
|
get_odoo_version,
|
2026-03-10 02:49:03 -04:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTODOInit(unittest.TestCase):
|
|
|
|
|
def test_initial_attributes(self):
|
|
|
|
|
todo = TODO()
|
|
|
|
|
self.assertIsNone(todo.dir_path)
|
2026-03-10 03:45:11 -04:00
|
|
|
self.assertIsNone(todo.selected_file_path)
|
2026-03-10 02:49:03 -04:00
|
|
|
self.assertIsNotNone(todo.config_file)
|
|
|
|
|
self.assertIsNotNone(todo.execute)
|
2026-03-10 03:59:35 -04:00
|
|
|
self.assertIsNotNone(todo.kdbx_manager)
|
2026-03-10 02:49:03 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestFillHelpInfo(unittest.TestCase):
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.todo = TODO()
|
|
|
|
|
|
|
|
|
|
@patch("script.todo.todo.t")
|
|
|
|
|
def test_basic_help_info(self, mock_t):
|
|
|
|
|
mock_t.side_effect = lambda k: {
|
|
|
|
|
"command": "Command:",
|
|
|
|
|
"back": "Back",
|
|
|
|
|
}.get(k, k)
|
2026-03-10 04:23:27 -04:00
|
|
|
choices = [
|
2026-03-10 02:49:03 -04:00
|
|
|
{"prompt_description": "Option A"},
|
|
|
|
|
{"prompt_description": "Option B"},
|
|
|
|
|
]
|
2026-03-10 04:23:27 -04:00
|
|
|
result = self.todo.fill_help_info(choices)
|
2026-03-10 02:49:03 -04:00
|
|
|
self.assertIn("[1] Option A", result)
|
|
|
|
|
self.assertIn("[2] Option B", result)
|
|
|
|
|
self.assertIn("[0] Back", result)
|
|
|
|
|
|
|
|
|
|
@patch("script.todo.todo.t")
|
|
|
|
|
def test_with_prompt_description_key(self, mock_t):
|
|
|
|
|
mock_t.side_effect = lambda k: {
|
|
|
|
|
"command": "Command:",
|
|
|
|
|
"back": "Back",
|
|
|
|
|
"my_key": "Translated Description",
|
|
|
|
|
}.get(k, k)
|
2026-03-10 04:23:27 -04:00
|
|
|
choices = [
|
2026-03-10 02:49:03 -04:00
|
|
|
{
|
|
|
|
|
"prompt_description": "fallback",
|
|
|
|
|
"prompt_description_key": "my_key",
|
|
|
|
|
},
|
|
|
|
|
]
|
2026-03-10 04:23:27 -04:00
|
|
|
result = self.todo.fill_help_info(choices)
|
2026-03-10 02:49:03 -04:00
|
|
|
self.assertIn("[1] Translated Description", result)
|
|
|
|
|
|
|
|
|
|
@patch("script.todo.todo.t")
|
|
|
|
|
def test_empty_list(self, mock_t):
|
|
|
|
|
mock_t.side_effect = lambda k: {
|
|
|
|
|
"command": "Command:",
|
|
|
|
|
"back": "Back",
|
|
|
|
|
}.get(k, k)
|
|
|
|
|
result = self.todo.fill_help_info([])
|
|
|
|
|
self.assertIn("Command:", result)
|
|
|
|
|
self.assertIn("[0] Back", result)
|
|
|
|
|
self.assertNotIn("[1]", result)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestGetOdooVersion(unittest.TestCase):
|
|
|
|
|
def test_reads_version_data(self):
|
|
|
|
|
version_data = {
|
|
|
|
|
"odoo18.0_python3.12.10": {
|
|
|
|
|
"odoo_version": "18.0",
|
|
|
|
|
"python_version": "3.12.10",
|
|
|
|
|
"default": True,
|
|
|
|
|
"is_deprecated": False,
|
|
|
|
|
},
|
|
|
|
|
"odoo16.0_python3.10.18": {
|
|
|
|
|
"odoo_version": "16.0",
|
|
|
|
|
"python_version": "3.10.18",
|
|
|
|
|
"default": False,
|
|
|
|
|
"is_deprecated": False,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
|
|
|
version_file = os.path.join(tmpdir, "version.json")
|
|
|
|
|
with open(version_file, "w") as f:
|
|
|
|
|
json.dump(version_data, f)
|
|
|
|
|
|
|
|
|
|
odoo_version_file = os.path.join(tmpdir, ".odoo-version")
|
|
|
|
|
with open(odoo_version_file, "w") as f:
|
|
|
|
|
f.write("18.0")
|
|
|
|
|
|
|
|
|
|
with patch(
|
2026-03-10 03:59:35 -04:00
|
|
|
"script.todo.version_manager.VERSION_DATA_FILE", version_file
|
2026-03-10 02:49:03 -04:00
|
|
|
), patch(
|
2026-03-10 03:59:35 -04:00
|
|
|
"script.todo.version_manager.INSTALLED_ODOO_VERSION_FILE",
|
2026-03-10 02:49:03 -04:00
|
|
|
os.path.join(tmpdir, "nonexistent.txt"),
|
|
|
|
|
), patch(
|
2026-03-10 03:59:35 -04:00
|
|
|
"script.todo.version_manager.ODOO_VERSION_FILE",
|
2026-03-10 02:49:03 -04:00
|
|
|
odoo_version_file,
|
|
|
|
|
):
|
2026-03-10 04:23:27 -04:00
|
|
|
versions, installed, odoo_current = get_odoo_version()
|
2026-03-10 02:49:03 -04:00
|
|
|
|
2026-03-10 04:23:27 -04:00
|
|
|
self.assertEqual(len(versions), 2)
|
2026-03-10 02:49:03 -04:00
|
|
|
self.assertEqual(odoo_current, "odoo18.0")
|
|
|
|
|
# Check erplibre_version was added
|
2026-03-10 04:23:27 -04:00
|
|
|
names = [v["erplibre_version"] for v in versions]
|
2026-03-10 02:49:03 -04:00
|
|
|
self.assertIn("odoo18.0_python3.12.10", names)
|
|
|
|
|
self.assertIn("odoo16.0_python3.10.18", names)
|
|
|
|
|
|
|
|
|
|
def test_installed_versions_read(self):
|
|
|
|
|
version_data = {
|
|
|
|
|
"odoo18.0_python3.12.10": {
|
|
|
|
|
"odoo_version": "18.0",
|
|
|
|
|
"python_version": "3.12.10",
|
|
|
|
|
"default": True,
|
|
|
|
|
"is_deprecated": False,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
|
|
|
version_file = os.path.join(tmpdir, "version.json")
|
|
|
|
|
with open(version_file, "w") as f:
|
|
|
|
|
json.dump(version_data, f)
|
|
|
|
|
|
|
|
|
|
installed_file = os.path.join(tmpdir, "installed.txt")
|
|
|
|
|
with open(installed_file, "w") as f:
|
|
|
|
|
f.write("odoo18.0\nodoo16.0\n")
|
|
|
|
|
|
|
|
|
|
with patch(
|
2026-03-10 03:59:35 -04:00
|
|
|
"script.todo.version_manager.VERSION_DATA_FILE", version_file
|
2026-03-10 02:49:03 -04:00
|
|
|
), patch(
|
2026-03-10 03:59:35 -04:00
|
|
|
"script.todo.version_manager.INSTALLED_ODOO_VERSION_FILE",
|
2026-03-10 02:49:03 -04:00
|
|
|
installed_file,
|
|
|
|
|
), patch(
|
2026-03-10 03:59:35 -04:00
|
|
|
"script.todo.version_manager.ODOO_VERSION_FILE",
|
2026-03-10 02:49:03 -04:00
|
|
|
os.path.join(tmpdir, "nonexistent"),
|
|
|
|
|
):
|
2026-03-10 04:23:27 -04:00
|
|
|
versions, installed, odoo_current = get_odoo_version()
|
2026-03-10 02:49:03 -04:00
|
|
|
|
2026-03-10 04:23:27 -04:00
|
|
|
self.assertEqual(installed, ["odoo16.0", "odoo18.0"])
|
2026-03-10 02:49:03 -04:00
|
|
|
self.assertIsNone(odoo_current)
|
|
|
|
|
|
|
|
|
|
def test_no_version_data_raises(self):
|
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
|
|
|
version_file = os.path.join(tmpdir, "empty.json")
|
|
|
|
|
with open(version_file, "w") as f:
|
|
|
|
|
json.dump({}, f)
|
|
|
|
|
|
2026-03-10 04:05:04 -04:00
|
|
|
with patch(
|
|
|
|
|
"script.todo.version_manager.VERSION_DATA_FILE", version_file
|
|
|
|
|
):
|
2026-03-10 02:49:03 -04:00
|
|
|
with self.assertRaises(Exception):
|
2026-03-10 03:59:35 -04:00
|
|
|
get_odoo_version()
|
2026-03-10 02:49:03 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestOnDirSelected(unittest.TestCase):
|
|
|
|
|
@patch("script.todo.todo.todo_file_browser", create=True)
|
|
|
|
|
def test_sets_dir_path(self, mock_browser):
|
|
|
|
|
todo = TODO()
|
|
|
|
|
todo.on_dir_selected("/some/path")
|
|
|
|
|
self.assertEqual(todo.dir_path, "/some/path")
|
2026-08-10 03:10:50 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestExecuteFromConfiguration(unittest.TestCase):
|
2026-03-10 02:49:03 -04:00
|
|
|
def test_with_command(self):
|
|
|
|
|
todo = TODO()
|
|
|
|
|
todo.execute = MagicMock()
|
|
|
|
|
dct = {"command": "./run.sh"}
|
|
|
|
|
todo.execute_from_configuration(dct)
|
|
|
|
|
todo.execute.exec_command_live.assert_called()
|
|
|
|
|
|
2026-08-10 03:10:50 -04:00
|
|
|
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])
|
|
|
|
|
|
2026-03-10 02:49:03 -04:00
|
|
|
def test_makefile_cmd_ignored_when_flag(self):
|
|
|
|
|
todo = TODO()
|
|
|
|
|
todo.execute = MagicMock()
|
|
|
|
|
dct = {"makefile_cmd": "run_test"}
|
|
|
|
|
todo.execute_from_configuration(dct, ignore_makefile=True)
|
|
|
|
|
todo.execute.exec_command_live.assert_not_called()
|
|
|
|
|
|
|
|
|
|
def test_with_callback(self):
|
|
|
|
|
todo = TODO()
|
|
|
|
|
todo.execute = MagicMock()
|
|
|
|
|
callback = MagicMock()
|
|
|
|
|
dct = {"callback": callback}
|
|
|
|
|
todo.execute_from_configuration(dct)
|
|
|
|
|
callback.assert_called_once_with(dct)
|
|
|
|
|
|
|
|
|
|
def test_makefile_error_stops_execution(self):
|
|
|
|
|
todo = TODO()
|
|
|
|
|
todo.execute = MagicMock()
|
|
|
|
|
todo.execute.exec_command_live.return_value = 1
|
|
|
|
|
callback = MagicMock()
|
|
|
|
|
dct = {"makefile_cmd": "broken", "callback": callback}
|
|
|
|
|
todo.execute_from_configuration(dct)
|
|
|
|
|
callback.assert_not_called()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestConstants(unittest.TestCase):
|
|
|
|
|
def test_config_file_path(self):
|
|
|
|
|
self.assertEqual(CONFIG_FILE, "./script/todo/todo.json")
|
|
|
|
|
|
|
|
|
|
def test_config_override_path(self):
|
|
|
|
|
self.assertEqual(CONFIG_OVERRIDE_FILE, "./private/todo/todo.json")
|
|
|
|
|
|
|
|
|
|
def test_logo_path(self):
|
|
|
|
|
self.assertEqual(LOGO_ASCII_FILE, "./script/todo/logo_ascii.txt")
|
|
|
|
|
|
|
|
|
|
def test_venv_erplibre(self):
|
2026-03-10 03:06:07 -04:00
|
|
|
self.assertEqual(VENV_ERPLIBRE, ".venv.erplibre")
|
2026-03-10 02:49:03 -04:00
|
|
|
|
|
|
|
|
def test_file_error_path(self):
|
2026-03-10 03:06:07 -04:00
|
|
|
self.assertEqual(ERROR_LOG_PATH, ".erplibre.error.txt")
|
2026-03-10 02:49:03 -04:00
|
|
|
|
|
|
|
|
def test_version_data_file(self):
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
VERSION_DATA_FILE,
|
|
|
|
|
os.path.join("conf", "supported_version_erplibre.json"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_mobile_paths(self):
|
|
|
|
|
self.assertEqual(ANDROID_DIR, "android")
|
|
|
|
|
self.assertIn("mobile", MOBILE_HOME_PATH)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestDeployGitServer(unittest.TestCase):
|
|
|
|
|
def test_local_mode(self):
|
|
|
|
|
todo = TODO()
|
|
|
|
|
todo.execute = MagicMock()
|
|
|
|
|
todo._deploy_git_server(production_ready=False, action="init")
|
|
|
|
|
cmd = todo.execute.exec_command_live.call_args[0][0]
|
|
|
|
|
self.assertIn("--action init", cmd)
|
|
|
|
|
self.assertNotIn("--production-ready", cmd)
|
|
|
|
|
|
|
|
|
|
def test_production_mode(self):
|
|
|
|
|
todo = TODO()
|
|
|
|
|
todo.execute = MagicMock()
|
|
|
|
|
todo._deploy_git_server(production_ready=True, action="all")
|
|
|
|
|
cmd = todo.execute.exec_command_live.call_args[0][0]
|
|
|
|
|
self.assertIn("--production-ready", cmd)
|
|
|
|
|
self.assertIn("--action all", cmd)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestProcessKillGitDaemon(unittest.TestCase):
|
|
|
|
|
def test_calls_pkill(self):
|
|
|
|
|
todo = TODO()
|
|
|
|
|
todo.execute = MagicMock()
|
|
|
|
|
todo.process_kill_git_daemon()
|
|
|
|
|
cmd = todo.execute.exec_command_live.call_args[0][0]
|
|
|
|
|
self.assertIn("pkill", cmd)
|
|
|
|
|
self.assertIn("git daemon", cmd)
|
2026-08-10 03:10:50 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestExecuteUnitTests(unittest.TestCase):
|
2026-03-10 02:49:03 -04:00
|
|
|
def test_success_path(self):
|
|
|
|
|
todo = TODO()
|
|
|
|
|
todo.execute = MagicMock()
|
|
|
|
|
todo.execute.exec_command_live.return_value = (0, ["OK"])
|
|
|
|
|
with patch("builtins.print") as mock_print:
|
|
|
|
|
todo.execute_unit_tests()
|
|
|
|
|
cmd = todo.execute.exec_command_live.call_args[0][0]
|
|
|
|
|
self.assertIn("unittest discover", cmd)
|
|
|
|
|
|
|
|
|
|
def test_failure_path(self):
|
|
|
|
|
todo = TODO()
|
|
|
|
|
todo.execute = MagicMock()
|
|
|
|
|
todo.execute.exec_command_live.return_value = (1, ["FAIL"])
|
|
|
|
|
with patch("builtins.print") as mock_print:
|
|
|
|
|
todo.execute_unit_tests()
|
2026-08-10 03:10:50 -04:00
|
|
|
# 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):
|
2026-03-10 02:49:03 -04:00
|
|
|
def test_empty_kdbx_key(self):
|
|
|
|
|
todo = TODO()
|
2026-03-10 03:59:35 -04:00
|
|
|
result = todo.kdbx_manager.get_extra_command_user("")
|
2026-03-10 02:49:03 -04:00
|
|
|
self.assertEqual(result, "")
|
|
|
|
|
|
|
|
|
|
def test_none_kdbx_key(self):
|
|
|
|
|
todo = TODO()
|
2026-03-10 03:59:35 -04:00
|
|
|
result = todo.kdbx_manager.get_extra_command_user(None)
|
2026-03-10 02:49:03 -04:00
|
|
|
self.assertEqual(result, "")
|
|
|
|
|
|
|
|
|
|
def test_kdbx_not_available(self):
|
|
|
|
|
todo = TODO()
|
2026-03-10 03:59:35 -04:00
|
|
|
todo.kdbx_manager.get_kdbx = MagicMock(return_value=None)
|
|
|
|
|
result = todo.kdbx_manager.get_extra_command_user("some_key")
|
2026-03-10 02:49:03 -04:00
|
|
|
self.assertEqual(result, "")
|
|
|
|
|
|
|
|
|
|
|
2026-08-10 03:10:50 -04:00
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
|
2026-03-10 03:59:35 -04:00
|
|
|
class TestSelectDatabase(unittest.TestCase):
|
2026-03-10 04:05:04 -04:00
|
|
|
@patch("script.todo.database_manager.click")
|
2026-03-10 03:59:35 -04:00
|
|
|
def test_select_database_returns_name(self, mock_click):
|
|
|
|
|
todo = TODO()
|
2026-03-10 04:05:04 -04:00
|
|
|
todo.db_manager._execute = MagicMock()
|
|
|
|
|
todo.db_manager._execute.exec_command_live.return_value = (
|
2026-03-10 03:59:35 -04:00
|
|
|
0,
|
|
|
|
|
["db_test", "db_prod"],
|
|
|
|
|
)
|
|
|
|
|
mock_click.prompt.return_value = "1"
|
2026-03-10 04:05:04 -04:00
|
|
|
result = todo.db_manager.select_database()
|
2026-03-10 03:59:35 -04:00
|
|
|
self.assertEqual(result, "db_test")
|
|
|
|
|
|
2026-03-10 04:05:04 -04:00
|
|
|
@patch("script.todo.database_manager.click")
|
2026-03-10 03:59:35 -04:00
|
|
|
def test_select_database_returns_false_on_zero(self, mock_click):
|
|
|
|
|
todo = TODO()
|
2026-03-10 04:05:04 -04:00
|
|
|
todo.db_manager._execute = MagicMock()
|
|
|
|
|
todo.db_manager._execute.exec_command_live.return_value = (
|
|
|
|
|
0,
|
|
|
|
|
["db_test"],
|
|
|
|
|
)
|
2026-03-10 03:59:35 -04:00
|
|
|
mock_click.prompt.return_value = "0"
|
2026-03-10 04:05:04 -04:00
|
|
|
result = todo.db_manager.select_database()
|
2026-03-10 03:59:35 -04:00
|
|
|
self.assertFalse(result)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestRestoreFromDatabase(unittest.TestCase):
|
|
|
|
|
@patch("builtins.input")
|
|
|
|
|
def test_restore_by_filename(self, mock_input):
|
|
|
|
|
todo = TODO()
|
2026-03-10 04:05:04 -04:00
|
|
|
todo.db_manager._execute = MagicMock()
|
|
|
|
|
todo.db_manager._execute.exec_command_live.return_value = (
|
|
|
|
|
0,
|
|
|
|
|
[],
|
|
|
|
|
)
|
2026-03-10 03:59:35 -04:00
|
|
|
# status="1" (by filename), db name default, no neutralize
|
|
|
|
|
mock_input.side_effect = ["1", "", "n", "n"]
|
2026-03-10 04:05:04 -04:00
|
|
|
todo.db_manager.restore_from_database()
|
|
|
|
|
cmd = todo.db_manager._execute.exec_command_live.call_args_list[0][0][
|
|
|
|
|
0
|
|
|
|
|
]
|
2026-03-10 03:59:35 -04:00
|
|
|
self.assertIn("db_restore.py", cmd)
|
|
|
|
|
|
|
|
|
|
@patch("builtins.input")
|
|
|
|
|
def test_restore_with_neutralize(self, mock_input):
|
|
|
|
|
todo = TODO()
|
2026-03-10 04:05:04 -04:00
|
|
|
todo.db_manager._execute = MagicMock()
|
|
|
|
|
todo.db_manager._execute.exec_command_live.return_value = (
|
|
|
|
|
0,
|
|
|
|
|
[],
|
|
|
|
|
)
|
2026-03-10 03:59:35 -04:00
|
|
|
mock_input.side_effect = ["1", "mydb", "y", "n"]
|
2026-03-10 04:05:04 -04:00
|
|
|
todo.db_manager.restore_from_database()
|
|
|
|
|
cmd = todo.db_manager._execute.exec_command_live.call_args_list[0][0][
|
|
|
|
|
0
|
|
|
|
|
]
|
2026-03-10 03:59:35 -04:00
|
|
|
self.assertIn("--neutralize", cmd)
|
|
|
|
|
self.assertIn("mydb_neutralize", cmd)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestCreateBackupFromDatabase(unittest.TestCase):
|
2026-03-10 04:05:04 -04:00
|
|
|
@patch("script.todo.database_manager.click")
|
2026-03-10 03:59:35 -04:00
|
|
|
@patch("builtins.input")
|
|
|
|
|
def test_creates_backup_command(self, mock_input, mock_click):
|
|
|
|
|
todo = TODO()
|
2026-03-10 04:05:04 -04:00
|
|
|
todo.db_manager._execute = MagicMock()
|
|
|
|
|
todo.db_manager._execute.exec_command_live.return_value = (
|
2026-03-10 03:59:35 -04:00
|
|
|
0,
|
|
|
|
|
["test_db"],
|
|
|
|
|
)
|
|
|
|
|
mock_click.prompt.return_value = "1"
|
|
|
|
|
# backup name input
|
|
|
|
|
mock_input.return_value = "backup.zip"
|
2026-03-10 04:05:04 -04:00
|
|
|
todo.db_manager.create_backup_from_database()
|
|
|
|
|
cmd = todo.db_manager._execute.exec_command_live.call_args_list[-1][0][
|
|
|
|
|
0
|
|
|
|
|
]
|
2026-03-10 03:59:35 -04:00
|
|
|
self.assertIn("--backup", cmd)
|
|
|
|
|
self.assertIn("test_db", cmd)
|
|
|
|
|
|
|
|
|
|
|
2026-08-10 03:10:50 -04:00
|
|
|
class TestModuleLevelAbortExit(unittest.TestCase):
|
|
|
|
|
"""`click.exceptions.Abort` (raised by `click.prompt` on both Ctrl+C and
|
|
|
|
|
Ctrl+D/EOF - see click's own `termui.prompt_func`) is NOT a
|
|
|
|
|
`KeyboardInterrupt` subclass. Only the top-level menu's `click.prompt`
|
|
|
|
|
call is wrapped locally, inside `run()` (todo.py around line 149) -
|
|
|
|
|
every submenu (`prompt_assistant`, etc.) lets `Abort` propagate
|
|
|
|
|
uncaught. These tests drive the real script end to end (not a mock of
|
|
|
|
|
the dispatch chain) to prove the module-level guard around
|
|
|
|
|
`todo.run()` (todo.py around line 7159) now catches it too.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def _run_todo(self, stdin_text):
|
|
|
|
|
repo_root = Path(__file__).resolve().parent.parent
|
|
|
|
|
python_bin = repo_root / ".venv.erplibre" / "bin" / "python3"
|
|
|
|
|
env = os.environ.copy()
|
|
|
|
|
with tempfile.TemporaryDirectory() as home_dir:
|
|
|
|
|
env["HOME"] = home_dir
|
|
|
|
|
return subprocess.run(
|
|
|
|
|
[str(python_bin), "script/todo/todo.py"],
|
|
|
|
|
cwd=repo_root,
|
|
|
|
|
input=stdin_text,
|
|
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
|
|
|
|
env=env,
|
|
|
|
|
timeout=30,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_ctrl_d_in_a_submenu_exits_cleanly(self):
|
|
|
|
|
# "3" enters the Assistant submenu; the immediate EOF that follows
|
|
|
|
|
# raises Abort from a click.prompt() call that run() does not wrap.
|
|
|
|
|
result = self._run_todo("3\n")
|
|
|
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
|
|
|
self.assertNotIn("Traceback", result.stderr)
|
|
|
|
|
self.assertNotIn("click.exceptions.Abort", result.stderr)
|
|
|
|
|
|
|
|
|
|
def test_ctrl_d_on_the_top_menu_still_exits_cleanly(self):
|
|
|
|
|
# Regression guard: the pre-existing local handler in run() must
|
|
|
|
|
# keep working once the module-level guard is added alongside it.
|
|
|
|
|
result = self._run_todo("")
|
|
|
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
|
|
|
self.assertNotIn("Traceback", result.stderr)
|
|
|
|
|
|
|
|
|
|
|
2026-03-10 02:49:03 -04:00
|
|
|
if __name__ == "__main__":
|
|
|
|
|
unittest.main()
|