[ADD] test: add unit tests for refactoring

Ensure safe refactoring by covering all 6 target scripts
with 121 tests: execute.py, todo_i18n.py,
kill_process_by_port.py, git_tool.py, and todo.py.

Generated by Claude Code 2.1.72 model claude-sonnet-4-6

Co-Authored-By: Mathieu Benoit <mathben@technolibre.ca>
This commit is contained in:
Mathieu Benoit 2026-03-10 02:49:03 -04:00
parent f78f578552
commit 054c12af6d
5 changed files with 1323 additions and 0 deletions

169
test/test_execute.py Normal file
View file

@ -0,0 +1,169 @@
#!/usr/bin/env python3
# © 2025 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
import os
import unittest
from unittest.mock import patch
from script.execute.execute import Execute
class TestExecuteInit(unittest.TestCase):
"""Test Execute class initialization."""
@patch("shutil.which")
def test_init_with_gnome_terminal(self, mock_which):
mock_which.side_effect = lambda x: (
"/usr/bin/gnome-terminal" if x == "gnome-terminal" else None
)
exe = Execute()
self.assertIn("gnome-terminal", exe.cmd_source_erplibre)
self.assertIn(".venv.erplibre", exe.cmd_source_erplibre)
self.assertIn("gnome-terminal", exe.cmd_source_default)
@patch("shutil.which")
def test_init_with_osascript(self, mock_which):
mock_which.side_effect = lambda x: (
"/usr/bin/osascript" if x == "osascript" else None
)
exe = Execute()
self.assertIn("osascript", exe.cmd_source_erplibre)
@patch("shutil.which", return_value=None)
def test_init_fallback_source(self, mock_which):
exe = Execute()
self.assertIn(".venv.erplibre/bin/activate", exe.cmd_source_erplibre)
self.assertEqual(exe.cmd_source_default, "")
class TestExecCommandLive(unittest.TestCase):
"""Test exec_command_live method."""
def setUp(self):
with patch("shutil.which", return_value=None):
self.exe = Execute()
def test_simple_command_returns_zero(self):
result = self.exe.exec_command_live(
"echo hello",
source_erplibre=False,
quiet=True,
)
self.assertEqual(result, 0)
def test_failing_command_returns_nonzero(self):
result = self.exe.exec_command_live(
"exit 42",
source_erplibre=False,
quiet=True,
)
self.assertEqual(result, 42)
def test_return_status_and_output(self):
status, output = self.exe.exec_command_live(
"echo hello",
source_erplibre=False,
quiet=True,
return_status_and_output=True,
)
self.assertEqual(status, 0)
self.assertEqual(output, ["hello"])
def test_return_status_and_output_multiline(self):
status, output = self.exe.exec_command_live(
"echo -e 'line1\nline2\nline3'",
source_erplibre=False,
quiet=True,
return_status_and_output=True,
)
self.assertEqual(status, 0)
self.assertEqual(output, ["line1", "line2", "line3"])
def test_return_status_and_command(self):
status, cmd = self.exe.exec_command_live(
"echo test",
source_erplibre=False,
quiet=True,
return_status_and_command=True,
)
self.assertEqual(status, 0)
self.assertEqual(cmd, "echo test")
def test_return_status_and_output_and_command(self):
status, cmd, output = self.exe.exec_command_live(
"echo result",
source_erplibre=False,
quiet=True,
return_status_and_output_and_command=True,
)
self.assertEqual(status, 0)
self.assertEqual(cmd, "echo result")
self.assertEqual(output, ["result"])
def test_source_erplibre_prepends_activate(self):
status, cmd = self.exe.exec_command_live(
"echo test",
source_erplibre=True,
quiet=True,
return_status_and_command=True,
)
self.assertIn(".venv.erplibre/bin/activate", cmd)
def test_single_source_erplibre(self):
status, cmd = self.exe.exec_command_live(
"echo test",
source_erplibre=False,
single_source_erplibre=True,
quiet=True,
return_status_and_command=True,
)
self.assertIn(".venv.erplibre/bin/activate", cmd)
self.assertIn("echo test", cmd)
def test_single_source_odoo_no_version_returns_error(self):
with patch("os.path.exists", return_value=False):
result = self.exe.exec_command_live(
"echo test",
source_erplibre=False,
single_source_odoo=True,
source_odoo="",
quiet=True,
)
self.assertEqual(result, -1)
def test_single_source_odoo_with_version(self):
status, cmd = self.exe.exec_command_live(
"echo test",
source_erplibre=False,
single_source_odoo=True,
source_odoo="odoo18",
quiet=True,
return_status_and_command=True,
)
self.assertIn(".venv.odoo18/bin/activate", cmd)
def test_new_env_passed_to_subprocess(self):
status, output = self.exe.exec_command_live(
"echo $MY_TEST_VAR",
source_erplibre=False,
quiet=True,
new_env={"MY_TEST_VAR": "test_value_123"},
return_status_and_output=True,
)
self.assertEqual(status, 0)
self.assertEqual(output, ["test_value_123"])
def test_empty_output_command(self):
status, output = self.exe.exec_command_live(
"true",
source_erplibre=False,
quiet=True,
return_status_and_output=True,
)
self.assertEqual(status, 0)
self.assertEqual(output, [])
if __name__ == "__main__":
unittest.main()

