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