From ab95ee6300e6e95afc066907d1dd7e5ebc09a11c Mon Sep 17 00:00:00 2001 From: Andre Miras Date: Sat, 1 Nov 2025 16:09:08 +0000 Subject: [PATCH] :white_check_mark: unit test patching & checkdependencies modules - tests/test_patching.py: tests covering platform detection, architecture checks, API level comparisons, version checking, and logical conjunctions used in recipe patch conditionals - tests/test_checkdependencies.py: tests covering module import verification, version requirement checking, and environment variable handling --- tests/test_checkdependencies.py | 195 +++++++++++++++++++ tests/test_patching.py | 326 ++++++++++++++++++++++++++++++++ 2 files changed, 521 insertions(+) create mode 100644 tests/test_checkdependencies.py create mode 100644 tests/test_patching.py diff --git a/tests/test_checkdependencies.py b/tests/test_checkdependencies.py new file mode 100644 index 000000000..c629893c5 --- /dev/null +++ b/tests/test_checkdependencies.py @@ -0,0 +1,195 @@ +import sys +from unittest import mock + +from pythonforandroid import checkdependencies + + +class TestCheckPythonDependencies: + """Test check_python_dependencies function.""" + + @mock.patch('pythonforandroid.checkdependencies.import_module') + def test_all_modules_present(self, mock_import): + """Test that check_python_dependencies completes when all modules are present.""" + # Mock all required modules + mock_colorama = mock.Mock() + mock_colorama.__version__ = '0.4.0' + mock_sh = mock.Mock() + mock_sh.__version__ = '1.12' + mock_appdirs = mock.Mock() + mock_jinja2 = mock.Mock() + + def import_side_effect(name): + if name == 'colorama': + return mock_colorama + elif name == 'sh': + return mock_sh + elif name == 'appdirs': + return mock_appdirs + elif name == 'jinja2': + return mock_jinja2 + raise ImportError(f"No module named '{name}'") + + mock_import.side_effect = import_side_effect + + with mock.patch.object(sys, 'modules', { + 'colorama': mock_colorama, + 'sh': mock_sh, + 'appdirs': mock_appdirs, + 'jinja2': mock_jinja2 + }): + checkdependencies.check_python_dependencies() + + @mock.patch('builtins.exit') + @mock.patch('builtins.print') + @mock.patch('pythonforandroid.checkdependencies.import_module') + def test_missing_module_without_version(self, mock_import, mock_print, mock_exit): + """Test error message when module without version requirement is missing.""" + modules_dict = {} + + def import_side_effect(name): + if name == 'appdirs': + raise ImportError(f"No module named '{name}'") + mock_mod = mock.Mock() + mock_mod.__version__ = '1.0' + modules_dict[name] = mock_mod + return mock_mod + + mock_import.side_effect = import_side_effect + + with mock.patch.object(sys, 'modules', modules_dict): + checkdependencies.check_python_dependencies() + + # Verify error message was printed + error_calls = [str(call) for call in mock_print.call_args_list] + assert any('appdirs' in call and 'ERROR' in call for call in error_calls) + mock_exit.assert_called_once_with(1) + + @mock.patch('builtins.exit') + @mock.patch('builtins.print') + @mock.patch('pythonforandroid.checkdependencies.import_module') + def test_missing_module_with_version(self, mock_import, mock_print, mock_exit): + """Test error message when module with version requirement is missing.""" + modules_dict = {} + + def import_side_effect(name): + if name == 'colorama': + raise ImportError(f"No module named '{name}'") + mock_mod = mock.Mock() + mock_mod.__version__ = '1.0' + modules_dict[name] = mock_mod + return mock_mod + + mock_import.side_effect = import_side_effect + + with mock.patch.object(sys, 'modules', modules_dict): + checkdependencies.check_python_dependencies() + + # Verify error message includes version requirement + error_calls = [str(call) for call in mock_print.call_args_list] + assert any('colorama' in call and '0.3.3' in call for call in error_calls) + mock_exit.assert_called_once_with(1) + + @mock.patch('builtins.exit') + @mock.patch('builtins.print') + @mock.patch('pythonforandroid.checkdependencies.import_module') + def test_module_version_too_old(self, mock_import, mock_print, mock_exit): + """Test error when module version is too old.""" + mock_colorama = mock.Mock() + mock_colorama.__version__ = '0.2.0' # Too old, needs 0.3.3 + modules_dict = {'colorama': mock_colorama} + + def import_side_effect(name): + if name == 'colorama': + return mock_colorama + mock_mod = mock.Mock() + mock_mod.__version__ = '1.0' + modules_dict[name] = mock_mod + return mock_mod + + mock_import.side_effect = import_side_effect + + with mock.patch.object(sys, 'modules', modules_dict): + checkdependencies.check_python_dependencies() + + # Verify error message about version + error_calls = [str(call) for call in mock_print.call_args_list] + assert any('version' in call.lower() and 'colorama' in call for call in error_calls) + mock_exit.assert_called_once_with(1) + + @mock.patch('pythonforandroid.checkdependencies.import_module') + def test_module_version_acceptable(self, mock_import): + """Test that acceptable versions pass.""" + mock_colorama = mock.Mock() + mock_colorama.__version__ = '0.4.0' # Newer than 0.3.3 + mock_sh = mock.Mock() + mock_sh.__version__ = '1.12' # Newer than 1.10 + + def import_side_effect(name): + if name == 'colorama': + return mock_colorama + elif name == 'sh': + return mock_sh + mock_mod = mock.Mock() + return mock_mod + + mock_import.side_effect = import_side_effect + + with mock.patch.object(sys, 'modules', { + 'colorama': mock_colorama, + 'sh': mock_sh + }): + # Should complete without error + checkdependencies.check_python_dependencies() + + @mock.patch('pythonforandroid.checkdependencies.import_module') + def test_module_without_version_attribute(self, mock_import): + """Test handling of modules that don't have __version__.""" + mock_colorama = mock.Mock(spec=[]) # No __version__ attribute + modules_dict = {'colorama': mock_colorama} + + def import_side_effect(name): + if name == 'colorama': + return mock_colorama + mock_mod = mock.Mock() + modules_dict[name] = mock_mod + return mock_mod + + mock_import.side_effect = import_side_effect + + with mock.patch.object(sys, 'modules', modules_dict): + # Should complete without error (version check is skipped) + checkdependencies.check_python_dependencies() + + +class TestCheck: + """Test the main check() function.""" + + @mock.patch('pythonforandroid.checkdependencies.check_python_dependencies') + @mock.patch('pythonforandroid.checkdependencies.check_and_install_default_prerequisites') + def test_check_with_skip_prerequisites(self, mock_prereqs, mock_python_deps): + """Test check() skips prerequisites when SKIP_PREREQUISITES_CHECK=1.""" + with mock.patch.dict('os.environ', {'SKIP_PREREQUISITES_CHECK': '1'}): + checkdependencies.check() + + mock_prereqs.assert_not_called() + mock_python_deps.assert_called_once() + + @mock.patch('pythonforandroid.checkdependencies.check_python_dependencies') + @mock.patch('pythonforandroid.checkdependencies.check_and_install_default_prerequisites') + def test_check_without_skip(self, mock_prereqs, mock_python_deps): + """Test check() runs prerequisites when SKIP_PREREQUISITES_CHECK is not set.""" + with mock.patch.dict('os.environ', {}, clear=True): + checkdependencies.check() + + mock_prereqs.assert_called_once() + mock_python_deps.assert_called_once() + + @mock.patch('pythonforandroid.checkdependencies.check_python_dependencies') + @mock.patch('pythonforandroid.checkdependencies.check_and_install_default_prerequisites') + def test_check_with_skip_set_to_zero(self, mock_prereqs, mock_python_deps): + """Test check() runs prerequisites when SKIP_PREREQUISITES_CHECK=0.""" + with mock.patch.dict('os.environ', {'SKIP_PREREQUISITES_CHECK': '0'}): + checkdependencies.check() + + mock_prereqs.assert_called_once() + mock_python_deps.assert_called_once() diff --git a/tests/test_patching.py b/tests/test_patching.py new file mode 100644 index 000000000..dd085f340 --- /dev/null +++ b/tests/test_patching.py @@ -0,0 +1,326 @@ +from unittest import mock + +from pythonforandroid.patching import ( + is_platform, + is_linux, + is_darwin, + is_windows, + is_arch, + is_api, + is_api_gt, + is_api_gte, + is_api_lt, + is_api_lte, + is_ndk, + is_version_gt, + is_version_lt, + version_starts_with, + will_build, + check_all, + check_any, +) + + +class TestPlatformChecks: + """Test platform detection functions.""" + + @mock.patch('pythonforandroid.patching.uname') + def test_is_platform_linux(self, mock_uname): + """Test is_platform returns check function for Linux.""" + mock_uname.return_value = mock.Mock(system='Linux') + check_fn = is_platform('Linux') + assert check_fn(None, None) + + @mock.patch('pythonforandroid.patching.uname') + def test_is_platform_darwin(self, mock_uname): + """Test is_platform returns check function for Darwin.""" + mock_uname.return_value = mock.Mock(system='Darwin') + check_fn = is_platform('Darwin') + assert check_fn(None, None) + + @mock.patch('pythonforandroid.patching.uname') + def test_is_platform_case_insensitive(self, mock_uname): + """Test is_platform is case insensitive.""" + mock_uname.return_value = mock.Mock(system='LINUX') + check_fn = is_platform('linux') + assert check_fn(None, None) + + @mock.patch('pythonforandroid.patching.uname') + def test_is_platform_mismatch(self, mock_uname): + """Test is_platform returns False for mismatched platform.""" + mock_uname.return_value = mock.Mock(system='Linux') + check_fn = is_platform('Windows') + assert not check_fn(None, None) + + def test_is_linux(self): + """Test is_linux constant function is defined.""" + # is_linux is defined at module import time based on real platform + # We can only verify it's callable + assert callable(is_linux) + + def test_is_darwin(self): + """Test is_darwin constant function is defined.""" + # is_darwin is defined at module import time based on real platform + # We can only verify it's callable + assert callable(is_darwin) + + def test_is_windows(self): + """Test is_windows constant function is defined.""" + # is_windows is defined at module import time based on real platform + # We can only verify it's callable + assert callable(is_windows) + + +class TestArchChecks: + """Test architecture check functions.""" + + def test_is_arch_match(self): + """Test is_arch returns True for matching architecture.""" + mock_arch = mock.Mock(arch='armeabi-v7a') + check_fn = is_arch('armeabi-v7a') + assert check_fn(mock_arch) + + def test_is_arch_mismatch(self): + """Test is_arch returns False for mismatched architecture.""" + mock_arch = mock.Mock(arch='armeabi-v7a') + check_fn = is_arch('arm64-v8a') + assert not check_fn(mock_arch) + + +class TestAndroidAPIChecks: + """Test Android API level comparison functions.""" + + def test_is_api_equal(self): + """Test is_api for equal API level.""" + mock_recipe = mock.Mock() + mock_recipe.ctx.android_api = 21 + check_fn = is_api(21) + assert check_fn(None, mock_recipe) + + def test_is_api_not_equal(self): + """Test is_api for unequal API level.""" + mock_recipe = mock.Mock() + mock_recipe.ctx.android_api = 21 + check_fn = is_api(27) + assert not check_fn(None, mock_recipe) + + def test_is_api_gt(self): + """Test is_api_gt for greater than comparison.""" + mock_recipe = mock.Mock() + mock_recipe.ctx.android_api = 27 + check_fn = is_api_gt(21) + assert check_fn(None, mock_recipe) + + mock_recipe.ctx.android_api = 21 + assert not check_fn(None, mock_recipe) + + def test_is_api_gte(self): + """Test is_api_gte for greater than or equal comparison.""" + mock_recipe = mock.Mock() + mock_recipe.ctx.android_api = 27 + check_fn = is_api_gte(21) + assert check_fn(None, mock_recipe) + + mock_recipe.ctx.android_api = 21 + check_fn = is_api_gte(21) + assert check_fn(None, mock_recipe) + + mock_recipe.ctx.android_api = 19 + assert not check_fn(None, mock_recipe) + + def test_is_api_lt(self): + """Test is_api_lt for less than comparison.""" + mock_recipe = mock.Mock() + mock_recipe.ctx.android_api = 19 + check_fn = is_api_lt(21) + assert check_fn(None, mock_recipe) + + mock_recipe.ctx.android_api = 21 + assert not check_fn(None, mock_recipe) + + def test_is_api_lte(self): + """Test is_api_lte for less than or equal comparison.""" + mock_recipe = mock.Mock() + mock_recipe.ctx.android_api = 19 + check_fn = is_api_lte(21) + assert check_fn(None, mock_recipe) + + mock_recipe.ctx.android_api = 21 + check_fn = is_api_lte(21) + assert check_fn(None, mock_recipe) + + mock_recipe.ctx.android_api = 27 + assert not check_fn(None, mock_recipe) + + +class TestNDKChecks: + """Test NDK version check functions.""" + + def test_is_ndk_equal(self): + """Test is_ndk for equal NDK version.""" + mock_ndk = mock.Mock(name='ndk_r21e') + mock_recipe = mock.Mock() + mock_recipe.ctx.ndk = mock_ndk + check_fn = is_ndk(mock_ndk) + assert check_fn(None, mock_recipe) + + def test_is_ndk_not_equal(self): + """Test is_ndk for unequal NDK version.""" + mock_ndk1 = mock.Mock(name='ndk_r21e') + mock_ndk2 = mock.Mock(name='ndk_r25c') + mock_recipe = mock.Mock() + mock_recipe.ctx.ndk = mock_ndk1 + check_fn = is_ndk(mock_ndk2) + assert not check_fn(None, mock_recipe) + + +class TestVersionChecks: + """Test recipe version comparison functions.""" + + def test_is_version_gt(self): + """Test is_version_gt for version comparison.""" + mock_recipe = mock.Mock(version='2.0.0') + check_fn = is_version_gt('1.0.0') + assert check_fn(None, mock_recipe) + + mock_recipe.version = '1.0.0' + assert not check_fn(None, mock_recipe) + + def test_is_version_lt(self): + """Test is_version_lt for version comparison.""" + mock_recipe = mock.Mock(version='1.0.0') + check_fn = is_version_lt('2.0.0') + assert check_fn(None, mock_recipe) + + mock_recipe.version = '2.0.0' + assert not check_fn(None, mock_recipe) + + def test_version_starts_with(self): + """Test version_starts_with for version prefix matching.""" + mock_recipe = mock.Mock(version='1.15.2') + check_fn = version_starts_with('1.15') + assert check_fn(None, mock_recipe) + + check_fn = version_starts_with('1.14') + assert not check_fn(None, mock_recipe) + + check_fn = version_starts_with('2') + assert not check_fn(None, mock_recipe) + + +class TestWillBuild: + """Test will_build function.""" + + def test_will_build_present(self): + """Test will_build returns True when recipe is in build order.""" + mock_recipe = mock.Mock() + mock_recipe.ctx.recipe_build_order = ['python3', 'numpy', 'kivy'] + check_fn = will_build('numpy') + assert check_fn(None, mock_recipe) + + def test_will_build_absent(self): + """Test will_build returns False when recipe is not in build order.""" + mock_recipe = mock.Mock() + mock_recipe.ctx.recipe_build_order = ['python3', 'numpy', 'kivy'] + check_fn = will_build('scipy') + assert not check_fn(None, mock_recipe) + + +class TestConjunctions: + """Test logical conjunction functions.""" + + def test_check_all_all_true(self): + """Test check_all returns True when all checks pass.""" + def check1(_arch, _recipe): + return True + + def check2(_arch, _recipe): + return True + + def check3(_arch, _recipe): + return True + + check_fn = check_all(check1, check2, check3) + assert check_fn(None, None) + + def test_check_all_one_false(self): + """Test check_all returns False when one check fails.""" + def check1(_arch, _recipe): + return True + + def check2(_arch, _recipe): + return False + + def check3(_arch, _recipe): + return True + + check_fn = check_all(check1, check2, check3) + assert not check_fn(None, None) + + def test_check_all_all_false(self): + """Test check_all returns False when all checks fail.""" + def check1(_arch, _recipe): + return False + + def check2(_arch, _recipe): + return False + + check_fn = check_all(check1, check2) + assert not check_fn(None, None) + + def test_check_any_one_true(self): + """Test check_any returns True when one check passes.""" + def check1(_arch, _recipe): + return False + + def check2(_arch, _recipe): + return True + + def check3(_arch, _recipe): + return False + + check_fn = check_any(check1, check2, check3) + assert check_fn(None, None) + + def test_check_any_all_false(self): + """Test check_any returns False when all checks fail.""" + def check1(_arch, _recipe): + return False + + def check2(_arch, _recipe): + return False + + check_fn = check_any(check1, check2) + assert not check_fn(None, None) + + def test_check_any_all_true(self): + """Test check_any returns True when all checks pass.""" + def check1(_arch, _recipe): + return True + + def check2(_arch, _recipe): + return True + + check_fn = check_any(check1, check2) + assert check_fn(None, None) + + @mock.patch('pythonforandroid.patching.uname') + def test_combined_checks(self, mock_uname): + """Test combining multiple check functions with check_all and check_any.""" + # Test check_all with is_platform and is_version_gt + mock_uname.return_value = mock.Mock(system='Linux') + mock_recipe = mock.Mock(version='2.0.0') + + check_fn = check_all( + is_platform('Linux'), + is_version_gt('1.0.0') + ) + assert check_fn(None, mock_recipe) + + # Test check_any with is_platform and is_version_gt + mock_uname.return_value = mock.Mock(system='Windows') + check_fn = check_any( + is_platform('Linux'), + is_version_gt('1.0.0') + ) + assert check_fn(None, mock_recipe)