diff --git a/test/test_execute.py b/test/test_execute.py new file mode 100644 index 0000000..2119a6a --- /dev/null +++ b/test/test_execute.py @@ -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() diff --git a/test/test_git_tool.py b/test/test_git_tool.py new file mode 100644 index 0000000..113514b --- /dev/null +++ b/test/test_git_tool.py @@ -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 = """ + + + + + +""" + 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 = """ + +""" + 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() diff --git a/test/test_kill_process_by_port.py b/test/test_kill_process_by_port.py new file mode 100644 index 0000000..d6e991f --- /dev/null +++ b/test/test_kill_process_by_port.py @@ -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() diff --git a/test/test_todo.py b/test/test_todo.py new file mode 100644 index 0000000..b2ef961 --- /dev/null +++ b/test/test_todo.py @@ -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() diff --git a/test/test_todo_i18n.py b/test/test_todo_i18n.py new file mode 100644 index 0000000..64ff1ee --- /dev/null +++ b/test/test_todo_i18n.py @@ -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()