327
test/test_git_tool.py Normal file
View file

@ -0,0 +1,327 @@
#!/usr/bin/env python3
# © 2025 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
import os
import tempfile
import unittest
from collections import OrderedDict
from unittest.mock import MagicMock, mock_open, patch
from script.git.git_tool import (
CST_EL_GITHUB_TOKEN,
CST_FILE_SOURCE_REPO_ADDONS,
DEFAULT_PROJECT_NAME,
DEFAULT_REMOTE_URL,
DEFAULT_WEBSITE,
GitTool,
Struct,
)
class TestStruct(unittest.TestCase):
def test_basic_attributes(self):
s = Struct(a=1, b="hello")
self.assertEqual(s.a, 1)
self.assertEqual(s.b, "hello")
def test_empty_struct(self):
s = Struct()
self.assertEqual(s.__dict__, {})
def test_override_existing(self):
s = Struct(x=10)
self.assertEqual(s.x, 10)
class TestGetUrl(unittest.TestCase):
def test_https_to_git(self):
url, url_https, url_git = GitTool.get_url(
"https://github.com/OCA/server-tools.git"
)
self.assertEqual(url, "https://github.com/OCA/server-tools.git")
self.assertEqual(url_https, "https://github.com/OCA/server-tools.git")
self.assertEqual(url_git, "git@github.com:OCA/server-tools.git")
def test_git_to_https(self):
url, url_https, url_git = GitTool.get_url(
"git@github.com:OCA/server-tools.git"
)
self.assertEqual(url_https, "https://github.com/OCA/server-tools.git")
self.assertEqual(url_git, "git@github.com:OCA/server-tools.git")
def test_https_without_git_suffix(self):
url, url_https, url_git = GitTool.get_url(
"https://github.com/ERPLibre/ERPLibre"
)
self.assertEqual(url_https, "https://github.com/ERPLibre/ERPLibre")
self.assertEqual(url_git, "git@github.com:ERPLibre/ERPLibre")
class TestGetTransformedRepoInfo(unittest.TestCase):
def setUp(self):
self.gt = GitTool()
def test_https_url_as_submodule(self):
result = self.gt.get_transformed_repo_info_from_url(
"https://github.com/OCA/server-tools.git",
repo_path=".",
get_obj=False,
)
self.assertEqual(result["organization"], "OCA")
self.assertEqual(result["repo_name"], "server-tools")
self.assertEqual(result["project_name"], "server-tools.git")
self.assertEqual(result["path"], "addons/OCA_server-tools")
self.assertTrue(result["is_submodule"])
def test_git_url_as_submodule(self):
result = self.gt.get_transformed_repo_info_from_url(
"git@github.com:ERPLibre/erplibre_addons.git",
repo_path=".",
get_obj=False,
)
self.assertEqual(result["organization"], "ERPLibre")
self.assertEqual(result["repo_name"], "erplibre_addons")
def test_not_submodule(self):
result = self.gt.get_transformed_repo_info_from_url(
"https://github.com/OCA/server-tools.git",
repo_path="/tmp/test",
get_obj=False,
is_submodule=False,
)
self.assertEqual(result["path"], "/tmp/test")
self.assertFalse(result["is_submodule"])
def test_get_obj_true_returns_struct(self):
result = self.gt.get_transformed_repo_info_from_url(
"https://github.com/OCA/web.git",
get_obj=True,
)
self.assertIsInstance(result, Struct)
self.assertEqual(result.organization, "OCA")
def test_organization_force(self):
result = self.gt.get_transformed_repo_info_from_url(
"https://github.com/OCA/server-tools.git",
get_obj=False,
organization_force="MyOrg",
)
self.assertEqual(result["organization"], "MyOrg")
self.assertEqual(result["original_organization"], "OCA")
self.assertIn("MyOrg", result["url_https"])
def test_custom_sub_path(self):
result = self.gt.get_transformed_repo_info_from_url(
"https://github.com/OCA/server-tools.git",
get_obj=False,
sub_path="custom",
)
self.assertEqual(result["path"], "custom/OCA_server-tools")
def test_empty_sub_path(self):
result = self.gt.get_transformed_repo_info_from_url(
"https://github.com/OCA/server-tools.git",
get_obj=False,
sub_path="",
)
self.assertEqual(result["path"], "server-tools")
def test_dot_sub_path(self):
result = self.gt.get_transformed_repo_info_from_url(
"https://github.com/OCA/server-tools.git",
get_obj=False,
sub_path=".",
)
self.assertEqual(result["path"], "server-tools")
def test_revision_and_clone_depth(self):
result = self.gt.get_transformed_repo_info_from_url(
"https://github.com/OCA/web.git",
get_obj=False,
revision="16.0",
clone_depth="1",
)
self.assertEqual(result["revision"], "16.0")
self.assertEqual(result["clone_depth"], "1")
def test_url_without_git_suffix(self):
result = self.gt.get_transformed_repo_info_from_url(
"https://github.com/OCA/server-tools",
get_obj=False,
)
self.assertEqual(result["repo_name"], "server-tools")
class TestDefaultProperties(unittest.TestCase):
def test_default_project_name(self):
gt = GitTool()
self.assertEqual(gt.default_project_name, DEFAULT_PROJECT_NAME)
def test_default_website(self):
gt = GitTool()
self.assertEqual(gt.default_website, DEFAULT_WEBSITE)
def test_default_remote_url(self):
gt = GitTool()
self.assertEqual(gt.default_remote_url, DEFAULT_REMOTE_URL)
@patch(
"builtins.open",
mock_open(read_data="18.0"),
)
def test_odoo_version(self):
gt = GitTool()
self.assertEqual(gt.odoo_version, "18.0")
@patch(
"builtins.open",
mock_open(read_data="16.0"),
)
def test_odoo_version_long(self):
gt = GitTool()
self.assertEqual(gt.odoo_version_long, "odoo16.0")
class TestStrInsert(unittest.TestCase):
def test_insert_middle(self):
result = GitTool.str_insert("abcdef", "XY", 3)
self.assertEqual(result, "abcXYdef")
def test_insert_beginning(self):
result = GitTool.str_insert("hello", "X", 0)
self.assertEqual(result, "Xhello")
def test_insert_end(self):
result = GitTool.str_insert("hello", "X", 5)
self.assertEqual(result, "helloX")
class TestGetProjectConfig(unittest.TestCase):
def test_reads_github_token(self):
content = (
"#!/bin/bash\n"
'EL_GITHUB_TOKEN="my_token_123"\n'
'OTHER_VAR="value"\n'
)
with tempfile.NamedTemporaryFile(
mode="w", suffix=".sh", delete=False, dir="/tmp"
) as f:
f.write(content)
f.flush()
tmpdir = os.path.dirname(f.name)
tmpname = os.path.basename(f.name)
try:
# We need env_var.sh in a directory
env_var_path = os.path.join(tmpdir, "env_var.sh")
os.rename(f.name, env_var_path)
result = GitTool.get_project_config(repo_path=tmpdir)
self.assertEqual(result[CST_EL_GITHUB_TOKEN], "my_token_123")
finally:
if os.path.exists(env_var_path):
os.unlink(env_var_path)
class TestGetRepoInfoSubmodule(unittest.TestCase):
def test_parses_gitmodules(self):
gitmodules_content = (
'[submodule "addons/OCA_server-tools"]\n'
"\turl = https://github.com/OCA/server-tools.git\n"
"\tpath = addons/OCA_server-tools\n"
"\n"
'[submodule "addons/OCA_web"]\n'
"\turl = https://github.com/OCA/web.git\n"
"\tpath = addons/OCA_web\n"
)
gt = GitTool()
with tempfile.TemporaryDirectory() as tmpdir:
gitmodules_path = os.path.join(tmpdir, ".gitmodules")
with open(gitmodules_path, "w") as f:
f.write(gitmodules_content)
result = gt.get_repo_info_submodule(
repo_path=tmpdir, add_root=False
)
self.assertEqual(len(result), 2)
names = [r["name"] for r in result]
self.assertIn("addons/OCA_server-tools", names)
self.assertIn("addons/OCA_web", names)
def test_single_submodule(self):
gitmodules_content = (
'[submodule "addons/test"]\n'
"\turl = https://github.com/Test/repo.git\n"
"\tpath = addons/test\n"
)
gt = GitTool()
with tempfile.TemporaryDirectory() as tmpdir:
with open(os.path.join(tmpdir, ".gitmodules"), "w") as f:
f.write(gitmodules_content)
result = gt.get_repo_info_submodule(repo_path=tmpdir)
self.assertEqual(len(result), 1)
self.assertEqual(result[0]["name"], "addons/test")
self.assertIn("https://", result[0]["url_https"])
self.assertIn("git@", result[0]["url_git"])
class TestGetManifestXmlInfo(unittest.TestCase):
def test_parses_manifest(self):
xml_content = """<?xml version="1.0" encoding="UTF-8"?>
<manifest>
<remote name="OCA" fetch="https://github.com/OCA/"/>
<default remote="OCA" revision="16.0"/>
<project name="server-tools.git" path="addons/OCA_server-tools"
remote="OCA" groups="odoo16.0"/>
</manifest>
"""
gt = GitTool()
with tempfile.NamedTemporaryFile(
mode="w", suffix=".xml", delete=False
) as f:
f.write(xml_content)
f.flush()
try:
dct_remote, dct_project, default_remote = (
gt.get_manifest_xml_info(filename=f.name)
)
self.assertIn("OCA", dct_remote)
self.assertIn("server-tools.git", dct_project)
self.assertEqual(default_remote["@remote"], "OCA")
finally:
os.unlink(f.name)
def test_empty_manifest(self):
xml_content = """<?xml version="1.0" encoding="UTF-8"?>
<manifest/>
"""
gt = GitTool()
with tempfile.NamedTemporaryFile(
mode="w", suffix=".xml", delete=False
) as f:
f.write(xml_content)
f.flush()
try:
dct_remote, dct_project, default_remote = (
gt.get_manifest_xml_info(filename=f.name)
)
self.assertEqual(dct_remote, {})
self.assertEqual(dct_project, {})
self.assertIsNone(default_remote)
finally:
os.unlink(f.name)
class TestConstants(unittest.TestCase):
def test_file_source_repo_addons(self):
self.assertEqual(CST_FILE_SOURCE_REPO_ADDONS, "source_repo_addons.csv")
def test_default_project_name(self):
self.assertEqual(DEFAULT_PROJECT_NAME, "ERPLibre")
def test_default_website(self):
self.assertEqual(DEFAULT_WEBSITE, "erplibre.ca")
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,251 @@
#!/usr/bin/env python3
# © 2025 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
import unittest
from unittest.mock import MagicMock, patch
import psutil
from script.process.kill_process_by_port import (
PROTECTED_NAMES,
STOP_PARENT_KILL,
choose_target,
find_listeners,
get_ancestry,
kill_process,
kill_tree,
proc_desc,
)
def _make_proc(pid, name="python", cmdline=None, username="user"):
"""Create a mock psutil.Process."""
p = MagicMock(spec=psutil.Process)
p.pid = pid
p.name.return_value = name
p.cmdline.return_value = cmdline or [name]
p.username.return_value = username
return p
class TestProcDesc(unittest.TestCase):
def test_normal_process(self):
p = _make_proc(123, "python", ["python", "app.py"])
result = proc_desc(p)
self.assertIn("pid=123", result)
self.assertIn("python app.py", result)
self.assertIn("user=user", result)
def test_empty_cmdline_uses_name(self):
p = _make_proc(42, "bash", [])
result = proc_desc(p)
self.assertIn("pid=42", result)
self.assertIn("name=bash", result)
def test_psutil_error_fallback(self):
p = MagicMock(spec=psutil.Process)
p.pid = 99
p.cmdline.side_effect = psutil.Error("gone")
result = proc_desc(p)
self.assertEqual(result, "pid=99")
class TestGetAncestry(unittest.TestCase):
@patch("script.process.kill_process_by_port.psutil.Process")
def test_nonexistent_pid(self, mock_proc_cls):
mock_proc_cls.side_effect = psutil.NoSuchProcess(99999)
chain = get_ancestry(99999)
self.assertEqual(chain, [])
@patch("script.process.kill_process_by_port.psutil.Process")
def test_chain_stops_at_pid_1(self, mock_proc_cls):
p_child = MagicMock()
p_child.pid = 100
p_child.ppid.return_value = 1
p_init = MagicMock()
p_init.pid = 1
mock_proc_cls.side_effect = [p_child, p_init]
chain = get_ancestry(100)
self.assertEqual(len(chain), 2)
self.assertEqual(chain[0].pid, 100)
self.assertEqual(chain[1].pid, 1)
@patch("script.process.kill_process_by_port.psutil.Process")
def test_chain_stops_at_ppid_zero(self, mock_proc_cls):
p = MagicMock()
p.pid = 50
p.ppid.return_value = 0
mock_proc_cls.return_value = p
chain = get_ancestry(50)
self.assertEqual(len(chain), 1)
self.assertEqual(chain[0].pid, 50)
class TestChooseTarget(unittest.TestCase):
def test_empty_chain_returns_none(self):
result = choose_target([], 1)
self.assertIsNone(result)
def test_stops_at_run_sh(self):
p1 = _make_proc(100, "python", ["python", "odoo-bin"])
p2 = _make_proc(99, "bash", ["./run.sh"])
p3 = _make_proc(1, "systemd", ["systemd"])
chain = [p1, p2, p3]
target, lst = choose_target(chain, 1)
self.assertEqual(target.pid, 99)
def test_stops_at_odoo_bin_sh(self):
p1 = _make_proc(200, "python", ["python", "odoo-bin"])
p2 = _make_proc(199, "bash", ["./odoo_bin.sh"])
chain = [p1, p2]
target, lst = choose_target(chain, 1)
self.assertEqual(target.pid, 199)
def test_no_stop_marker_uses_nb_parent(self):
p1 = _make_proc(300, "python", ["python"])
p2 = _make_proc(299, "bash", ["bash"])
p3 = _make_proc(1, "systemd", ["systemd"])
chain = [p1, p2, p3]
target, lst = choose_target(chain, 2)
self.assertEqual(target.pid, 299)
class TestKillProcess(unittest.TestCase):
def test_terminate_called(self):
p = _make_proc(10)
kill_process(p, force=False)
p.terminate.assert_called_once()
p.kill.assert_not_called()
def test_kill_called_with_force(self):
p = _make_proc(10)
kill_process(p, force=True)
p.kill.assert_called_once()
p.terminate.assert_not_called()
class TestKillTree(unittest.TestCase):
@patch("script.process.kill_process_by_port.psutil.wait_procs")
def test_kills_children_then_root(self, mock_wait):
root = _make_proc(10, "root_proc")
child1 = _make_proc(11, "child1")
child2 = _make_proc(12, "child2")
root.children.return_value = [child1, child2]
mock_wait.return_value = (
[root, child1, child2],
[],
)
alive = kill_tree(root, force=False)
child1.terminate.assert_called_once()
child2.terminate.assert_called_once()
root.terminate.assert_called_once()
self.assertEqual(alive, [])
@patch("script.process.kill_process_by_port.psutil.wait_procs")
def test_returns_alive_processes(self, mock_wait):
root = _make_proc(10)
root.children.return_value = []
mock_wait.return_value = ([], [root])
alive = kill_tree(root, force=True)
self.assertEqual(len(alive), 1)
@patch("script.process.kill_process_by_port.psutil.wait_procs")
def test_handles_psutil_error_on_child(self, mock_wait):
root = _make_proc(10)
child = _make_proc(11)
child.terminate.side_effect = psutil.NoSuchProcess(11)
root.children.return_value = [child]
mock_wait.return_value = ([root], [])
alive = kill_tree(root, force=False)
self.assertEqual(alive, [])
class TestFindListeners(unittest.TestCase):
@patch("script.process.kill_process_by_port.psutil.net_connections")
def test_finds_listening_pid(self, mock_conns):
conn = MagicMock()
conn.laddr = MagicMock()
conn.laddr.port = 8069
conn.status = psutil.CONN_LISTEN
conn.pid = 1234
mock_conns.return_value = [conn]
result = find_listeners(8069)
self.assertEqual(result, [1234])
@patch("script.process.kill_process_by_port.psutil.net_connections")
def test_ignores_different_port(self, mock_conns):
conn = MagicMock()
conn.laddr = MagicMock()
conn.laddr.port = 9999
conn.status = psutil.CONN_LISTEN
conn.pid = 1234
mock_conns.return_value = [conn]
result = find_listeners(8069)
self.assertEqual(result, [])
@patch("script.process.kill_process_by_port.psutil.net_connections")
def test_ignores_non_listen_status(self, mock_conns):
conn = MagicMock()
conn.laddr = MagicMock()
conn.laddr.port = 8069
conn.status = psutil.CONN_ESTABLISHED
conn.pid = 1234
mock_conns.return_value = [conn]
result = find_listeners(8069)
self.assertEqual(result, [])
@patch("script.process.kill_process_by_port.psutil.net_connections")
def test_no_connections(self, mock_conns):
mock_conns.return_value = []
result = find_listeners(8069)
self.assertEqual(result, [])
@patch("script.process.kill_process_by_port.psutil.net_connections")
def test_deduplicates_pids(self, mock_conns):
conn1 = MagicMock()
conn1.laddr = MagicMock()
conn1.laddr.port = 8069
conn1.status = psutil.CONN_LISTEN
conn1.pid = 100
conn2 = MagicMock()
conn2.laddr = MagicMock()
conn2.laddr.port = 8069
conn2.status = psutil.CONN_LISTEN
conn2.pid = 100
mock_conns.return_value = [conn1, conn2]
result = find_listeners(8069)
self.assertEqual(result, [100])
class TestProtectedNames(unittest.TestCase):
def test_systemd_is_protected(self):
self.assertIn("systemd", PROTECTED_NAMES)
def test_gnome_shell_is_protected(self):
self.assertIn("gnome-shell", PROTECTED_NAMES)
def test_sshd_is_protected(self):
self.assertIn("sshd", PROTECTED_NAMES)
class TestStopParentKill(unittest.TestCase):
def test_contains_run_sh(self):
self.assertIn("./run.sh", STOP_PARENT_KILL)
def test_contains_odoo_bin_sh(self):
self.assertIn("./odoo_bin.sh", STOP_PARENT_KILL)
if __name__ == "__main__":
unittest.main()

