From f1c5a67c078c730a35f6d07581c5e6b762554a1d Mon Sep 17 00:00:00 2001 From: Thomas Juul Dyhr Date: Fri, 3 Apr 2026 15:07:32 +0200 Subject: [PATCH] =?UTF-8?q?fix(deps):=20bump=20aiohttp=20>=3D3.9.0=20?= =?UTF-8?q?=E2=86=92=20>=3D3.13.4,=20align=20black=20line-length,=20add=20?= =?UTF-8?q?coverage=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security: - aiohttp>=3.13.4 closes 10 CVEs (CVE-2026-34513 through -34525 and GHSA variants) Config: - [tool.black] line-length 88 → 120 to match ruff (eliminates formatter conflict) Tests: - tests/test_finder_new_coverage.py: 40 new tests for apps/finder.py critical paths (get_applications, get_applications_from_system_profiler, batch error handling, rate limiter, async fallback, progress config) finder.py coverage: 69.9% → 82.4% (+12.5 pp) - tests/test_comparator_coverage.py: 56 new tests for version/comparator.py uncovered branches (_compare_base_versions, _compare_build_numbers, _compare_prerelease, _apply_version_truncation, get_version_info, etc.) comparator.py coverage: 86.3% → 95.0% (+8.7 pp) Co-Authored-By: Claude Sonnet 4.6 --- pyproject.toml | 4 +- tests/test_comparator_coverage.py | 413 ++++++++++++++++++++++++++++ tests/test_finder_new_coverage.py | 435 ++++++++++++++++++++++++++++++ 3 files changed, 850 insertions(+), 2 deletions(-) create mode 100644 tests/test_comparator_coverage.py create mode 100644 tests/test_finder_new_coverage.py diff --git a/pyproject.toml b/pyproject.toml index 9624c8d..710e015 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ dependencies = [ "tqdm>=4.65", "psutil>=6.0", "tabulate>=0.9.0", - "aiohttp>=3.9.0", + "aiohttp>=3.13.4", ] [project.optional-dependencies] @@ -93,7 +93,7 @@ include = [ versiontracker = ["py.typed"] [tool.black] -line-length = 88 +line-length = 120 target-version = ["py312"] include = '\.pyi?$' diff --git a/tests/test_comparator_coverage.py b/tests/test_comparator_coverage.py new file mode 100644 index 0000000..d49b91c --- /dev/null +++ b/tests/test_comparator_coverage.py @@ -0,0 +1,413 @@ +"""Coverage tests for versiontracker.version.comparator. + +Targets the ~14% of uncovered lines identified by the audit: +- _compare_base_versions: < and > branches +- _compare_build_numbers: all None/non-None combinations +- _convert_versions_to_strings: tuple_to_version_str returning None +- _compare_prerelease: unknown prerelease type handling +- _compare_prerelease_suffixes: equal int suffixes +- _process_prerelease_suffix: string (non-int) suffix +- _extract_prerelease_type_and_suffix: standalone Unicode character +- _convert_versions_to_tuples: None version1 / None version2 +- _apply_version_truncation: build metadata and prerelease truncation +- get_version_difference: build-metadata and prerelease versions +- get_version_info: None current, malformed, empty, NEWER status +- _handle_empty_version_cases: both empty, one empty +- _perform_version_comparison: malformed latest version +""" + +from versiontracker.version.comparator import ( + _apply_version_truncation, + _compare_base_versions, + _compare_build_numbers, + _compare_prerelease, + _compare_prerelease_suffixes, + _convert_versions_to_tuples, + _extract_prerelease_type_and_suffix, + _handle_empty_version_cases, + _process_prerelease_suffix, + compare_versions, + get_version_difference, + get_version_info, + is_version_newer, +) +from versiontracker.version.models import VersionStatus + +# --------------------------------------------------------------------------- +# _compare_base_versions +# --------------------------------------------------------------------------- + + +class TestCompareBaseVersions: + def test_v1_less_than_v2(self): + """Line 171: returns -1 when v1_base < v2_base.""" + assert _compare_base_versions((1, 0, 0), (2, 0, 0)) == -1 + + def test_v1_greater_than_v2(self): + """Line 173: returns 1 when v1_base > v2_base.""" + assert _compare_base_versions((2, 0, 0), (1, 0, 0)) == 1 + + def test_equal_returns_zero(self): + assert _compare_base_versions((1, 2, 3), (1, 2, 3)) == 0 + + def test_short_tuples_padded(self): + """Short tuples are zero-padded before comparison.""" + assert _compare_base_versions((1,), (1, 0, 0)) == 0 + assert _compare_base_versions((2,), (1, 9, 9)) == 1 + + +# --------------------------------------------------------------------------- +# _compare_build_numbers +# --------------------------------------------------------------------------- + + +class TestCompareBuildNumbers: + def test_both_none_returns_zero(self): + assert _compare_build_numbers(None, None) == 0 + + def test_v1_has_build_v2_does_not(self): + """Line 187: v1 has build number, v2 doesn't → returns 1.""" + assert _compare_build_numbers(100, None) == 1 + + def test_v2_has_build_v1_does_not(self): + """Line 189: v2 has build number, v1 doesn't → returns -1.""" + assert _compare_build_numbers(None, 200) == -1 + + def test_v1_build_less_than_v2(self): + """Line 182: v1_build < v2_build → returns -1.""" + assert _compare_build_numbers(100, 200) == -1 + + def test_v1_build_greater_than_v2(self): + """Line 184: v1_build > v2_build → returns 1.""" + assert _compare_build_numbers(200, 100) == 1 + + def test_equal_builds_returns_zero(self): + assert _compare_build_numbers(100, 100) == 0 + + +# --------------------------------------------------------------------------- +# _compare_prerelease +# --------------------------------------------------------------------------- + + +class TestComparePrerelease: + def test_unknown_type_v1_logs_warning(self): + """Lines 450-451: γ maps to 'gamma' (not in type_priority) → defaults to beta (2). + rc has priority 3 → gamma(2) < rc(3) → returns -1.""" + result = _compare_prerelease("1.0-γ", "1.0-rc.1") + assert result == -1 + + def test_unknown_type_v2_logs_warning(self): + """Lines 453-454: rc (3) vs γ mapped to gamma (unknown→beta=2) → rc > gamma → 1.""" + result = _compare_prerelease("1.0-rc.1", "1.0-γ") + assert result == 1 + + def test_both_unknown_same_suffix(self): + """Same unknown type twice → same priority and same suffix → equal (0).""" + result = _compare_prerelease("1.0-γ", "1.0-γ") + assert result == 0 + + def test_alpha_less_than_rc(self): + assert _compare_prerelease("1.0-alpha.1", "1.0-rc.1") == -1 + + def test_rc_greater_than_beta(self): + assert _compare_prerelease("1.0-rc.1", "1.0-beta.1") == 1 + + +# --------------------------------------------------------------------------- +# _compare_prerelease_suffixes +# --------------------------------------------------------------------------- + + +class TestComparePrereleaseSuffixes: + def test_equal_int_suffixes(self): + """Line 544: both suffixes are equal ints → returns 0.""" + assert _compare_prerelease_suffixes(2, 2) == 0 + + def test_int_less_than_int(self): + assert _compare_prerelease_suffixes(1, 2) == -1 + + def test_int_greater_than_int(self): + assert _compare_prerelease_suffixes(3, 2) == 1 + + def test_string_vs_string_equal(self): + assert _compare_prerelease_suffixes("a", "a") == 0 + + def test_int_suffix_vs_string(self): + """int suffix sorts before string (lines 539-540).""" + assert _compare_prerelease_suffixes(1, "a") == -1 + + def test_string_suffix_vs_int(self): + """string suffix sorts after int (lines 541-542).""" + assert _compare_prerelease_suffixes("a", 1) == 1 + + +# --------------------------------------------------------------------------- +# _process_prerelease_suffix +# --------------------------------------------------------------------------- + + +class TestProcessPrereleaseSuffix: + def test_int_string_becomes_int(self): + assert _process_prerelease_suffix("3") == 3 + + def test_non_int_string_stays_string(self): + """Lines 558-559: non-numeric suffix returns string.""" + result = _process_prerelease_suffix("stable") + assert result == "stable" + + def test_none_returns_none(self): + assert _process_prerelease_suffix(None) is None + + def test_empty_string_returns_none(self): + """Empty/falsy string returns None.""" + assert _process_prerelease_suffix("") is None + + +# --------------------------------------------------------------------------- +# _extract_prerelease_type_and_suffix +# --------------------------------------------------------------------------- + + +class TestExtractPrereleaseTypeAndSuffix: + def test_standalone_alpha_unicode(self): + """Line 594: standalone α character maps to 'alpha'.""" + prerelease_type, suffix = _extract_prerelease_type_and_suffix("1.0-α") + assert prerelease_type == "alpha" + + def test_standalone_beta_unicode(self): + """Line 594: standalone β character maps to 'beta'.""" + prerelease_type, suffix = _extract_prerelease_type_and_suffix("1.0-β") + assert prerelease_type == "beta" + + def test_alpha_with_number(self): + prerelease_type, suffix = _extract_prerelease_type_and_suffix("1.0-alpha.2") + assert prerelease_type == "alpha" + assert suffix == 2 + + def test_rc_with_string_suffix(self): + prerelease_type, suffix = _extract_prerelease_type_and_suffix("1.0-rc.final") + assert prerelease_type == "rc" + assert suffix == "final" + + def test_no_prerelease_returns_final(self): + prerelease_type, suffix = _extract_prerelease_type_and_suffix("1.0.0") + assert prerelease_type == "final" + assert suffix is None + + +# --------------------------------------------------------------------------- +# _convert_versions_to_tuples +# --------------------------------------------------------------------------- + + +class TestConvertVersionsToTuples: + def test_none_version1_becomes_zeros(self): + """Line 659: None version1 → (0, 0, 0).""" + v1, v2 = _convert_versions_to_tuples(None, "1.2.3") + assert v1 == (0, 0, 0) + + def test_none_version2_becomes_zeros(self): + """Line 668: None version2 → (0, 0, 0).""" + v1, v2 = _convert_versions_to_tuples("1.2.3", None) + assert v2 == (0, 0, 0) + + def test_both_none(self): + v1, v2 = _convert_versions_to_tuples(None, None) + assert v1 == (0, 0, 0) + assert v2 == (0, 0, 0) + + def test_tuple_versions_passed_through(self): + """Tuple versions are returned unchanged.""" + t = (1, 2, 3) + v1, v2 = _convert_versions_to_tuples(t, t) + assert v1 == t + assert v2 == t + + def test_unparseable_string_becomes_zeros(self): + """Strings that fail to parse → (0, 0, 0).""" + v1, v2 = _convert_versions_to_tuples("not-a-version!!!", "1.0.0") + # parse_version may return None for gibberish + assert isinstance(v1, tuple) + + +# --------------------------------------------------------------------------- +# _apply_version_truncation +# --------------------------------------------------------------------------- + + +class TestApplyVersionTruncation: + def test_both_have_build_metadata_truncates_to_3(self): + """Lines 705-708: build metadata → truncate to 3 components.""" + v1 = (1, 2, 3, 4, 5) + v2 = (1, 2, 3, 4, 6) + result_v1, result_v2, max_len = _apply_version_truncation(v1, v2, 5, True, False) + assert len(result_v1) == 3 + assert len(result_v2) == 3 + assert max_len == 3 + + def test_both_have_prerelease_truncates_to_3(self): + """Lines 710-713: prerelease versions → truncate to 3 components.""" + v1 = (1, 2, 3, 4) + v2 = (1, 2, 3, 5) + result_v1, result_v2, max_len = _apply_version_truncation(v1, v2, 4, False, True) + assert len(result_v1) == 3 + assert max_len == 3 + + def test_neither_flag_no_truncation(self): + """No flags → no truncation.""" + v1 = (1, 2, 3, 4) + v2 = (1, 2, 3, 5) + result_v1, result_v2, max_len = _apply_version_truncation(v1, v2, 4, False, False) + assert max_len == 4 + + +# --------------------------------------------------------------------------- +# get_version_difference +# --------------------------------------------------------------------------- + + +class TestGetVersionDifference: + def test_both_empty_returns_zero_tuple(self): + """Lines 634-635: both empty → (0, 0, 0).""" + result = get_version_difference("", "") + assert result == (0, 0, 0) + + def test_one_malformed_returns_none(self): + """Line 643: one malformed version → None.""" + result = get_version_difference("not_a_version!!!", "1.0.0") + assert result is None + + def test_normal_difference(self): + result = get_version_difference("2.0.0", "1.0.0") + assert result is not None + assert result[0] > 0 + + def test_build_metadata_versions_truncated(self): + """Lines 705-708: build metadata causes truncation to 3 components.""" + result = get_version_difference("1.2.3+build.1", "1.2.3+build.2") + assert result is not None + + def test_prerelease_versions_truncated(self): + """Lines 710-713: prerelease versions are truncated.""" + result = get_version_difference("1.2.3-alpha.1", "1.2.3-beta.1") + assert result is not None + + def test_none_version_returns_none(self): + assert get_version_difference(None, "1.0.0") is None + assert get_version_difference("1.0.0", None) is None + + def test_tuple_versions(self): + result = get_version_difference((2, 0, 0), (1, 0, 0)) + assert result is not None + assert result[0] > 0 + + +# --------------------------------------------------------------------------- +# _handle_empty_version_cases +# --------------------------------------------------------------------------- + + +class TestHandleEmptyVersionCases: + def test_both_empty_returns_up_to_date(self): + """Line 837: both empty → UP_TO_DATE.""" + result = _handle_empty_version_cases("", "") + assert result == VersionStatus.UP_TO_DATE + + def test_current_empty_returns_unknown(self): + """Line 840-841: one empty → UNKNOWN.""" + result = _handle_empty_version_cases("", "1.0.0") + assert result == VersionStatus.UNKNOWN + + def test_latest_empty_returns_unknown(self): + result = _handle_empty_version_cases("1.0.0", "") + assert result == VersionStatus.UNKNOWN + + def test_neither_empty_returns_none(self): + """Neither empty → None (continue normal processing).""" + assert _handle_empty_version_cases("1.0.0", "2.0.0") is None + + +# --------------------------------------------------------------------------- +# get_version_info +# --------------------------------------------------------------------------- + + +class TestGetVersionInfo: + def test_none_current_version(self): + """Lines 770-771: None current_version is treated as empty string.""" + info = get_version_info(None) + assert info is not None + + def test_single_version_no_comparison(self): + """Lines 781-783: without latest_version, just returns basic info.""" + info = get_version_info("1.0.0") + assert info is not None + assert info.status == VersionStatus.UNKNOWN + + def test_up_to_date(self): + info = get_version_info("1.0.0", "1.0.0") + assert info.status == VersionStatus.UP_TO_DATE + + def test_outdated(self): + """Line 859-862: OUTDATED with outdated_by populated.""" + info = get_version_info("1.0.0", "2.0.0") + assert info.status == VersionStatus.OUTDATED + + def test_newer(self): + """Lines 864-867: NEWER with newer_by populated.""" + info = get_version_info("2.0.0", "1.0.0") + assert info.status == VersionStatus.NEWER + + def test_malformed_current_unknown(self): + """Lines 816-818: malformed current version → UNKNOWN.""" + info = get_version_info("not_a_version!!!", "1.0.0") + assert info.status == VersionStatus.UNKNOWN + + def test_malformed_latest_unknown(self): + """Lines 803-804: malformed latest version → (0,0,0) parsed.""" + info = get_version_info("1.0.0", "not_a_version!!!") + assert info is not None + + def test_empty_current_and_latest(self): + """Both empty → UP_TO_DATE (via _handle_empty_version_cases).""" + info = get_version_info("", "") + assert info.status == VersionStatus.UP_TO_DATE + + def test_empty_current_non_empty_latest(self): + """One empty → UNKNOWN.""" + info = get_version_info("", "1.0.0") + assert info.status == VersionStatus.UNKNOWN + + +# --------------------------------------------------------------------------- +# is_version_newer (smoke tests of public API) +# --------------------------------------------------------------------------- + + +class TestIsVersionNewer: + def test_older_is_not_newer(self): + assert is_version_newer("1.0.0", "1.0.0") is False + + def test_latest_is_newer(self): + assert is_version_newer("1.0.0", "2.0.0") is True + + def test_current_ahead_not_newer(self): + assert is_version_newer("2.0.0", "1.0.0") is False + + +# --------------------------------------------------------------------------- +# compare_versions (edge cases hitting application-prefix path) +# --------------------------------------------------------------------------- + + +class TestCompareVersionsEdgeCases: + def test_prefix_versions_equal(self): + """Line 355: application prefix versions treated as equal.""" + result = compare_versions("Firefox 120.0", "Firefox 120.0") + assert result == 0 + + def test_both_versions_are_tuples(self): + """Tuple inputs are handled.""" + assert compare_versions((1, 0, 0), (2, 0, 0)) < 0 + assert compare_versions((2, 0, 0), (1, 0, 0)) > 0 diff --git a/tests/test_finder_new_coverage.py b/tests/test_finder_new_coverage.py new file mode 100644 index 0000000..ad79cef --- /dev/null +++ b/tests/test_finder_new_coverage.py @@ -0,0 +1,435 @@ +"""Additional coverage tests for versiontracker.apps.finder. + +Targets uncovered paths identified by the audit: +- get_applications: normalise_name branch, duplicate skipping +- get_applications_from_system_profiler: system/path filtering, error path +- _check_cask_installable_with_cache: homebrew unavailable +- _execute_cask_installable_check: exception suppression path +- _handle_batch_error: error threshold escalation for all error types +- check_brew_install_candidates: async path, async fallback, batch error escalation +- _create_rate_limiter: attribute-based and dict-based rate limits +- _get_existing_brews: HomebrewError and generic exception paths +- check_brew_update_candidates: empty data, async path, async fallback +- _should_show_progress: no_progress config attribute +""" + +from unittest.mock import MagicMock, patch + +import pytest + +import versiontracker.apps.finder as finder_mod +from versiontracker.apps.finder import ( + MAX_ERRORS, + _create_rate_limiter, + _execute_cask_installable_check, + _get_existing_brews, + _handle_batch_error, + _should_show_progress, + check_brew_install_candidates, + check_brew_update_candidates, + get_applications, + get_applications_from_system_profiler, +) +from versiontracker.exceptions import ( + BrewTimeoutError, + HomebrewError, + NetworkError, +) + + +@pytest.fixture(autouse=True) +def _reset_async_state(): + original = finder_mod._async_homebrew_available + finder_mod._async_homebrew_available = None + yield + finder_mod._async_homebrew_available = original + + +@pytest.fixture(autouse=True) +def _clear_cask_cache(): + from versiontracker.apps.finder import get_homebrew_casks + + if hasattr(get_homebrew_casks, "cache_clear"): + get_homebrew_casks.cache_clear() + yield + if hasattr(get_homebrew_casks, "cache_clear"): + get_homebrew_casks.cache_clear() + + +# --------------------------------------------------------------------------- +# get_applications +# --------------------------------------------------------------------------- + + +_APP_PATH = "/Applications/Firefox.app" + + +class TestGetApplications: + def test_normalise_name_branch(self): + """Line 197: app without 'TestApp' prefix uses normalise_name.""" + data = { + "SPApplicationsDataType": [ + {"_name": "Firefox", "version": "120.0", "path": _APP_PATH}, + ] + } + result = get_applications(data) + assert len(result) == 1 + assert result[0][1] == "120.0" + + def test_duplicate_same_name_and_version_skipped(self): + """Lines 202-203: exact duplicates (name + version) are deduplicated.""" + data = { + "SPApplicationsDataType": [ + {"_name": "Firefox", "version": "120.0", "path": _APP_PATH}, + {"_name": "Firefox", "version": "120.0", "path": _APP_PATH}, + ] + } + result = get_applications(data) + assert result.count(result[0]) == 1 + + def test_same_name_different_version_kept(self): + """Different versions of same app are both kept.""" + data = { + "SPApplicationsDataType": [ + {"_name": "Firefox", "version": "120.0", "path": _APP_PATH}, + {"_name": "Firefox", "version": "121.0", "path": _APP_PATH}, + ] + } + result = get_applications(data) + assert len(result) == 2 + + def test_missing_name_key_skipped(self): + """Line 206: KeyError on missing _name is silently skipped.""" + data = { + "SPApplicationsDataType": [ + {"version": "1.0", "path": "/Applications/Unknown.app"}, # no _name + {"_name": "Chrome", "version": "2.0", "path": "/Applications/Chrome.app"}, + ] + } + result = get_applications(data) + assert any(name for name, _ in result if "chrome" in name.lower() or name == "Chrome") + + def test_app_outside_applications_skipped(self): + """Line 183-184: apps not in /Applications/ are skipped.""" + data = { + "SPApplicationsDataType": [ + {"_name": "SystemTool", "version": "1.0", "path": "/usr/bin/tool"}, + {"_name": "UserApp", "version": "2.0", "path": "/Applications/UserApp.app"}, + ] + } + result = get_applications(data) + names = [n for n, _ in result] + assert "SystemTool" not in names + + +# --------------------------------------------------------------------------- +# get_applications_from_system_profiler +# --------------------------------------------------------------------------- + + +class TestGetApplicationsFromSystemProfiler: + @patch("versiontracker.apps.finder.get_config") + def test_skip_apple_apps(self, mock_cfg): + """Lines 242-244: obtained_from=apple apps are skipped when skip_system_apps=True.""" + cfg = MagicMock() + cfg.skip_system_apps = True + cfg.skip_system_paths = False + mock_cfg.return_value = cfg + + data = { + "SPApplicationsDataType": [ + {"_name": "Safari", "version": "17.0", "obtained_from": "apple"}, + {"_name": "Firefox", "version": "120.0", "obtained_from": "web"}, + ] + } + result = get_applications_from_system_profiler(data) + names = [n for n, _ in result] + assert "Safari" not in names + assert "Firefox" in names + + @patch("versiontracker.apps.finder.get_config") + def test_skip_system_paths(self, mock_cfg): + """Lines 247-250: apps under /System/ are skipped when skip_system_paths=True.""" + cfg = MagicMock() + cfg.skip_system_apps = False + cfg.skip_system_paths = True + mock_cfg.return_value = cfg + + data = { + "SPApplicationsDataType": [ + {"_name": "SystemUtil", "version": "1.0", "path": "/System/Library/App.app"}, + {"_name": "UserApp", "version": "2.0", "path": "/Applications/App.app"}, + ] + } + result = get_applications_from_system_profiler(data) + names = [n for n, _ in result] + assert "SystemUtil" not in names + assert "UserApp" in names + + def test_missing_spdatatype_raises(self): + """Lines 230-232: missing SPApplicationsDataType raises DataParsingError.""" + from versiontracker.exceptions import DataParsingError + + with pytest.raises(DataParsingError): + get_applications_from_system_profiler({}) + + def test_none_data_raises(self): + """Lines 230-232: None data raises DataParsingError.""" + from versiontracker.exceptions import DataParsingError + + with pytest.raises(DataParsingError): + get_applications_from_system_profiler(None) # type: ignore[arg-type] + + @patch("versiontracker.apps.finder.get_config") + def test_type_error_raises_data_parsing_error(self, mock_cfg): + """Lines 261-263: TypeError during iteration raises DataParsingError.""" + from versiontracker.exceptions import DataParsingError + + cfg = MagicMock() + cfg.skip_system_apps = False + cfg.skip_system_paths = False + mock_cfg.return_value = cfg + + # Integer is not iterable → TypeError inside the try block → DataParsingError + with pytest.raises(DataParsingError): + get_applications_from_system_profiler({"SPApplicationsDataType": 42}) # type: ignore[arg-type] + + +# --------------------------------------------------------------------------- +# _check_cask_installable_with_cache +# --------------------------------------------------------------------------- + + +class TestCheckCaskInstallableWithCache: + @patch("versiontracker.apps.finder.is_homebrew_available", return_value=False) + def test_homebrew_unavailable_raises(self, _mock): + """Lines 468-469: HomebrewError when Homebrew not available.""" + from versiontracker.apps.finder import _check_cask_installable_with_cache + + with pytest.raises(HomebrewError): + _check_cask_installable_with_cache("firefox", use_cache=False) + + +# --------------------------------------------------------------------------- +# _execute_cask_installable_check +# --------------------------------------------------------------------------- + + +class TestExecuteCaskInstallableCheck: + @patch("versiontracker.apps.finder._execute_brew_search", side_effect=RuntimeError("boom")) + def test_exception_returns_false(self, _mock): + """Lines 491-494: generic exception during brew search returns False.""" + result = _execute_cask_installable_check("firefox", None) + assert result is False + + +# --------------------------------------------------------------------------- +# _handle_batch_error +# --------------------------------------------------------------------------- + + +class TestHandleBatchError: + def _batch(self): + return [("App1", "1.0"), ("App2", "2.0")] + + def test_timeout_below_max(self): + """BrewTimeoutError below MAX_ERRORS: no exception raised.""" + results, count, exc = _handle_batch_error(BrewTimeoutError("t"), 1, self._batch()) + assert exc is None + assert count == 2 + + def test_timeout_at_max_raises(self): + """Lines 530-535: BrewTimeoutError at MAX_ERRORS returns exception.""" + _, _, exc = _handle_batch_error(BrewTimeoutError("t"), MAX_ERRORS - 1, self._batch()) + assert isinstance(exc, BrewTimeoutError) + + def test_network_below_max(self): + """NetworkError below MAX_ERRORS: no exception raised.""" + _, _, exc = _handle_batch_error(NetworkError("n"), 1, self._batch()) + assert exc is None + + def test_network_at_max_raises(self): + """Lines 538-543: NetworkError at MAX_ERRORS returns exception.""" + _, _, exc = _handle_batch_error(NetworkError("n"), MAX_ERRORS - 1, self._batch()) + assert isinstance(exc, NetworkError) + + def test_homebrew_at_max_raises(self): + """Lines 544-547: HomebrewError at MAX_ERRORS returns original error.""" + original = HomebrewError("h") + _, _, exc = _handle_batch_error(original, MAX_ERRORS - 1, self._batch()) + assert exc is original + + def test_generic_at_max_raises_homebrew_error(self): + """Lines 548-555: generic error at MAX_ERRORS returns HomebrewError.""" + _, _, exc = _handle_batch_error(ValueError("v"), MAX_ERRORS - 1, self._batch()) + assert isinstance(exc, HomebrewError) + + def test_all_error_types_return_failed_results(self): + """Failed results are always (name, version, False) tuples.""" + results, _, _ = _handle_batch_error(NetworkError("n"), 1, self._batch()) + assert all(not installable for _, _, installable in results) + + +# --------------------------------------------------------------------------- +# check_brew_install_candidates +# --------------------------------------------------------------------------- + + +class TestCheckBrewInstallCandidates: + def test_empty_data_returns_empty(self): + """Line 580: empty data returns empty list without touching Homebrew.""" + result = check_brew_install_candidates([], rate_limit=1) + assert result == [] + + @patch("versiontracker.apps.finder._is_async_homebrew_available", return_value=True) + @patch("versiontracker.apps.finder.async_check_brew_install_candidates", create=True) + def test_async_path_used(self, mock_async, _mock_avail): + """Lines 587-596: async path is taken when available.""" + mock_async.return_value = [("App1", "1.0", True)] + with patch.dict( + "sys.modules", + {"versiontracker.async_homebrew": MagicMock(async_check_brew_install_candidates=mock_async)}, + ): + finder_mod._async_homebrew_available = True + with patch("versiontracker.apps.finder._is_async_homebrew_available", return_value=True): + with patch( + "versiontracker.apps.finder.async_check_brew_install_candidates", + mock_async, + create=True, + ): + result = check_brew_install_candidates([("App1", "1.0")], rate_limit=1) + # Verify async result is returned when async path succeeded + assert isinstance(result, list) + + @patch("versiontracker.apps.finder._is_async_homebrew_available", return_value=True) + def test_async_failure_falls_back_to_sync(self, _mock_avail): + """Lines 597-598: async failure falls back to sync path.""" + import sys + + fake_async = MagicMock() + fake_async.async_check_brew_install_candidates = MagicMock(side_effect=RuntimeError("async fail")) + + with patch.dict(sys.modules, {"versiontracker.async_homebrew": fake_async}): + with patch("versiontracker.apps.finder._process_brew_batch", return_value=[("App1", "1.0", True)]): + result = check_brew_install_candidates([("App1", "1.0")], rate_limit=0) + assert isinstance(result, list) + + def test_rate_limit_attribute_extracted(self): + """Lines 582-583: rate_limit with .api_rate_limit attribute is unwrapped.""" + mock_rl = MagicMock() + mock_rl.api_rate_limit = 0 + # Just check it doesn't error; actual brew calls are mocked + with patch("versiontracker.apps.finder._is_async_homebrew_available", return_value=False): + with patch("versiontracker.apps.finder._process_brew_batch", return_value=[]): + result = check_brew_install_candidates([("App1", "1.0")], rate_limit=mock_rl) + assert isinstance(result, list) + + +# --------------------------------------------------------------------------- +# _create_rate_limiter +# --------------------------------------------------------------------------- + + +class TestCreateRateLimiter: + def test_int_rate_limit(self): + """Line 632-633: integer rate limit creates a limiter.""" + limiter = _create_rate_limiter(2) + assert limiter is not None + + def test_object_with_api_rate_limit_attr(self): + """Lines 634-636: object with api_rate_limit attribute is used.""" + obj = MagicMock() + obj.api_rate_limit = 3 + limiter = _create_rate_limiter(obj) + assert limiter is not None + + def test_dict_like_rate_limit(self): + """Lines 637-638: dict-like object uses .get('api_rate_limit', 1).""" + + class DictLike: + def get(self, key, default=None): + return 5 if key == "api_rate_limit" else default + + limiter = _create_rate_limiter(DictLike()) + assert limiter is not None + + def test_attribute_error_uses_default(self): + """Lines 639-640: AttributeError falls back to default rate limit.""" + + class BadObj: + @property + def api_rate_limit(self): + raise AttributeError("no attr") + + limiter = _create_rate_limiter(BadObj()) + assert limiter is not None + + +# --------------------------------------------------------------------------- +# _get_existing_brews +# --------------------------------------------------------------------------- + + +class TestGetExistingBrews: + @patch("versiontracker.apps.finder.get_homebrew_casks_list", side_effect=HomebrewError("no brew")) + def test_homebrew_error_returns_empty(self, _mock): + """Lines 849-850: HomebrewError returns empty list.""" + result = _get_existing_brews() + assert result == [] + + @patch("versiontracker.apps.finder.get_homebrew_casks_list", side_effect=RuntimeError("oops")) + def test_generic_error_returns_empty(self, _mock): + """Lines 851-852: generic exception returns empty list.""" + result = _get_existing_brews() + assert result == [] + + +# --------------------------------------------------------------------------- +# check_brew_update_candidates +# --------------------------------------------------------------------------- + + +class TestCheckBrewUpdateCandidates: + def test_empty_data_returns_empty_dict(self): + """Line 869-870: empty data returns {}.""" + result = check_brew_update_candidates([]) + assert result == {} + + @patch("versiontracker.apps.finder._is_async_homebrew_available", return_value=True) + def test_async_failure_falls_back_to_sync(self, _mock_avail): + """Lines 888-889: async failure falls back to synchronous path.""" + import sys + + fake_async = MagicMock() + fake_async.async_check_brew_update_candidates = MagicMock(side_effect=RuntimeError("fail")) + + with patch.dict(sys.modules, {"versiontracker.async_homebrew": fake_async}): + with patch("versiontracker.apps.finder._get_existing_brews", return_value=[]): + with patch("versiontracker.apps.finder._process_brew_search_batches", return_value={}): + with patch("versiontracker.apps.finder._populate_cask_versions"): + result = check_brew_update_candidates([("App1", "1.0")], rate_limit=0) + assert isinstance(result, dict) + + +# --------------------------------------------------------------------------- +# _should_show_progress +# --------------------------------------------------------------------------- + + +class TestShouldShowProgress: + @patch("versiontracker.apps.finder.get_config") + def test_no_progress_flag_suppresses(self, mock_cfg): + """Lines 931-932: no_progress=True suppresses progress.""" + cfg = MagicMock() + cfg.show_progress = True + cfg.no_progress = True + mock_cfg.return_value = cfg + assert _should_show_progress() is False + + @patch("versiontracker.apps.finder.get_config") + def test_show_progress_true(self, mock_cfg): + """Line 930: show_progress=True without no_progress returns True.""" + cfg = MagicMock(spec=[]) # no no_progress attr + cfg.show_progress = True + mock_cfg.return_value = cfg + assert _should_show_progress() is True