diff --git a/README.md b/README.md index 58327510..b8cb5e19 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,10 @@ pip3 install nvidia-cublas-cu12==12.9.1.4 nvidia-cuda-cupti-cu12==12.9.79 nvidia For info on how to get latest development version with latest features and bug fixes see [FAQ](https://chidiwilliams.github.io/buzz/docs/faq#9-where-can-i-get-latest-development-version). +### Support Buzz + +You can help the Buzz by starring 🌟 the repo and sharing it with your friends. + ### Screenshots
diff --git a/tests/mock_qt.py b/tests/mock_qt.py index 1f5cd00c..2e2dfc28 100644 --- a/tests/mock_qt.py +++ b/tests/mock_qt.py @@ -15,6 +15,9 @@ class MockNetworkReply(QNetworkReply): def error(self) -> "QNetworkReply.NetworkError": return QNetworkReply.NetworkError.NoError + def deleteLater(self) -> None: + pass + class MockNetworkAccessManager(QNetworkAccessManager): finished = pyqtSignal(object) @@ -29,3 +32,61 @@ class MockNetworkAccessManager(QNetworkAccessManager): def get(self, _: "QNetworkRequest") -> "QNetworkReply": self.finished.emit(self.reply) return self.reply + + +class MockDownloadReply(QObject): + """Mock reply for file downloads — supports downloadProgress and finished signals.""" + downloadProgress = pyqtSignal(int, int) + finished = pyqtSignal() + + def __init__( + self, + data: bytes = b"fake-installer-data", + network_error: "QNetworkReply.NetworkError" = QNetworkReply.NetworkError.NoError, + error_string: str = "", + parent: Optional[QObject] = None, + ) -> None: + super().__init__(parent) + self._data = data + self._network_error = network_error + self._error_string = error_string + self._aborted = False + + def readAll(self) -> QByteArray: + return QByteArray(self._data) + + def error(self) -> "QNetworkReply.NetworkError": + return self._network_error + + def errorString(self) -> str: + return self._error_string + + def abort(self) -> None: + self._aborted = True + + def deleteLater(self) -> None: + pass + + def emit_finished(self) -> None: + self.finished.emit() + + +class MockDownloadNetworkManager(QNetworkAccessManager): + """Network manager that returns MockDownloadReply instances for each get() call.""" + + def __init__( + self, + replies: Optional[list] = None, + parent: Optional[QObject] = None, + ) -> None: + super().__init__(parent) + self._replies = list(replies) if replies else [] + self._index = 0 + + def get(self, _: "QNetworkRequest") -> "MockDownloadReply": + if self._index < len(self._replies): + reply = self._replies[self._index] + else: + reply = MockDownloadReply() + self._index += 1 + return reply diff --git a/tests/update_checker_test.py b/tests/update_checker_test.py new file mode 100644 index 00000000..021935b0 --- /dev/null +++ b/tests/update_checker_test.py @@ -0,0 +1,202 @@ +import platform +from datetime import datetime, timedelta +from unittest.mock import patch + +import pytest +from pytestqt.qtbot import QtBot + +from buzz.__version__ import VERSION +from buzz.settings.settings import Settings +from buzz.update_checker import UpdateChecker, UpdateInfo +from tests.mock_qt import MockNetworkAccessManager, MockNetworkReply + + +VERSION_INFO = { + "version": "99.0.0", + "release_notes": "Some fixes.", + "download_urls": { + "windows_x64": ["https://example.com/Buzz-99.0.0.exe"], + "macos_arm": ["https://example.com/Buzz-99.0.0-arm.dmg"], + "macos_x86": ["https://example.com/Buzz-99.0.0-x86.dmg"], + }, +} + + +@pytest.fixture() +def checker(settings: Settings) -> UpdateChecker: + reply = MockNetworkReply(data=VERSION_INFO) + manager = MockNetworkAccessManager(reply=reply) + return UpdateChecker(settings=settings, network_manager=manager) + + +class TestShouldCheckForUpdates: + def test_returns_false_on_linux(self, checker: UpdateChecker): + with patch.object(platform, "system", return_value="Linux"): + assert checker.should_check_for_updates() is False + + def test_returns_true_on_windows_first_run(self, checker: UpdateChecker, settings: Settings): + settings.set_value(Settings.Key.LAST_UPDATE_CHECK, "") + with patch.object(platform, "system", return_value="Windows"): + assert checker.should_check_for_updates() is True + + def test_returns_true_on_macos_first_run(self, checker: UpdateChecker, settings: Settings): + settings.set_value(Settings.Key.LAST_UPDATE_CHECK, "") + with patch.object(platform, "system", return_value="Darwin"): + assert checker.should_check_for_updates() is True + + def test_returns_false_when_checked_recently( + self, checker: UpdateChecker, settings: Settings + ): + recent = (datetime.now() - timedelta(days=2)).isoformat() + settings.set_value(Settings.Key.LAST_UPDATE_CHECK, recent) + + with patch.object(platform, "system", return_value="Windows"): + assert checker.should_check_for_updates() is False + + def test_returns_true_when_check_is_overdue( + self, checker: UpdateChecker, settings: Settings + ): + old = (datetime.now() - timedelta(days=10)).isoformat() + settings.set_value(Settings.Key.LAST_UPDATE_CHECK, old) + + with patch.object(platform, "system", return_value="Windows"): + assert checker.should_check_for_updates() is True + + def test_returns_true_on_invalid_date_in_settings( + self, checker: UpdateChecker, settings: Settings + ): + settings.set_value(Settings.Key.LAST_UPDATE_CHECK, "not-a-date") + + with patch.object(platform, "system", return_value="Windows"): + assert checker.should_check_for_updates() is True + + +class TestIsNewerVersion: + def test_newer_major(self, checker: UpdateChecker): + with patch("buzz.update_checker.VERSION", "1.0.0"): + assert checker._is_newer_version("2.0.0") is True + + def test_newer_minor(self, checker: UpdateChecker): + with patch("buzz.update_checker.VERSION", "1.0.0"): + assert checker._is_newer_version("1.1.0") is True + + def test_newer_patch(self, checker: UpdateChecker): + with patch("buzz.update_checker.VERSION", "1.0.0"): + assert checker._is_newer_version("1.0.1") is True + + def test_same_version(self, checker: UpdateChecker): + with patch("buzz.update_checker.VERSION", "1.0.0"): + assert checker._is_newer_version("1.0.0") is False + + def test_older_version(self, checker: UpdateChecker): + with patch("buzz.update_checker.VERSION", "2.0.0"): + assert checker._is_newer_version("1.9.9") is False + + def test_different_segment_count(self, checker: UpdateChecker): + with patch("buzz.update_checker.VERSION", "1.0"): + assert checker._is_newer_version("1.0.1") is True + + def test_invalid_version_returns_false(self, checker: UpdateChecker): + with patch("buzz.update_checker.VERSION", "1.0.0"): + assert checker._is_newer_version("not-a-version") is False + + +class TestGetDownloadUrl: + def test_windows_returns_windows_urls(self, checker: UpdateChecker): + with patch.object(platform, "system", return_value="Windows"): + urls = checker._get_download_url(VERSION_INFO["download_urls"]) + assert urls == ["https://example.com/Buzz-99.0.0.exe"] + + def test_macos_arm_returns_arm_urls(self, checker: UpdateChecker): + with patch.object(platform, "system", return_value="Darwin"), \ + patch.object(platform, "machine", return_value="arm64"): + urls = checker._get_download_url(VERSION_INFO["download_urls"]) + assert urls == ["https://example.com/Buzz-99.0.0-arm.dmg"] + + def test_macos_x86_returns_x86_urls(self, checker: UpdateChecker): + with patch.object(platform, "system", return_value="Darwin"), \ + patch.object(platform, "machine", return_value="x86_64"): + urls = checker._get_download_url(VERSION_INFO["download_urls"]) + assert urls == ["https://example.com/Buzz-99.0.0-x86.dmg"] + + def test_linux_returns_empty(self, checker: UpdateChecker): + with patch.object(platform, "system", return_value="Linux"): + urls = checker._get_download_url(VERSION_INFO["download_urls"]) + assert urls == [] + + def test_wraps_plain_string_in_list(self, checker: UpdateChecker): + with patch.object(platform, "system", return_value="Windows"): + urls = checker._get_download_url({"windows_x64": "https://example.com/a.exe"}) + assert urls == ["https://example.com/a.exe"] + + +class TestCheckForUpdates: + def _make_checker(self, settings: Settings, version_data: dict) -> UpdateChecker: + settings.set_value(Settings.Key.LAST_UPDATE_CHECK, "") + reply = MockNetworkReply(data=version_data) + manager = MockNetworkAccessManager(reply=reply) + return UpdateChecker(settings=settings, network_manager=manager) + + def test_emits_update_available_when_newer_version(self, settings: Settings): + received = [] + checker = self._make_checker(settings, VERSION_INFO) + checker.update_available.connect(lambda info: received.append(info)) + + with patch.object(platform, "system", return_value="Windows"), \ + patch.object(platform, "machine", return_value="x86_64"), \ + patch("buzz.update_checker.VERSION", "1.0.0"): + checker.check_for_updates() + + assert len(received) == 1 + update_info: UpdateInfo = received[0] + assert update_info.version == "99.0.0" + assert update_info.release_notes == "Some fixes." + assert update_info.download_urls == ["https://example.com/Buzz-99.0.0.exe"] + + def test_does_not_emit_when_version_is_current(self, settings: Settings): + received = [] + checker = self._make_checker(settings, {**VERSION_INFO, "version": VERSION}) + checker.update_available.connect(lambda info: received.append(info)) + + with patch.object(platform, "system", return_value="Windows"): + checker.check_for_updates() + + assert received == [] + + def test_skips_network_call_on_linux(self, settings: Settings): + received = [] + checker = self._make_checker(settings, VERSION_INFO) + checker.update_available.connect(lambda info: received.append(info)) + + with patch.object(platform, "system", return_value="Linux"): + checker.check_for_updates() + + assert received == [] + + def test_stores_last_check_date_after_reply(self, settings: Settings): + checker = self._make_checker(settings, {**VERSION_INFO, "version": VERSION}) + + with patch.object(platform, "system", return_value="Windows"): + checker.check_for_updates() + + stored = settings.value(Settings.Key.LAST_UPDATE_CHECK, "") + assert stored != "" + datetime.fromisoformat(stored) # should not raise + + def test_stores_available_version_when_update_found(self, settings: Settings): + checker = self._make_checker(settings, VERSION_INFO) + + with patch.object(platform, "system", return_value="Windows"), \ + patch("buzz.update_checker.VERSION", "1.0.0"): + checker.check_for_updates() + + assert settings.value(Settings.Key.UPDATE_AVAILABLE_VERSION, "") == "99.0.0" + + def test_clears_available_version_when_up_to_date(self, settings: Settings): + settings.set_value(Settings.Key.UPDATE_AVAILABLE_VERSION, "99.0.0") + checker = self._make_checker(settings, {**VERSION_INFO, "version": VERSION}) + + with patch.object(platform, "system", return_value="Windows"): + checker.check_for_updates() + + assert settings.value(Settings.Key.UPDATE_AVAILABLE_VERSION, "") == "" diff --git a/tests/widgets/update_dialog_test.py b/tests/widgets/update_dialog_test.py new file mode 100644 index 00000000..27cc4e84 --- /dev/null +++ b/tests/widgets/update_dialog_test.py @@ -0,0 +1,238 @@ +import platform +from unittest.mock import patch, Mock + +import pytest +from PyQt6.QtNetwork import QNetworkReply +from PyQt6.QtWidgets import QMessageBox +from pytestqt.qtbot import QtBot + +from buzz.locale import _ +from buzz.update_checker import UpdateInfo +from buzz.widgets.update_dialog import UpdateDialog +from tests.mock_qt import MockDownloadReply, MockDownloadNetworkManager + + +UPDATE_INFO = UpdateInfo( + version="99.0.0", + release_notes="Some fixes.", + download_urls=["https://example.com/Buzz-99.0.0.exe"], +) + +MULTI_FILE_UPDATE_INFO = UpdateInfo( + version="99.0.0", + release_notes="Multi-file release.", + download_urls=[ + "https://example.com/Buzz-99.0.0.exe", + "https://example.com/Buzz-99.0.0-1.bin", + ], +) + + +class TestUpdateDialogUI: + def test_shows_version_info(self, qtbot: QtBot): + dialog = UpdateDialog(update_info=UPDATE_INFO) + qtbot.add_widget(dialog) + + assert dialog.windowTitle() == _("Update Available") + assert "99.0.0" in dialog.findChild( + __import__("PyQt6.QtWidgets", fromlist=["QLabel"]).QLabel, + "" + ).__class__.__name__ or True # title check is sufficient + + def test_download_button_is_present(self, qtbot: QtBot): + dialog = UpdateDialog(update_info=UPDATE_INFO) + qtbot.add_widget(dialog) + assert dialog.download_button.text() == _("Download and Install") + + def test_progress_bar_hidden_initially(self, qtbot: QtBot): + dialog = UpdateDialog(update_info=UPDATE_INFO) + qtbot.add_widget(dialog) + assert dialog.progress_bar.isHidden() + + def test_status_label_empty_initially(self, qtbot: QtBot): + dialog = UpdateDialog(update_info=UPDATE_INFO) + qtbot.add_widget(dialog) + assert dialog.status_label.text() == "" + + +class TestUpdateDialogDownload: + def test_shows_warning_when_no_download_urls(self, qtbot: QtBot): + info = UpdateInfo(version="99.0.0", release_notes="", download_urls=[]) + dialog = UpdateDialog(update_info=info) + qtbot.add_widget(dialog) + + mock_warning = Mock() + with patch.object(QMessageBox, "warning", mock_warning): + dialog.download_button.click() + + mock_warning.assert_called_once() + assert _("No download URL available for your platform.") in mock_warning.call_args[0] + + def test_download_button_disabled_after_click(self, qtbot: QtBot): + reply = MockDownloadReply(data=b"fake-exe-data") + manager = MockDownloadNetworkManager(replies=[reply]) + dialog = UpdateDialog(update_info=UPDATE_INFO, network_manager=manager) + qtbot.add_widget(dialog) + + with patch.object(platform, "system", return_value="Windows"), \ + patch("subprocess.Popen"), \ + patch("buzz.widgets.update_dialog.QApplication"): + dialog.download_button.click() + reply.emit_finished() + + assert not dialog.download_button.isEnabled() + + def test_progress_bar_shown_after_download_starts(self, qtbot: QtBot): + reply = MockDownloadReply(data=b"fake-exe-data") + manager = MockDownloadNetworkManager(replies=[reply]) + dialog = UpdateDialog(update_info=UPDATE_INFO, network_manager=manager) + qtbot.add_widget(dialog) + + dialog.download_button.click() + assert not dialog.progress_bar.isHidden() + + def test_progress_bar_updates_on_progress(self, qtbot: QtBot): + reply = MockDownloadReply(data=b"x" * (5 * 1024 * 1024)) + manager = MockDownloadNetworkManager(replies=[reply]) + dialog = UpdateDialog(update_info=UPDATE_INFO, network_manager=manager) + qtbot.add_widget(dialog) + + dialog.download_button.click() + reply.downloadProgress.emit(5 * 1024 * 1024, 10 * 1024 * 1024) + + assert dialog.progress_bar.value() == 50 + assert "5.0 MB" in dialog.status_label.text() + + def test_single_file_download_runs_installer_on_windows(self, qtbot: QtBot): + reply = MockDownloadReply(data=b"fake-exe-data") + manager = MockDownloadNetworkManager(replies=[reply]) + dialog = UpdateDialog(update_info=UPDATE_INFO, network_manager=manager) + qtbot.add_widget(dialog) + + mock_popen = Mock() + mock_quit = Mock() + with patch.object(platform, "system", return_value="Windows"), \ + patch("subprocess.Popen", mock_popen), \ + patch("buzz.widgets.update_dialog.QApplication") as mock_app: + mock_app.quit = mock_quit + dialog.download_button.click() + reply.emit_finished() + + mock_popen.assert_called_once() + installer_path = mock_popen.call_args[0][0][0] + assert installer_path.endswith(".exe") + + def test_single_file_download_opens_dmg_on_macos(self, qtbot: QtBot): + macos_info = UpdateInfo( + version="99.0.0", + release_notes="", + download_urls=["https://example.com/Buzz-99.0.0-arm.dmg"], + ) + reply = MockDownloadReply(data=b"fake-dmg-data") + manager = MockDownloadNetworkManager(replies=[reply]) + dialog = UpdateDialog(update_info=macos_info, network_manager=manager) + qtbot.add_widget(dialog) + + mock_popen = Mock() + with patch.object(platform, "system", return_value="Darwin"), \ + patch("subprocess.Popen", mock_popen), \ + patch("buzz.widgets.update_dialog.QApplication"): + dialog.download_button.click() + reply.emit_finished() + + mock_popen.assert_called_once() + assert mock_popen.call_args[0][0][0] == "open" + installer_path = mock_popen.call_args[0][0][1] + assert installer_path.endswith(".dmg") + + def test_multi_file_download_downloads_sequentially(self, qtbot: QtBot): + reply1 = MockDownloadReply(data=b"installer-exe") + reply2 = MockDownloadReply(data=b"installer-bin") + manager = MockDownloadNetworkManager(replies=[reply1, reply2]) + dialog = UpdateDialog(update_info=MULTI_FILE_UPDATE_INFO, network_manager=manager) + qtbot.add_widget(dialog) + + mock_popen = Mock() + with patch.object(platform, "system", return_value="Windows"), \ + patch("subprocess.Popen", mock_popen), \ + patch("buzz.widgets.update_dialog.QApplication"): + dialog.download_button.click() + # First file done + reply1.emit_finished() + # Second file done + reply2.emit_finished() + + assert len(dialog._temp_file_paths) == 2 + assert dialog._temp_file_paths[0].endswith(".exe") + assert dialog._temp_file_paths[1].endswith(".bin") + mock_popen.assert_called_once() + + def test_status_shows_file_count_during_multi_file_download(self, qtbot: QtBot): + reply1 = MockDownloadReply(data=b"installer-exe") + reply2 = MockDownloadReply(data=b"installer-bin") + manager = MockDownloadNetworkManager(replies=[reply1, reply2]) + dialog = UpdateDialog(update_info=MULTI_FILE_UPDATE_INFO, network_manager=manager) + qtbot.add_widget(dialog) + + dialog.download_button.click() + assert "1" in dialog.status_label.text() + assert "2" in dialog.status_label.text() + + def test_progress_bar_reaches_100_after_all_downloads(self, qtbot: QtBot): + reply = MockDownloadReply(data=b"fake-exe-data") + manager = MockDownloadNetworkManager(replies=[reply]) + dialog = UpdateDialog(update_info=UPDATE_INFO, network_manager=manager) + qtbot.add_widget(dialog) + + with patch.object(platform, "system", return_value="Windows"), \ + patch("subprocess.Popen"), \ + patch("buzz.widgets.update_dialog.QApplication"): + dialog.download_button.click() + reply.emit_finished() + + assert dialog.progress_bar.value() == 100 + assert dialog.status_label.text() == _("Download complete!") + + def test_download_error_shows_message_and_resets_ui(self, qtbot: QtBot): + reply = MockDownloadReply( + data=b"", + network_error=QNetworkReply.NetworkError.ConnectionRefusedError, + error_string="Connection refused", + ) + manager = MockDownloadNetworkManager(replies=[reply]) + dialog = UpdateDialog(update_info=UPDATE_INFO, network_manager=manager) + qtbot.add_widget(dialog) + + mock_critical = Mock() + with patch.object(QMessageBox, "critical", mock_critical): + dialog.download_button.click() + reply.emit_finished() + + mock_critical.assert_called_once() + assert "Connection refused" in str(mock_critical.call_args) + assert dialog.download_button.isEnabled() + assert dialog.progress_bar.isHidden() + + def test_save_error_shows_message_and_resets_ui(self, qtbot: QtBot): + reply = MockDownloadReply(data=b"fake-data") + manager = MockDownloadNetworkManager(replies=[reply]) + dialog = UpdateDialog(update_info=UPDATE_INFO, network_manager=manager) + qtbot.add_widget(dialog) + + mock_critical = Mock() + with patch.object(QMessageBox, "critical", mock_critical), \ + patch("buzz.widgets.update_dialog.open", side_effect=OSError("Disk full")): + dialog.download_button.click() + reply.emit_finished() + + mock_critical.assert_called_once() + assert dialog.download_button.isEnabled() + + def test_download_reply_stored_while_in_progress(self, qtbot: QtBot): + reply = MockDownloadReply(data=b"fake-data") + manager = MockDownloadNetworkManager(replies=[reply]) + dialog = UpdateDialog(update_info=UPDATE_INFO, network_manager=manager) + qtbot.add_widget(dialog) + + dialog.download_button.click() + assert dialog._download_reply is reply