332
test/test_todo.py Normal file
View file

@ -0,0 +1,332 @@
#!/usr/bin/env python3
# © 2025 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
import json
import os
import tempfile
import unittest
from unittest.mock import MagicMock, patch
from script.todo.todo import (
ANDROID_DIR,
CONFIG_FILE,
CONFIG_OVERRIDE_FILE,
ENABLE_CRASH,
GRADLE_FILE,
INSTALLED_ODOO_VERSION_FILE,
LOGO_ASCII_FILE,
MOBILE_HOME_PATH,
ODOO_VERSION_FILE,
STRINGS_FILE,
TODO,
VERSION_DATA_FILE,
cst_venv_erplibre,
file_error_path,
)
class TestTODOInit(unittest.TestCase):
def test_initial_attributes(self):
todo = TODO()
self.assertIsNone(todo.dir_path)
self.assertIsNone(todo.kdbx)
self.assertIsNone(todo.file_path)
self.assertIsNotNone(todo.config_file)
self.assertIsNotNone(todo.execute)
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)
lst_choice = [
{"prompt_description": "Option A"},
{"prompt_description": "Option B"},
]
result = self.todo.fill_help_info(lst_choice)
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)
lst_choice = [
{
"prompt_description": "fallback",
"prompt_description_key": "my_key",
},
]
result = self.todo.fill_help_info(lst_choice)
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,
},
}
todo = TODO()
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(
"script.todo.todo.VERSION_DATA_FILE", version_file
), patch(
"script.todo.todo.INSTALLED_ODOO_VERSION_FILE",
os.path.join(tmpdir, "nonexistent.txt"),
), patch(
"script.todo.todo.ODOO_VERSION_FILE",
odoo_version_file,
):
lst_version, lst_installed, odoo_current = (
todo.get_odoo_version()
)
self.assertEqual(len(lst_version), 2)
self.assertEqual(odoo_current, "odoo18.0")
# Check erplibre_version was added
names = [v["erplibre_version"] for v in lst_version]
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,
},
}
todo = TODO()
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(
"script.todo.todo.VERSION_DATA_FILE", version_file
), patch(
"script.todo.todo.INSTALLED_ODOO_VERSION_FILE",
installed_file,
), patch(
"script.todo.todo.ODOO_VERSION_FILE",
os.path.join(tmpdir, "nonexistent"),
):
lst_version, lst_installed, odoo_current = (
todo.get_odoo_version()
)
self.assertEqual(lst_installed, ["odoo16.0", "odoo18.0"])
self.assertIsNone(odoo_current)
def test_no_version_data_raises(self):
todo = TODO()
with tempfile.TemporaryDirectory() as tmpdir:
version_file = os.path.join(tmpdir, "empty.json")
with open(version_file, "w") as f:
json.dump({}, f)
with patch("script.todo.todo.VERSION_DATA_FILE", version_file):
with self.assertRaises(Exception):
todo.get_odoo_version()
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")
class TestExecuteFromConfiguration(unittest.TestCase):
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()
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()
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):
self.assertEqual(cst_venv_erplibre, ".venv.erplibre")
def test_file_error_path(self):
self.assertEqual(file_error_path, ".erplibre.error.txt")
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)
class TestExecuteUnitTests(unittest.TestCase):
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()
# Verify it was called - error handling path
class TestKdbxGetExtraCommandUser(unittest.TestCase):
def test_empty_kdbx_key(self):
todo = TODO()
result = todo.kdbx_get_extra_command_user("")
self.assertEqual(result, "")
def test_none_kdbx_key(self):
todo = TODO()
result = todo.kdbx_get_extra_command_user(None)
self.assertEqual(result, "")
def test_kdbx_not_available(self):
todo = TODO()
todo.get_kdbx = MagicMock(return_value=None)
result = todo.kdbx_get_extra_command_user("some_key")
self.assertEqual(result, "")
class TestSetupClaudeCommit(unittest.TestCase):
def test_existing_file_skips(self):
todo = TODO()
with patch("os.path.exists", return_value=True), patch(
"builtins.print"
) as mock_print:
todo._setup_claude_commit()
# Should print exists message without asking for input
if __name__ == "__main__":
unittest.main()

