From 9b0a08bfa7b8b20daee8918c6da79e797de7d6e2 Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Mon, 2 Mar 2026 19:52:39 -0800 Subject: [PATCH 1/2] Lint Fixes --- pyproject.toml | 15 +-- synodic_client/application/screen/screen.py | 120 ++++++++++---------- tests/unit/qt/test_gather_packages.py | 68 ++++------- uninstall_debug.txt | 74 ------------ 4 files changed, 86 insertions(+), 191 deletions(-) delete mode 100644 uninstall_debug.txt diff --git a/pyproject.toml b/pyproject.toml index a6bd48f..3e294fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,18 +25,9 @@ homepage = "https://github.com/synodic/synodic-client" repository = "https://github.com/synodic/synodic-client" [dependency-groups] -build = [ - "pyinstaller>=6.19.0", -] -lint = [ - "ruff>=0.15.4", - "pyrefly>=0.55.0", -] -test = [ - "pytest>=9.0.2", - "pytest-cov>=7.0.0", - "pytest-mock>=3.15.1", -] +build = ["pyinstaller>=6.19.0"] +lint = ["ruff>=0.15.4", "pyrefly>=0.55.0"] +test = ["pytest>=9.0.2", "pytest-cov>=7.0.0", "pytest-mock>=3.15.1"] [project.scripts] synodic-c = "synodic_client.cli:app" diff --git a/synodic_client/application/screen/screen.py b/synodic_client/application/screen/screen.py index 400d919..0be9ae0 100644 --- a/synodic_client/application/screen/screen.py +++ b/synodic_client/application/screen/screen.py @@ -8,6 +8,7 @@ from porringer.api import API from porringer.backend.builder import Builder +from porringer.backend.command.core.discovery import DiscoveredPlugins from porringer.core.plugin_schema.plugin_manager import PluginManager from porringer.core.plugin_schema.project_environment import ProjectEnvironment from porringer.schema import ( @@ -1158,26 +1159,29 @@ def _active_chip_plugins(self) -> set[str] | None: return None return {name for name, chip in self._filter_chips.items() if chip.isChecked()} - def _apply_filter(self, _text: str | None = None) -> None: - """Show/hide section widgets based on search text and active chips. + @staticmethod + def _is_plugin_active(plugin_name: str, active: set[str] | None) -> bool: + """Return whether *plugin_name* passes the chip filter.""" + return active is None or plugin_name in active - A single pass walks ``_section_widgets`` tracking the current - plugin and kind. Visibility rules: + @staticmethod + def _finalise_provider( + provider: PluginProviderHeader | None, + has_visible: bool, + ) -> bool: + """Set provider visibility and return whether it had visible children.""" + if provider is not None: + provider.setVisible(has_visible) + return has_visible - * **PluginProviderHeader** — visible when its plugin is in the - active chip set **and** at least one child row matches the - search text. - * **PluginRow** — visible when its plugin is active **and** its - package name or plugin name contains the search text. - * **ProjectChildRow** — follows its parent :class:`PluginRow`. - * **PluginKindHeader** — visible when at least one child - provider in its kind group is visible. + def _apply_filter(self, _text: str | None = None) -> None: + """Show/hide section widgets based on search text and active chips. - After the pass, kind headers with no visible children are hidden. + Delegates to :meth:`_is_plugin_active` for chip matching and + :meth:`_finalise_provider` for provider visibility bookkeeping. """ query = self._search_input.text().strip().lower() active = self._active_chip_plugins() - all_active = active is None # None → no chips yet, show all current_kind_header: PluginKindHeader | None = None kind_has_visible = False @@ -1187,13 +1191,8 @@ def _apply_filter(self, _text: str | None = None) -> None: for widget in self._section_widgets: if isinstance(widget, PluginKindHeader): - # Finalise previous kind + kind_has_visible |= self._finalise_provider(current_provider, provider_has_visible_child) if current_kind_header is not None: - # Finalise last provider of previous kind - if current_provider is not None: - current_provider.setVisible(provider_has_visible_child) - if provider_has_visible_child: - kind_has_visible = True current_kind_header.setVisible(kind_has_visible) current_kind_header = widget @@ -1202,31 +1201,20 @@ def _apply_filter(self, _text: str | None = None) -> None: provider_has_visible_child = False elif isinstance(widget, PluginProviderHeader): - # Finalise previous provider - if current_provider is not None: - current_provider.setVisible(provider_has_visible_child) - if provider_has_visible_child: - kind_has_visible = True + kind_has_visible |= self._finalise_provider(current_provider, provider_has_visible_child) current_provider = widget provider_has_visible_child = False - plugin_name = widget._plugin_name - plugin_active = all_active or (active is not None and plugin_name in active) - - if not plugin_active: - # Entire provider hidden + if not self._is_plugin_active(widget._plugin_name, active): widget.setVisible(False) - provider_has_visible_child = False elif isinstance(widget, PluginRow): - plugin_name = widget._plugin_name - plugin_active = all_active or (active is not None and plugin_name in active) - if not plugin_active: + if not self._is_plugin_active(widget._plugin_name, active): widget.setVisible(False) parent_row_visible = False continue - name_match = not query or query in widget._package_name.lower() or query in plugin_name.lower() + name_match = not query or query in widget._package_name.lower() or query in widget._plugin_name.lower() widget.setVisible(name_match) parent_row_visible = name_match if name_match: @@ -1236,10 +1224,7 @@ def _apply_filter(self, _text: str | None = None) -> None: widget.setVisible(parent_row_visible) # Finalise last provider and kind - if current_provider is not None: - current_provider.setVisible(provider_has_visible_child) - if provider_has_visible_child: - kind_has_visible = True + kind_has_visible |= self._finalise_provider(current_provider, provider_has_visible_child) if current_kind_header is not None: current_kind_header.setVisible(kind_has_visible) @@ -1834,6 +1819,7 @@ async def _async_refresh(self) -> None: if self._coordinator is not None: snapshot = await self._coordinator.refresh() results = snapshot.validated_directories + discovered = snapshot.discovered else: loop = asyncio.get_running_loop() results = await loop.run_in_executor( @@ -1843,6 +1829,7 @@ async def _async_refresh(self) -> None: check_manifest=True, ), ) + discovered = None directories: list[tuple[Path, str, bool]] = [] current_paths: set[Path] = set() @@ -1854,32 +1841,12 @@ async def _async_refresh(self) -> None: current_paths.add(path) # Remove widgets for directories no longer in cache - for path in list(self._widgets): - if path not in current_paths: - widget = self._widgets.pop(path) - self._stack.removeWidget(widget) - widget.reset() - widget.deleteLater() + self._remove_stale_widgets(current_paths) # Grab pre-discovered plugins so each widget can skip redundant discovery - discovered = snapshot.discovered if self._coordinator is not None else None # Create new widgets for new directories - for path, _name, valid in directories: - if path not in self._widgets and valid: - widget = SetupPreviewWidget( - self._porringer, - self, - show_close=False, - config=self._config, - ) - widget._discovered_plugins = discovered - widget.install_finished.connect(self._on_install_finished) - widget.phase_changed.connect( - lambda phase, p=path: self._on_widget_phase_changed(p, phase), - ) - self._widgets[path] = widget - self._stack.addWidget(widget) + self._create_directory_widgets(directories, discovered) # Rebuild sidebar self._sidebar.set_directories(directories) @@ -1909,6 +1876,37 @@ async def _async_refresh(self) -> None: # --- Event handlers --- + def _remove_stale_widgets(self, current_paths: set[Path]) -> None: + """Remove stacked widgets for directories no longer in the cache.""" + for path in list(self._widgets): + if path not in current_paths: + widget = self._widgets.pop(path) + self._stack.removeWidget(widget) + widget.reset() + widget.deleteLater() + + def _create_directory_widgets( + self, + directories: list[tuple[Path, str, bool]], + discovered: DiscoveredPlugins | None, + ) -> None: + """Create :class:`SetupPreviewWidget` instances for new valid directories.""" + for path, _name, valid in directories: + if path not in self._widgets and valid: + widget = SetupPreviewWidget( + self._porringer, + self, + show_close=False, + config=self._config, + ) + widget._discovered_plugins = discovered + widget.install_finished.connect(self._on_install_finished) + widget.phase_changed.connect( + lambda phase, p=path: self._on_widget_phase_changed(p, phase), + ) + self._widgets[path] = widget + self._stack.addWidget(widget) + def _on_selection_changed(self, path: Path) -> None: """Handle sidebar selection — switch the stacked widget.""" widget = self._widgets.get(path) diff --git a/tests/unit/qt/test_gather_packages.py b/tests/unit/qt/test_gather_packages.py index ad4ea11..1d18f58 100644 --- a/tests/unit/qt/test_gather_packages.py +++ b/tests/unit/qt/test_gather_packages.py @@ -6,12 +6,30 @@ from pathlib import Path from unittest.mock import AsyncMock, MagicMock +from packaging.version import Version from porringer.core.schema import Package, PackageRelation, PackageRelationKind from porringer.schema import ManifestDirectory - -from synodic_client.application.screen.screen import PackageEntry, ProjectChildRow, ToolsView +from porringer.schema.plugin import PluginInfo, PluginKind +from PySide6.QtWidgets import QLabel, QPushButton + +from synodic_client.application.screen.screen import ( + FilterChip, + PackageEntry, + PluginKindHeader, + PluginProviderHeader, + PluginRow, + PluginRowData, + ProjectChildRow, + ProjectInstance, + ToolsView, +) from synodic_client.resolution import ResolvedConfig +# Named constants for expected counts (avoids PLR2004) +_EXPECTED_PROJECT_INSTANCES = 2 +_EXPECTED_VISIBLE_ROWS_ALL = 3 +_EXPECTED_VISIBLE_ROWS_PIPX = 2 + def _make_config() -> ResolvedConfig: """Build a minimal ResolvedConfig for tests.""" @@ -429,7 +447,7 @@ def test_multiple_projects_same_package() -> None: assert len(result) == 1 pkg = result[0] - assert len(pkg.project_instances) == 2 + assert len(pkg.project_instances) == _EXPECTED_PROJECT_INSTANCES labels = {pi.project_label for pi in pkg.project_instances} assert labels == {'project-a', 'project-b'} @@ -479,8 +497,6 @@ class TestProjectChildRow: @staticmethod def _make_instance(*, transitive: bool = False) -> ProjectChildRow: - from synodic_client.application.screen.screen import ProjectInstance - return ProjectChildRow( ProjectInstance( project_label='periapsis', @@ -498,8 +514,6 @@ def test_navigate_signal_emitted(self) -> None: row.navigate_to_project.connect(spy) # Find the navigate button (→) - from PySide6.QtWidgets import QPushButton - nav_btns = [w for w in row.findChildren(QPushButton) if w.text() == '\u2192'] assert len(nav_btns) == 1 nav_btns[0].click() @@ -518,8 +532,6 @@ def test_transitive_label_hidden_when_not_transitive(self) -> None: assert len(labels) == 0 -from PySide6.QtWidgets import QLabel - # --------------------------------------------------------------------------- # FilterChip # --------------------------------------------------------------------------- @@ -531,8 +543,6 @@ class TestFilterChip: @staticmethod def test_chip_starts_checked() -> None: """Filter chips start in the checked (active) state.""" - from synodic_client.application.screen.screen import FilterChip - chip = FilterChip('pipx') assert chip.isChecked() assert chip.text() == 'pipx' @@ -540,8 +550,6 @@ def test_chip_starts_checked() -> None: @staticmethod def test_toggling_emits_signal() -> None: """Toggling a chip emits the plugin name and new state.""" - from synodic_client.application.screen.screen import FilterChip - chip = FilterChip('uv') spy = MagicMock() chip.toggled_with_name.connect(spy) @@ -552,8 +560,6 @@ def test_toggling_emits_signal() -> None: @staticmethod def test_recheck_emits_true() -> None: """Re-checking a chip emits True.""" - from synodic_client.application.screen.screen import FilterChip - chip = FilterChip('pip') spy = MagicMock() chip.setChecked(False) @@ -589,16 +595,6 @@ def _populate_section_widgets(view: ToolsView) -> None: ProviderHeader(uv) PluginRow(mypy, plugin=uv) """ - from packaging.version import Version - from porringer.schema.plugin import PluginInfo, PluginKind - - from synodic_client.application.screen.screen import ( - PluginKindHeader, - PluginProviderHeader, - PluginRow, - PluginRowData, - ) - kind_hdr = PluginKindHeader(PluginKind.TOOL) view._section_widgets.append(kind_hdr) view._container_layout.insertWidget(0, kind_hdr) @@ -639,8 +635,6 @@ def test_search_hides_non_matching_rows(self) -> None: view._search_input.setText('ruff') - from synodic_client.application.screen.screen import PluginRow - visible_rows = [w for w in view._section_widgets if isinstance(w, PluginRow) and not w.isHidden()] assert len(visible_rows) == 1 assert visible_rows[0]._package_name == 'ruff' @@ -653,10 +647,8 @@ def test_empty_search_shows_all(self) -> None: view._search_input.setText('ruff') view._search_input.setText('') - from synodic_client.application.screen.screen import PluginRow - visible_rows = [w for w in view._section_widgets if isinstance(w, PluginRow) and not w.isHidden()] - assert len(visible_rows) == 3 + assert len(visible_rows) == _EXPECTED_VISIBLE_ROWS_ALL def test_chip_deselection_hides_plugin(self) -> None: """Deselecting a chip hides all rows from that plugin.""" @@ -665,8 +657,6 @@ def test_chip_deselection_hides_plugin(self) -> None: view._filter_chips['pipx'].setChecked(False) - from synodic_client.application.screen.screen import PluginRow - visible_rows = [w for w in view._section_widgets if isinstance(w, PluginRow) and not w.isHidden()] assert len(visible_rows) == 1 assert visible_rows[0]._package_name == 'mypy' @@ -679,10 +669,8 @@ def test_chip_reselection_restores(self) -> None: view._filter_chips['pipx'].setChecked(False) view._filter_chips['pipx'].setChecked(True) - from synodic_client.application.screen.screen import PluginRow - visible_rows = [w for w in view._section_widgets if isinstance(w, PluginRow) and not w.isHidden()] - assert len(visible_rows) == 3 + assert len(visible_rows) == _EXPECTED_VISIBLE_ROWS_ALL def test_search_plus_chip_filter(self) -> None: """Search and chip filtering compose — only matching rows in active plugins survive.""" @@ -692,8 +680,6 @@ def test_search_plus_chip_filter(self) -> None: view._filter_chips['uv'].setChecked(False) view._search_input.setText('pdm') - from synodic_client.application.screen.screen import PluginRow - visible_rows = [w for w in view._section_widgets if isinstance(w, PluginRow) and not w.isHidden()] assert len(visible_rows) == 1 assert visible_rows[0]._package_name == 'pdm' @@ -707,8 +693,6 @@ def test_kind_header_hidden_when_no_children_visible(self) -> None: view._filter_chips['pipx'].setChecked(False) view._filter_chips['uv'].setChecked(False) - from synodic_client.application.screen.screen import PluginKindHeader - hidden_kinds = [w for w in view._section_widgets if isinstance(w, PluginKindHeader) and w.isHidden()] assert len(hidden_kinds) == 1 @@ -719,8 +703,6 @@ def test_provider_hidden_when_search_matches_nothing(self) -> None: view._search_input.setText('mypy') - from synodic_client.application.screen.screen import PluginProviderHeader - visible_providers = [ w for w in view._section_widgets if isinstance(w, PluginProviderHeader) and not w.isHidden() ] @@ -734,10 +716,8 @@ def test_search_matches_plugin_name(self) -> None: view._search_input.setText('pipx') - from synodic_client.application.screen.screen import PluginRow - visible_rows = [w for w in view._section_widgets if isinstance(w, PluginRow) and not w.isHidden()] - assert len(visible_rows) == 2 + assert len(visible_rows) == _EXPECTED_VISIBLE_ROWS_PIPX names = {w._package_name for w in visible_rows} assert names == {'ruff', 'pdm'} diff --git a/uninstall_debug.txt b/uninstall_debug.txt deleted file mode 100644 index 404cabd..0000000 --- a/uninstall_debug.txt +++ /dev/null @@ -1,74 +0,0 @@ -.venv\Scripts\python.exe : DEBUG:asyncio:Using proactor: IocpProactor -At line:1 char:1 -+ .venv\Scripts\python.exe -c " -+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - + CategoryInfo : NotSpecified: (DEBUG:asyncio:U...r: IocpProactor - :String) [], RemoteException - + FullyQualifiedErrorId : NativeCommandError - -DEBUG:porringer.backend.builder:Entry points for porringer.environment: -['apt', 'brew', 'bun', 'deno', 'npm', 'pim', 'pip', 'pipx', 'pnpm', 'pyenv', -'uv', 'winget'] -DEBUG:porringer.backend.builder:environment plugin found: apt -DEBUG:porringer.backend.builder:environment plugin found: brew -DEBUG:porringer.backend.builder:environment plugin found: bun -DEBUG:porringer.backend.builder:environment plugin found: deno -DEBUG:porringer.backend.builder:environment plugin found: npm -DEBUG:porringer.backend.builder:environment plugin found: pim -DEBUG:porringer.backend.builder:environment plugin found: pip -DEBUG:porringer.backend.builder:environment plugin found: pipx -DEBUG:porringer.backend.builder:environment plugin found: pnpm -DEBUG:porringer.backend.builder:environment plugin found: pyenv -DEBUG:porringer.backend.builder:environment plugin found: uv -DEBUG:porringer.backend.builder:environment plugin found: winget -DEBUG:porringer.backend.builder:Plugin 'pim' dependency on 'winget' satisfied -DEBUG:porringer.backend.builder:Entry points for -porringer.project_environment: ['bun-project', 'deno-project', 'npm-project', -'pdm', 'pnpm-project', 'poetry', 'uv-project', 'yarn-project'] -DEBUG:porringer.backend.builder:project_environment plugin found: bun-project -DEBUG:porringer.backend.builder:project_environment plugin found: deno-project -DEBUG:porringer.backend.builder:project_environment plugin found: npm-project -DEBUG:porringer.backend.builder:project_environment plugin found: pdm -DEBUG:porringer.backend.builder:project_environment plugin found: pnpm-project -DEBUG:porringer.backend.builder:project_environment plugin found: poetry -DEBUG:porringer.backend.builder:project_environment plugin found: uv-project -DEBUG:porringer.backend.builder:project_environment plugin found: yarn-project -DEBUG:porringer.backend.builder:Entry points for porringer.scm: ['git'] -DEBUG:porringer.backend.builder:scm plugin found: git -INFO:porringer.backend.command.core.discovery:Plugin discovery: 12 -environments, 8 project, 1 scm -DEBUG:porringer.backend.command.core.discovery:Discovered plugins ù -environments: ['apt', 'brew', 'bun', 'deno', 'npm', 'pim', 'pip', 'pipx', -'pnpm', 'pyenv', 'uv', 'winget'], project: ['bun-project', 'deno-project', -'npm-project', 'pdm', 'pnpm-project', 'poetry', 'uv-project', 'yarn-project'], -scm: ['git'] -DEBUG:porringer.core.path:System PATH already in sync -DEBUG:porringer.backend.builder:RuntimeProvider 'pim' reported 1 tag(s): -['3.14-64'] -DEBUG:porringer.backend.builder:Resolved runtime 'python' via provider 'pim': -tag=3.14-64 -path=C:\Users\asher\AppData\Local\Python\pythoncore-3.14-64\python.exe -DEBUG:porringer.backend.builder:RuntimeProvider 'pyenv' is not available; -skipping -DEBUG:porringer.backend.builder:resolve_runtime_context complete: {'python': -'C:\\Users\\asher\\AppData\\Local\\Python\\pythoncore-3.14-64\\python.exe'} -DEBUG:porringer.api:discover_plugins: runtime_context={'python': -'C:\\Users\\asher\\AppData\\Local\\Python\\pythoncore-3.14-64\\python.exe'} -DEBUG:porringer.api:uninstall requested: plugin=pip package=cppython -dry_run=True -DEBUG:porringer.python_environment:python_command: using runtime override -C:\Users\asher\AppData\Local\Python\pythoncore-3.14-64\python.exe for -kind=python -DEBUG:porringer.pip.packages:listing packages via: -C:\Users\asher\AppData\Local\Python\pythoncore-3.14-64\python.exe -DEBUG:porringer.backend.command.core.resolution:packages query for pip -returned 43 entries -DEBUG:porringer.backend.command.core.resolution:is_package_installed('cppython' -): found=False matched=None -DEBUG:porringer.backend.command.core.resolution:resolved to skip: -reason=SkipReason.NOT_INSTALLED message='cppython' is not installed -=== Runtime Context === -executables: {'python': 'C:\\Users\\asher\\AppData\\Local\\Python\\pythoncore-3.14-64\\python.exe'} -=== Uninstall Result === -success=True skipped=True skip_reason=SkipReason.NOT_INSTALLED -message='cppython' is not installed From 1cb2fd26c733b1477623b33704b3643493e822b9 Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Mon, 2 Mar 2026 20:20:20 -0800 Subject: [PATCH 2/2] Auto-update --- synodic_client/application/screen/settings.py | 23 +- synodic_client/application/screen/tray.py | 199 +---------- synodic_client/application/theme.py | 15 + .../application/update_controller.py | 312 ++++++++++++++++++ synodic_client/config.py | 4 + synodic_client/resolution.py | 3 + tests/unit/qt/test_gather_packages.py | 1 + tests/unit/qt/test_settings.py | 8 +- tests/unit/qt/test_tray_window_show.py | 1 + tests/unit/qt/test_update_controller.py | 298 +++++++++++++++++ tests/unit/test_config.py | 1 + tests/unit/test_resolution.py | 1 + 12 files changed, 674 insertions(+), 192 deletions(-) create mode 100644 synodic_client/application/update_controller.py create mode 100644 tests/unit/qt/test_update_controller.py diff --git a/synodic_client/application/screen/settings.py b/synodic_client/application/screen/settings.py index a031e1e..ed2e526 100644 --- a/synodic_client/application/screen/settings.py +++ b/synodic_client/application/screen/settings.py @@ -29,7 +29,7 @@ from synodic_client.application.icon import app_icon from synodic_client.application.screen.card import CardFrame -from synodic_client.application.theme import SETTINGS_WINDOW_MIN_SIZE +from synodic_client.application.theme import SETTINGS_WINDOW_MIN_SIZE, UPDATE_STATUS_CHECKING_STYLE from synodic_client.logging import log_path from synodic_client.resolution import ResolvedConfig, update_user_config from synodic_client.startup import is_startup_registered, register_startup, remove_startup @@ -163,6 +163,11 @@ def _add_update_controls(self, content: QVBoxLayout) -> None: self._detect_updates_check.toggled.connect(self._on_detect_updates_changed) content.addWidget(self._detect_updates_check) + # Automatically apply updates + self._auto_apply_check = QCheckBox('Automatically apply updates') + self._auto_apply_check.toggled.connect(self._on_auto_apply_changed) + content.addWidget(self._auto_apply_check) + # Check for Updates row = QHBoxLayout() self._check_updates_btn = QPushButton('Check for Updates\u2026') @@ -218,16 +223,24 @@ def sync_from_config(self) -> None: # Checkboxes self._detect_updates_check.setChecked(config.detect_updates) + self._auto_apply_check.setChecked(config.auto_apply) self._auto_start_check.setChecked(is_startup_registered()) - def set_update_status(self, text: str) -> None: - """Set the inline status text next to the *Check for Updates* button.""" + def set_update_status(self, text: str, style: str = '') -> None: + """Set the inline status text next to the *Check for Updates* button. + + Args: + text: The status message. + style: Optional stylesheet for the label (e.g. color). + """ self._update_status_label.setText(text) + self._update_status_label.setStyleSheet(style) def set_checking(self) -> None: """Enter the *checking* state — disable button and show status.""" self._check_updates_btn.setEnabled(False) self._update_status_label.setText('Checking\u2026') + self._update_status_label.setStyleSheet(UPDATE_STATUS_CHECKING_STYLE) def reset_check_updates_button(self) -> None: """Re-enable the *Check for Updates* button after a check completes.""" @@ -262,6 +275,7 @@ def _block_signals(self) -> Iterator[None]: self._auto_update_spin, self._tool_update_spin, self._detect_updates_check, + self._auto_apply_check, self._auto_start_check, self._check_updates_btn, ) @@ -301,6 +315,9 @@ def _on_tool_update_interval_changed(self, value: int) -> None: def _on_detect_updates_changed(self, checked: bool) -> None: self._persist(detect_updates=checked) + def _on_auto_apply_changed(self, checked: bool) -> None: + self._persist(auto_apply=checked) + def _on_auto_start_changed(self, checked: bool) -> None: self._config = update_user_config(auto_start=checked) if checked: diff --git a/synodic_client/application/screen/tray.py b/synodic_client/application/screen/tray.py index 20d87c3..94e4de9 100644 --- a/synodic_client/application/screen/tray.py +++ b/synodic_client/application/screen/tray.py @@ -17,10 +17,9 @@ from synodic_client.application.icon import app_icon from synodic_client.application.screen.screen import MainWindow, ToolsView from synodic_client.application.screen.settings import SettingsWindow +from synodic_client.application.update_controller import UpdateController from synodic_client.application.workers import ( ToolUpdateResult, - check_for_update, - download_update, run_package_remove, run_tool_updates, ) @@ -31,7 +30,6 @@ resolve_config, resolve_update_config, ) -from synodic_client.updater import UpdateInfo logger = logging.getLogger(__name__) @@ -59,7 +57,6 @@ def __init__( self._client = client self._window = window self._config = config - self._update_task: asyncio.Task[None] | None = None self._tool_task: asyncio.Task[None] | None = None self.tray_icon = app_icon() @@ -74,14 +71,19 @@ def __init__( # Settings window (created once, shown/hidden on demand) self._settings_window = SettingsWindow(self._resolve_config()) self._settings_window.settings_changed.connect(self._on_settings_changed) - self._settings_window.check_updates_requested.connect(self._on_check_updates) # MainWindow gear button → open settings window.settings_requested.connect(self._show_settings) - # Periodic auto-update checking - self._auto_update_timer: QTimer | None = None - self._restart_auto_update_timer() + # Update controller — owns the self-update lifecycle & timer + self._banner = window.update_banner + self._update_controller = UpdateController( + app, + client, + self._banner, + self._settings_window, + config, + ) # Periodic tool update checking self._tool_update_timer: QTimer | None = None @@ -90,11 +92,6 @@ def __init__( # Connect ToolsView signals — deferred because ToolsView is created lazily window.tools_view_created.connect(self._connect_tools_view) - # Connect update banner signals - self._banner = window.update_banner - self._banner.restart_requested.connect(self._apply_update) - self._banner.retry_requested.connect(lambda: self._do_check_updates(silent=True)) - def _build_menu(self, app: QApplication, window: MainWindow) -> None: """Build the tray context menu.""" self.menu = QMenu() @@ -105,12 +102,6 @@ def _build_menu(self, app: QApplication, window: MainWindow) -> None: self.menu.addSeparator() - self.update_action = QAction('Check for Updates...', self.menu) - self.update_action.triggered.connect(self._on_check_updates) - self.menu.addAction(self.update_action) - - self.menu.addSeparator() - self.settings_action = QAction('Settings\u2026', self.menu) self.settings_action.triggered.connect(self._show_settings) self.menu.addAction(self.settings_action) @@ -172,16 +163,6 @@ def _restart_timer( logger.info('%s enabled (every %d minute(s))', label, interval_minutes) return timer - def _restart_auto_update_timer(self) -> None: - """Start (or restart) the periodic auto-update timer from config.""" - config = resolve_update_config(self._resolve_config()) - self._auto_update_timer = self._restart_timer( - self._auto_update_timer, - config.auto_update_interval_minutes, - self._on_auto_check_updates, - 'Automatic update checking', - ) - def _restart_tool_update_timer(self) -> None: """Start (or restart) the periodic tool update timer from config.""" config = resolve_update_config(self._resolve_config()) @@ -206,116 +187,10 @@ def _show_settings(self) -> None: def _on_settings_changed(self, config: ResolvedConfig) -> None: """React to a change made in the settings window.""" self._config = config - self._reinitialize_updater(config) - - def _reinitialize_updater(self, config: ResolvedConfig) -> None: - """Re-derive update settings and restart the updater and timers. - - The new ``Updater`` starts with the ``importlib.metadata`` - version which may be stale after a Velopack update. The - authoritative Velopack version is recovered automatically on - the first ``_get_velopack_manager()`` call (i.e. the next - update check), so no special handling is required here. - """ - update_cfg = resolve_update_config(config) - self._client.initialize_updater(update_cfg) - self._restart_auto_update_timer() + # Delegate updater reinit + immediate check to the controller + self._update_controller.on_settings_changed(config) + # Restart tool-update timer with new config self._restart_tool_update_timer() - logger.info('Updater re-initialized (channel: %s, source: %s)', update_cfg.channel.name, update_cfg.repo_url) - - def _reset_update_action(self) -> None: - """Restore the 'Check for Updates' action to its idle state.""" - self.update_action.setEnabled(True) - self.update_action.setText('Check for Updates...') - - def _on_check_updates(self) -> None: - """Handle manual check for updates action.""" - self._do_check_updates(silent=False) - - def _on_auto_check_updates(self) -> None: - """Handle automatic (periodic) check for updates. - - Failures and no-update results are logged silently without - showing the in-app error banner. - """ - self._do_check_updates(silent=True) - - def _do_check_updates(self, *, silent: bool) -> None: - """Run an update check. - - Args: - silent: When ``True``, suppress the in-app error banner - for failures and no-update results. The banner is - always shown when an update *is* available. - """ - if self._client.updater is None: - if not silent: - self._banner.show_error('Updater is not initialized.') - return - - # Disable both the tray action and the settings button while checking - self.update_action.setEnabled(False) - self.update_action.setText('Checking for Updates...') - self._settings_window.set_checking() - - self._update_task = asyncio.create_task(self._async_check_updates(silent=silent)) - - async def _async_check_updates(self, *, silent: bool) -> None: - """Run the update check coroutine and route results.""" - try: - result = await check_for_update(self._client) - self._on_update_check_finished(result, silent=silent) - except Exception as exc: - logger.exception('Update check failed') - self._on_update_check_error(str(exc), silent=silent) - - def _on_update_check_finished(self, result: UpdateInfo | None, *, silent: bool = False) -> None: - """Handle update check completion.""" - self._reset_update_action() - self._settings_window.reset_check_updates_button() - - if result is None: - self._settings_window.set_update_status('Check failed') - if not silent: - self._banner.show_error('Failed to check for updates.') - else: - logger.warning('Automatic update check failed (no result)') - return - - if result.error: - self._settings_window.set_update_status(result.error) - if not silent: - self._banner.show_error(result.error) - else: - logger.warning('Automatic update check failed: %s', result.error) - return - - if not result.available: - self._settings_window.set_update_status( - f'Up to date ({result.current_version})', - ) - if not silent: - logger.info('No updates available (current: %s)', result.current_version) - else: - logger.debug('Automatic update check: no update available') - return - - # Update available — show banner and start download automatically - version = str(result.latest_version) - self._settings_window.set_update_status(f'Update available: {version}') - self._banner.show_downloading(version) - self._start_download(version) - - def _on_update_check_error(self, error: str, *, silent: bool = False) -> None: - """Handle update check error.""" - self._reset_update_action() - self._settings_window.reset_check_updates_button() - self._settings_window.set_update_status(f'Error: {error}') - - if not silent: - self._banner.show_error(f'Update check error: {error}') - else: - logger.warning('Automatic update check error: %s', error) # -- Tool update helpers -- @@ -571,51 +446,3 @@ def _on_package_remove_finished( tools_view.refresh() self._window.show() - - # -- Self-update download & apply -- - - def _start_download(self, version: str) -> None: - """Start downloading the update in the background. - - Args: - version: The version string being downloaded (for banner display). - """ - self._update_task = asyncio.create_task(self._async_download(version)) - - async def _async_download(self, version: str) -> None: - """Run the download coroutine and route results.""" - try: - success = await download_update( - self._client, - on_progress=self._banner.show_downloading_progress, - ) - self._on_download_finished(success, version) - except Exception as exc: - logger.exception('Update download failed') - self._on_download_error(str(exc)) - - def _on_download_finished(self, success: bool, version: str) -> None: - """Handle download completion — transition banner to ready state.""" - if not success: - self._banner.show_error('Download failed. Please try again later.') - return - - self._banner.show_ready(version) - self._settings_window.set_update_status(f'Ready to install: {version}') - - def _on_download_error(self, error: str) -> None: - """Handle download error — show error banner.""" - self._banner.show_error(f'Download error: {error}') - - def _apply_update(self) -> None: - """Apply the downloaded update and restart.""" - if self._client.updater is None: - return - - try: - self._client.apply_update_on_exit(restart=True) - logger.info('Update scheduled — restarting application') - self._app.quit() - except Exception as e: - logger.error('Failed to apply update: %s', e) - self._banner.show_error(f'Failed to apply update: {e}') diff --git a/synodic_client/application/theme.py b/synodic_client/application/theme.py index db91cde..1adcd8d 100644 --- a/synodic_client/application/theme.py +++ b/synodic_client/application/theme.py @@ -436,6 +436,21 @@ ) """Gear button style for the MainWindow tab corner widget.""" +# --------------------------------------------------------------------------- +# Settings inline update-status colours +# --------------------------------------------------------------------------- +UPDATE_STATUS_UP_TO_DATE_STYLE = 'color: #89d185; font-size: 12px;' +"""Green text for 'Up to date' / 'Ready' status.""" + +UPDATE_STATUS_AVAILABLE_STYLE = 'color: #cca700; font-size: 12px;' +"""Orange text for 'Update available' status.""" + +UPDATE_STATUS_ERROR_STYLE = 'color: #f48771; font-size: 12px;' +"""Red text for error / check-failed status.""" + +UPDATE_STATUS_CHECKING_STYLE = 'color: #808080; font-size: 12px; font-style: italic;' +"""Grey italic text for 'Checking…' status.""" + # --------------------------------------------------------------------------- # Update banner (in-app self-update notification) # --------------------------------------------------------------------------- diff --git a/synodic_client/application/update_controller.py b/synodic_client/application/update_controller.py new file mode 100644 index 0000000..1971415 --- /dev/null +++ b/synodic_client/application/update_controller.py @@ -0,0 +1,312 @@ +"""Self-update orchestration controller. + +Owns the full update lifecycle — check → download → apply — and the +periodic auto-update timer. Extracted from :class:`TrayScreen` so +that tray, settings, and banner concerns are cleanly separated from +the update state-machine. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING + +from PySide6.QtCore import QTimer +from PySide6.QtWidgets import QApplication + +from synodic_client.application.screen.update_banner import UpdateBanner +from synodic_client.application.theme import ( + UPDATE_STATUS_AVAILABLE_STYLE, + UPDATE_STATUS_ERROR_STYLE, + UPDATE_STATUS_UP_TO_DATE_STYLE, +) +from synodic_client.application.workers import check_for_update, download_update +from synodic_client.resolution import ( + ResolvedConfig, + resolve_config, + resolve_update_config, +) +from synodic_client.updater import UpdateInfo + +if TYPE_CHECKING: + from synodic_client.application.screen.settings import SettingsWindow + from synodic_client.client import Client + +logger = logging.getLogger(__name__) + + +class UpdateController: + """Manages the self-update lifecycle: check → download → apply. + + Parameters + ---------- + app: + The running ``QApplication`` (needed for ``quit()`` on auto-apply). + client: + The Synodic Client service facade. + banner: + The in-app ``UpdateBanner`` widget. + settings_window: + The ``SettingsWindow`` (receives status text + colour). + config: + Optional pre-resolved configuration. ``None`` resolves from disk. + """ + + def __init__( + self, + app: QApplication, + client: Client, + banner: UpdateBanner, + settings_window: SettingsWindow, + config: ResolvedConfig | None = None, + ) -> None: + """Initialise the controller and start the periodic timer. + + Args: + app: The running ``QApplication``. + client: The Synodic Client service facade. + banner: The in-app ``UpdateBanner`` widget. + settings_window: The settings window for status feedback. + config: Optional pre-resolved configuration. + """ + self._app = app + self._client = client + self._banner = banner + self._settings_window = settings_window + self._config = config + self._update_task: asyncio.Task[None] | None = None + + # Derive auto-apply preference from config + resolved = self._resolve_config() + self._auto_apply: bool = resolved.auto_apply + + # Periodic auto-update timer + self._auto_update_timer: QTimer | None = None + self._restart_auto_update_timer() + + # Wire banner signals + self._banner.restart_requested.connect(self._apply_update) + self._banner.retry_requested.connect(lambda: self.check_now(silent=True)) + + # Wire settings check-updates button + self._settings_window.check_updates_requested.connect(self._on_manual_check) + + # ------------------------------------------------------------------ + # Config helpers + # ------------------------------------------------------------------ + + def _resolve_config(self) -> ResolvedConfig: + """Return the injected config or resolve from disk.""" + if self._config is not None: + return self._config + return resolve_config() + + # ------------------------------------------------------------------ + # Timer management + # ------------------------------------------------------------------ + + def _restart_auto_update_timer(self) -> None: + """Start (or restart) the periodic auto-update timer from config.""" + config = resolve_update_config(self._resolve_config()) + + if self._auto_update_timer is not None: + self._auto_update_timer.stop() + + interval = config.auto_update_interval_minutes + if interval <= 0: + logger.info('Automatic update checking is disabled') + self._auto_update_timer = None + return + + timer = QTimer() + timer.setInterval(interval * 60 * 1000) + timer.timeout.connect(self._on_auto_check) + timer.start() + logger.info('Automatic update checking enabled (every %d minute(s))', interval) + self._auto_update_timer = timer + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def check_now(self, *, silent: bool = False) -> None: + """Trigger an update check. + + Args: + silent: When ``True``, suppress the in-app error banner + for failures and no-update results. + """ + self._do_check(silent=silent) + + def on_settings_changed(self, config: ResolvedConfig) -> None: + """React to a settings change — reinitialise the updater and timers. + + Also triggers an immediate (silent) check so the user gets + feedback after switching channels. + """ + self._config = config + self._auto_apply = config.auto_apply + self._reinitialize_updater(config) + self.check_now(silent=True) + + # ------------------------------------------------------------------ + # Updater re-initialisation + # ------------------------------------------------------------------ + + def _reinitialize_updater(self, config: ResolvedConfig) -> None: + """Re-derive update settings and restart the updater and timer.""" + update_cfg = resolve_update_config(config) + self._client.initialize_updater(update_cfg) + self._restart_auto_update_timer() + logger.info( + 'Updater re-initialized (channel: %s, source: %s)', + update_cfg.channel.name, + update_cfg.repo_url, + ) + + # ------------------------------------------------------------------ + # Check flow + # ------------------------------------------------------------------ + + def _on_manual_check(self) -> None: + """Handle manual check-for-updates (from settings button).""" + self._do_check(silent=False) + + def _on_auto_check(self) -> None: + """Handle automatic (periodic) check — silent.""" + self._do_check(silent=True) + + def _do_check(self, *, silent: bool) -> None: + """Run an update check.""" + if self._client.updater is None: + if not silent: + self._banner.show_error('Updater is not initialized.') + return + + # Show checking state in settings + self._settings_window.set_checking() + + self._update_task = asyncio.create_task(self._async_check(silent=silent)) + + async def _async_check(self, *, silent: bool) -> None: + """Run the update check coroutine and route results.""" + try: + result = await check_for_update(self._client) + self._on_check_finished(result, silent=silent) + except Exception as exc: + logger.exception('Update check failed') + self._on_check_error(str(exc), silent=silent) + + def _on_check_finished(self, result: UpdateInfo | None, *, silent: bool = False) -> None: + """Route the update-check result.""" + self._settings_window.reset_check_updates_button() + + if result is None: + self._settings_window.set_update_status('Check failed', UPDATE_STATUS_ERROR_STYLE) + if not silent: + self._banner.show_error('Failed to check for updates.') + else: + logger.warning('Automatic update check failed (no result)') + return + + if result.error: + self._settings_window.set_update_status('Check failed', UPDATE_STATUS_ERROR_STYLE) + if not silent: + self._banner.show_error(result.error) + else: + logger.warning('Automatic update check failed: %s', result.error) + return + + if not result.available: + self._settings_window.set_update_status('Up to date', UPDATE_STATUS_UP_TO_DATE_STYLE) + if not silent: + logger.info('No updates available (current: %s)', result.current_version) + else: + logger.debug('Automatic update check: no update available') + return + + # Update available — show status and start download + version = str(result.latest_version) + self._settings_window.set_update_status( + f'v{version} available', + UPDATE_STATUS_AVAILABLE_STYLE, + ) + self._banner.show_downloading(version) + self._start_download(version) + + def _on_check_error(self, error: str, *, silent: bool = False) -> None: + """Handle unexpected exception during update check.""" + self._settings_window.reset_check_updates_button() + self._settings_window.set_update_status('Check failed', UPDATE_STATUS_ERROR_STYLE) + + if not silent: + self._banner.show_error(f'Update check error: {error}') + else: + logger.warning('Automatic update check error: %s', error) + + # ------------------------------------------------------------------ + # Download flow + # ------------------------------------------------------------------ + + def _start_download(self, version: str) -> None: + """Start downloading the update in the background.""" + self._update_task = asyncio.create_task(self._async_download(version)) + + async def _async_download(self, version: str) -> None: + """Run the download coroutine and route results.""" + try: + success = await download_update( + self._client, + on_progress=self._banner.show_downloading_progress, + ) + self._on_download_finished(success, version) + except Exception as exc: + logger.exception('Update download failed') + self._on_download_error(str(exc)) + + def _on_download_finished(self, success: bool, version: str) -> None: + """Handle download completion.""" + if not success: + self._banner.show_error('Download failed. Please try again later.') + self._settings_window.set_update_status('Download failed', UPDATE_STATUS_ERROR_STYLE) + return + + if self._auto_apply: + # Silently apply and restart — no banner, no user interaction + logger.info('Auto-applying update v%s', version) + self._settings_window.set_update_status( + f'v{version} installing\u2026', + UPDATE_STATUS_AVAILABLE_STYLE, + ) + self._apply_update() + return + + # Manual mode — show ready banner and let user choose when to restart + self._banner.show_ready(version) + self._settings_window.set_update_status( + f'v{version} ready', + UPDATE_STATUS_UP_TO_DATE_STYLE, + ) + + def _on_download_error(self, error: str) -> None: + """Handle download error — show error banner.""" + self._banner.show_error(f'Download error: {error}') + self._settings_window.set_update_status('Download failed', UPDATE_STATUS_ERROR_STYLE) + + # ------------------------------------------------------------------ + # Apply + # ------------------------------------------------------------------ + + def _apply_update(self) -> None: + """Apply the downloaded update and restart.""" + if self._client.updater is None: + return + + try: + self._client.apply_update_on_exit(restart=True) + logger.info('Update scheduled — restarting application') + self._app.quit() + except Exception as e: + logger.error('Failed to apply update: %s', e) + self._banner.show_error(f'Failed to apply update: {e}') diff --git a/synodic_client/config.py b/synodic_client/config.py index 47ffa27..af0f9ec 100644 --- a/synodic_client/config.py +++ b/synodic_client/config.py @@ -130,6 +130,10 @@ class UserConfig(BaseModel): # no overrides anywhere. prerelease_packages: dict[str, list[str]] | None = None + # Whether downloaded updates should be applied and restarted + # automatically without user interaction. None resolves to True. + auto_apply: bool | None = None + # Whether the application should start automatically with the OS. # None means use the default (enabled). Explicitly False disables # auto-startup. diff --git a/synodic_client/resolution.py b/synodic_client/resolution.py index de1c8bf..b716f89 100644 --- a/synodic_client/resolution.py +++ b/synodic_client/resolution.py @@ -61,6 +61,7 @@ class ResolvedConfig: plugin_auto_update: dict[str, bool | dict[str, bool]] | None detect_updates: bool prerelease_packages: dict[str, list[str]] | None + auto_apply: bool auto_start: bool @@ -140,6 +141,7 @@ def _resolve_from_user(user: UserConfig) -> ResolvedConfig: if tool_interval is None: tool_interval = DEFAULT_TOOL_UPDATE_INTERVAL_MINUTES + auto_apply = user.auto_apply if user.auto_apply is not None else True auto_start = user.auto_start if user.auto_start is not None else True return ResolvedConfig( @@ -150,6 +152,7 @@ def _resolve_from_user(user: UserConfig) -> ResolvedConfig: plugin_auto_update=user.plugin_auto_update, detect_updates=user.detect_updates, prerelease_packages=user.prerelease_packages, + auto_apply=auto_apply, auto_start=auto_start, ) diff --git a/tests/unit/qt/test_gather_packages.py b/tests/unit/qt/test_gather_packages.py index 1d18f58..c935f94 100644 --- a/tests/unit/qt/test_gather_packages.py +++ b/tests/unit/qt/test_gather_packages.py @@ -41,6 +41,7 @@ def _make_config() -> ResolvedConfig: plugin_auto_update=None, detect_updates=False, prerelease_packages=None, + auto_apply=True, auto_start=False, ) diff --git a/tests/unit/qt/test_settings.py b/tests/unit/qt/test_settings.py index a6b9648..d3a63f4 100644 --- a/tests/unit/qt/test_settings.py +++ b/tests/unit/qt/test_settings.py @@ -25,6 +25,7 @@ def _make_config(**overrides: Any) -> ResolvedConfig: 'plugin_auto_update': None, 'detect_updates': True, 'prerelease_packages': None, + 'auto_apply': True, 'auto_start': True, } defaults.update(overrides) @@ -350,10 +351,11 @@ def test_click_emits_signal_and_disables() -> None: @staticmethod def test_set_update_status() -> None: - """set_update_status sets the label text.""" + """set_update_status sets the label text and style.""" window = _make_window() - window.set_update_status('Up to date (v1.0.0)') - assert window._update_status_label.text() == 'Up to date (v1.0.0)' + window.set_update_status('Up to date', 'color: green;') + assert window._update_status_label.text() == 'Up to date' + assert 'green' in window._update_status_label.styleSheet() @staticmethod def test_reset_check_updates_button() -> None: diff --git a/tests/unit/qt/test_tray_window_show.py b/tests/unit/qt/test_tray_window_show.py index ab8d07e..433ac64 100644 --- a/tests/unit/qt/test_tray_window_show.py +++ b/tests/unit/qt/test_tray_window_show.py @@ -16,6 +16,7 @@ def tray_screen(): with ( patch('synodic_client.application.screen.tray.resolve_config'), patch('synodic_client.application.screen.tray.resolve_update_config') as mock_ucfg, + patch('synodic_client.application.screen.tray.UpdateController'), ): # Disable timers by setting intervals to 0 mock_ucfg.return_value = MagicMock( diff --git a/tests/unit/qt/test_update_controller.py b/tests/unit/qt/test_update_controller.py new file mode 100644 index 0000000..f480e81 --- /dev/null +++ b/tests/unit/qt/test_update_controller.py @@ -0,0 +1,298 @@ +"""Tests for the UpdateController self-update orchestrator.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock, patch + +from packaging.version import Version + +from synodic_client.application.screen.update_banner import UpdateBanner +from synodic_client.application.theme import ( + UPDATE_STATUS_AVAILABLE_STYLE, + UPDATE_STATUS_ERROR_STYLE, + UPDATE_STATUS_UP_TO_DATE_STYLE, +) +from synodic_client.application.update_controller import UpdateController +from synodic_client.resolution import ResolvedConfig +from synodic_client.updater import ( + DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES, + DEFAULT_TOOL_UPDATE_INTERVAL_MINUTES, + UpdateInfo, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_config(**overrides: Any) -> ResolvedConfig: + """Create a ``ResolvedConfig`` with sensible defaults and optional overrides.""" + defaults: dict[str, Any] = { + 'update_source': None, + 'update_channel': 'stable', + 'auto_update_interval_minutes': DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES, + 'tool_update_interval_minutes': DEFAULT_TOOL_UPDATE_INTERVAL_MINUTES, + 'plugin_auto_update': None, + 'detect_updates': True, + 'prerelease_packages': None, + 'auto_apply': True, + 'auto_start': True, + } + defaults.update(overrides) + return ResolvedConfig(**defaults) + + +def _make_controller( + *, + auto_apply: bool = True, + auto_update_interval_minutes: int = 0, +) -> tuple[UpdateController, MagicMock, MagicMock, UpdateBanner, MagicMock]: + """Build an ``UpdateController`` with mocked collaborators. + + Returns (controller, app_mock, client_mock, banner, settings_mock). + """ + config = _make_config( + auto_apply=auto_apply, + auto_update_interval_minutes=auto_update_interval_minutes, + ) + + app = MagicMock() + client = MagicMock() + client.updater = MagicMock() + banner = UpdateBanner() + settings = MagicMock() + + with patch('synodic_client.application.update_controller.resolve_update_config') as mock_ucfg: + mock_ucfg.return_value = MagicMock( + auto_update_interval_minutes=auto_update_interval_minutes, + ) + controller = UpdateController(app, client, banner, settings, config) + + return controller, app, client, banner, settings + + +# --------------------------------------------------------------------------- +# Check result routing +# --------------------------------------------------------------------------- + + +class TestCheckFinished: + """Verify _on_check_finished routes results correctly.""" + + @staticmethod + def test_none_result_sets_error_status() -> None: + """A None result should set 'Check failed' in red.""" + ctrl, _app, _client, banner, settings = _make_controller() + ctrl._on_check_finished(None, silent=False) + + settings.reset_check_updates_button.assert_called_once() + settings.set_update_status.assert_called_once_with('Check failed', UPDATE_STATUS_ERROR_STYLE) + + @staticmethod + def test_none_result_shows_banner_when_not_silent() -> None: + """A None result with silent=False should show the error banner.""" + ctrl, _app, _client, banner, settings = _make_controller() + ctrl._on_check_finished(None, silent=False) + + assert banner.state.name == 'ERROR' + + @staticmethod + def test_none_result_no_banner_when_silent() -> None: + """A None result with silent=True should NOT show the error banner.""" + ctrl, _app, _client, banner, settings = _make_controller() + ctrl._on_check_finished(None, silent=True) + + assert banner.state.name == 'HIDDEN' + + @staticmethod + def test_error_result_sets_error_status() -> None: + """An error result should set 'Check failed' status.""" + ctrl, _app, _client, banner, settings = _make_controller() + result = UpdateInfo(available=False, current_version=Version('1.0.0'), error='No releases found') + ctrl._on_check_finished(result, silent=False) + + settings.set_update_status.assert_called_once_with('Check failed', UPDATE_STATUS_ERROR_STYLE) + + @staticmethod + def test_no_update_sets_up_to_date() -> None: + """No update available should set 'Up to date' in green.""" + ctrl, _app, _client, banner, settings = _make_controller() + result = UpdateInfo(available=False, current_version=Version('1.0.0')) + ctrl._on_check_finished(result, silent=False) + + settings.set_update_status.assert_called_once_with('Up to date', UPDATE_STATUS_UP_TO_DATE_STYLE) + + @staticmethod + def test_update_available_sets_status_and_starts_download() -> None: + """Available update should set orange status and start download.""" + ctrl, _app, _client, banner, settings = _make_controller() + result = UpdateInfo(available=True, current_version=Version('1.0.0'), latest_version=Version('2.0.0')) + + with patch.object(ctrl, '_start_download') as mock_dl: + ctrl._on_check_finished(result, silent=False) + + settings.set_update_status.assert_called_once_with( + 'v2.0.0 available', + UPDATE_STATUS_AVAILABLE_STYLE, + ) + mock_dl.assert_called_once_with('2.0.0') + + +# --------------------------------------------------------------------------- +# Download completion — auto-apply vs manual +# --------------------------------------------------------------------------- + + +class TestDownloadFinished: + """Verify _on_download_finished behaviour with auto-apply on/off.""" + + @staticmethod + def test_auto_apply_calls_apply_update() -> None: + """When auto_apply=True, a successful download should call _apply_update.""" + ctrl, app, client, banner, settings = _make_controller(auto_apply=True) + + with patch.object(ctrl, '_apply_update') as mock_apply: + ctrl._on_download_finished(True, '2.0.0') + + mock_apply.assert_called_once() + + @staticmethod + def test_auto_apply_does_not_show_ready_banner() -> None: + """When auto_apply=True, the ready banner should NOT be shown.""" + ctrl, app, client, banner, settings = _make_controller(auto_apply=True) + + with patch.object(ctrl, '_apply_update'): + ctrl._on_download_finished(True, '2.0.0') + + # Banner should not be in READY state + assert banner.state.name != 'READY' + + @staticmethod + def test_no_auto_apply_shows_ready_banner() -> None: + """When auto_apply=False, a successful download should show the ready banner.""" + ctrl, app, client, banner, settings = _make_controller(auto_apply=False) + ctrl._on_download_finished(True, '2.0.0') + + assert banner.state.name == 'READY' + + @staticmethod + def test_no_auto_apply_sets_ready_status() -> None: + """When auto_apply=False, status should show 'v2.0.0 ready' in green.""" + ctrl, app, client, banner, settings = _make_controller(auto_apply=False) + ctrl._on_download_finished(True, '2.0.0') + + settings.set_update_status.assert_called_with( + 'v2.0.0 ready', + UPDATE_STATUS_UP_TO_DATE_STYLE, + ) + + @staticmethod + def test_download_failure_shows_error() -> None: + """A failed download should show an error banner.""" + ctrl, app, client, banner, settings = _make_controller() + ctrl._on_download_finished(False, '2.0.0') + + assert banner.state.name == 'ERROR' + settings.set_update_status.assert_called_with('Download failed', UPDATE_STATUS_ERROR_STYLE) + + +# --------------------------------------------------------------------------- +# Apply update +# --------------------------------------------------------------------------- + + +class TestApplyUpdate: + """Verify _apply_update delegates to client and quits.""" + + @staticmethod + def test_apply_update_calls_client_and_quits() -> None: + """_apply_update should call client.apply_update_on_exit and app.quit.""" + ctrl, app, client, banner, settings = _make_controller() + ctrl._apply_update() + + client.apply_update_on_exit.assert_called_once_with(restart=True) + app.quit.assert_called_once() + + @staticmethod + def test_apply_update_noop_without_updater() -> None: + """_apply_update should be a no-op when client.updater is None.""" + ctrl, app, client, banner, settings = _make_controller() + client.updater = None + ctrl._apply_update() + + client.apply_update_on_exit.assert_not_called() + app.quit.assert_not_called() + + +# --------------------------------------------------------------------------- +# Settings changed → immediate check +# --------------------------------------------------------------------------- + + +class TestSettingsChanged: + """Verify on_settings_changed triggers reinit and immediate check.""" + + @staticmethod + def test_settings_changed_triggers_reinit_and_check() -> None: + """Changing settings should reinitialise the updater and check.""" + ctrl, app, client, banner, settings = _make_controller() + + new_config = _make_config(update_channel='dev') + + with ( + patch.object(ctrl, '_reinitialize_updater') as mock_reinit, + patch.object(ctrl, 'check_now') as mock_check, + ): + ctrl.on_settings_changed(new_config) + + mock_reinit.assert_called_once_with(new_config) + mock_check.assert_called_once_with(silent=True) + + @staticmethod + def test_settings_changed_updates_auto_apply() -> None: + """Changing settings should update the auto_apply flag.""" + ctrl, app, client, banner, settings = _make_controller(auto_apply=True) + + new_config = _make_config(auto_apply=False) + + with ( + patch.object(ctrl, '_reinitialize_updater'), + patch.object(ctrl, 'check_now'), + ): + ctrl.on_settings_changed(new_config) + + assert ctrl._auto_apply is False + + +# --------------------------------------------------------------------------- +# Check error +# --------------------------------------------------------------------------- + + +class TestCheckError: + """Verify _on_check_error routes errors correctly.""" + + @staticmethod + def test_check_error_sets_failed_status() -> None: + """An exception during check should set 'Check failed' status.""" + ctrl, app, client, banner, settings = _make_controller() + ctrl._on_check_error('connection refused', silent=False) + + settings.set_update_status.assert_called_with('Check failed', UPDATE_STATUS_ERROR_STYLE) + + @staticmethod + def test_check_error_shows_banner_when_not_silent() -> None: + """An exception during check should show banner when not silent.""" + ctrl, app, client, banner, settings = _make_controller() + ctrl._on_check_error('timeout', silent=False) + + assert banner.state.name == 'ERROR' + + @staticmethod + def test_check_error_no_banner_when_silent() -> None: + """An exception during check should NOT show banner when silent.""" + ctrl, app, client, banner, settings = _make_controller() + ctrl._on_check_error('timeout', silent=True) + + assert banner.state.name == 'HIDDEN' diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index b96e28f..976ccbb 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -47,6 +47,7 @@ def test_defaults() -> None: assert config.plugin_auto_update is None assert config.detect_updates is True assert config.prerelease_packages is None + assert config.auto_apply is None assert config.auto_start is None @staticmethod diff --git a/tests/unit/test_resolution.py b/tests/unit/test_resolution.py index 89782b4..c954171 100644 --- a/tests/unit/test_resolution.py +++ b/tests/unit/test_resolution.py @@ -39,6 +39,7 @@ def _make_resolved(**overrides: Any) -> ResolvedConfig: 'plugin_auto_update': None, 'detect_updates': True, 'prerelease_packages': None, + 'auto_apply': True, 'auto_start': True, } defaults.update(overrides)