244
test/test_todo_i18n.py Normal file
View file

@ -0,0 +1,244 @@
#!/usr/bin/env python3
# © 2025 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
import os
import tempfile
import unittest
from unittest.mock import patch
from script.todo import todo_i18n
class TestTranslations(unittest.TestCase):
"""Test TRANSLATIONS dictionary integrity."""
def test_all_entries_have_fr_and_en(self):
for key, entry in todo_i18n.TRANSLATIONS.items():
self.assertIn("fr", entry, f"Key '{key}' missing 'fr' translation")
self.assertIn("en", entry, f"Key '{key}' missing 'en' translation")
def test_no_empty_translations(self):
for key, entry in todo_i18n.TRANSLATIONS.items():
for lang in ("fr", "en"):
self.assertTrue(
len(entry[lang]) > 0,
f"Key '{key}' has empty '{lang}' translation",
)
def test_translations_not_empty(self):
self.assertGreater(len(todo_i18n.TRANSLATIONS), 0)
class TestT(unittest.TestCase):
"""Test t() translation function."""
def setUp(self):
todo_i18n._current_lang = None
def tearDown(self):
todo_i18n._current_lang = None
def test_returns_french_when_lang_fr(self):
todo_i18n.set_lang("fr")
result = todo_i18n.t("menu_quit")
self.assertEqual(result, "Quitter")
def test_returns_english_when_lang_en(self):
todo_i18n.set_lang("en")
result = todo_i18n.t("menu_quit")
self.assertEqual(result, "Quit")
def test_unknown_key_returns_key(self):
todo_i18n.set_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")
self.assertEqual(result, "Quitter")
class TestGetLang(unittest.TestCase):
"""Test get_lang() function."""
def setUp(self):
todo_i18n._current_lang = None
def tearDown(self):
todo_i18n._current_lang = None
def test_returns_cached_lang(self):
todo_i18n._current_lang = "en"
result = todo_i18n.get_lang()
self.assertEqual(result, "en")
def test_reads_from_env_var_sh(self):
with tempfile.NamedTemporaryFile(
mode="w", suffix=".sh", delete=False
) as f:
f.write('EL_LANG="en"\n')
f.flush()
try:
with patch.object(
todo_i18n, "CONFIG_OVERRIDE_PRIVATE_FILE", f.name
):
result = todo_i18n.get_lang()
self.assertEqual(result, "en")
finally:
os.unlink(f.name)
def test_reads_unquoted_lang(self):
with tempfile.NamedTemporaryFile(
mode="w", suffix=".sh", delete=False
) as f:
f.write("EL_LANG=fr\n")
f.flush()
try:
with patch.object(
todo_i18n, "CONFIG_OVERRIDE_PRIVATE_FILE", f.name
):
result = todo_i18n.get_lang()
self.assertEqual(result, "fr")
finally:
os.unlink(f.name)
def test_env_variable_fallback(self):
with patch.object(
todo_i18n,
"CONFIG_OVERRIDE_PRIVATE_FILE",
"/nonexistent/path",
), patch.dict(os.environ, {"EL_LANG": "en"}):
result = todo_i18n.get_lang()
self.assertEqual(result, "en")
def test_default_is_fr(self):
with patch.object(
todo_i18n,
"CONFIG_OVERRIDE_PRIVATE_FILE",
"/nonexistent/path",
), patch.dict(os.environ, {}, clear=True):
result = todo_i18n.get_lang()
self.assertEqual(result, "fr")
def test_invalid_lang_in_file_falls_through(self):
with tempfile.NamedTemporaryFile(
mode="w", suffix=".sh", delete=False
) as f:
f.write('EL_LANG="de"\n')
f.flush()
try:
with patch.object(
todo_i18n, "CONFIG_OVERRIDE_PRIVATE_FILE", f.name
), patch.dict(os.environ, {}, clear=True):
result = todo_i18n.get_lang()
self.assertEqual(result, "fr")
finally:
os.unlink(f.name)
class TestSetLang(unittest.TestCase):
"""Test set_lang() function."""
def setUp(self):
todo_i18n._current_lang = None
def tearDown(self):
todo_i18n._current_lang = None
def test_sets_current_lang(self):
todo_i18n.set_lang("en")
self.assertEqual(todo_i18n._current_lang, "en")
def test_persists_to_file_update(self):
with tempfile.NamedTemporaryFile(
mode="w", suffix=".sh", delete=False
) as f:
f.write('EL_LANG="fr"\nOTHER=value\n')
f.flush()
try:
with patch.object(
todo_i18n, "CONFIG_OVERRIDE_PRIVATE_FILE", f.name
):
todo_i18n.set_lang("en")
with open(f.name) as rf:
content = rf.read()
self.assertIn('EL_LANG="en"', content)
self.assertIn("OTHER=value", content)
finally:
os.unlink(f.name)
def test_persists_to_file_append(self):
with tempfile.NamedTemporaryFile(
mode="w", suffix=".sh", delete=False
) as f:
f.write("SOME_VAR=123\n")
f.flush()
try:
with patch.object(
todo_i18n, "CONFIG_OVERRIDE_PRIVATE_FILE", f.name
):
todo_i18n.set_lang("en")
with open(f.name) as rf:
content = rf.read()
self.assertIn('EL_LANG="en"', content)
self.assertIn("SOME_VAR=123", content)
finally:
os.unlink(f.name)
def test_nonexistent_file_no_crash(self):
with patch.object(
todo_i18n,
"CONFIG_OVERRIDE_PRIVATE_FILE",
"/nonexistent/path",
):
todo_i18n.set_lang("en")
self.assertEqual(todo_i18n._current_lang, "en")
class TestLangIsConfigured(unittest.TestCase):
"""Test lang_is_configured() function."""
def test_returns_true_when_configured(self):
with tempfile.NamedTemporaryFile(
mode="w", suffix=".sh", delete=False
) as f:
f.write('EL_LANG="fr"\n')
f.flush()
try:
with patch.object(
todo_i18n, "CONFIG_OVERRIDE_PRIVATE_FILE", f.name
):
result = todo_i18n.lang_is_configured()
self.assertTrue(result)
finally:
os.unlink(f.name)
def test_returns_false_when_not_configured(self):
with tempfile.NamedTemporaryFile(
mode="w", suffix=".sh", delete=False
) as f:
f.write("SOME_VAR=123\n")
f.flush()
try:
with patch.object(
todo_i18n, "CONFIG_OVERRIDE_PRIVATE_FILE", f.name
):
result = todo_i18n.lang_is_configured()
self.assertFalse(result)
finally:
os.unlink(f.name)
def test_returns_false_when_file_missing(self):
with patch.object(
todo_i18n,
"CONFIG_OVERRIDE_PRIVATE_FILE",
"/nonexistent/path",
):
result = todo_i18n.lang_is_configured()
self.assertFalse(result)
if __name__ == "__main__":
unittest.main()