synodic-client 0.0.1.dev57__tar.gz → 0.0.1.dev62__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/PKG-INFO +1 -1
  2. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/pyproject.toml +1 -1
  3. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/synodic_client/application/init.py +10 -6
  4. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/synodic_client/application/screen/screen.py +4 -9
  5. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/synodic_client/application/screen/settings.py +26 -6
  6. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/synodic_client/application/screen/spinner.py +1 -0
  7. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/synodic_client/application/screen/tool_update_controller.py +6 -1
  8. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/synodic_client/application/update_controller.py +5 -2
  9. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/synodic_client/logging.py +7 -6
  10. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/tests/unit/qt/test_gather_packages.py +4 -10
  11. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/tests/unit/qt/test_logging.py +4 -5
  12. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/tests/unit/qt/test_settings.py +68 -4
  13. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/tests/unit/qt/test_update_controller.py +54 -0
  14. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/tests/unit/test_init.py +24 -2
  15. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/LICENSE.md +0 -0
  16. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/README.md +0 -0
  17. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/synodic_client/__init__.py +0 -0
  18. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/synodic_client/__main__.py +0 -0
  19. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/synodic_client/application/__init__.py +0 -0
  20. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/synodic_client/application/bootstrap.py +0 -0
  21. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/synodic_client/application/data.py +0 -0
  22. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/synodic_client/application/icon.py +0 -0
  23. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/synodic_client/application/instance.py +0 -0
  24. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/synodic_client/application/qt.py +0 -0
  25. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/synodic_client/application/schema.py +0 -0
  26. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/synodic_client/application/screen/__init__.py +0 -0
  27. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/synodic_client/application/screen/action_card.py +0 -0
  28. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/synodic_client/application/screen/card.py +0 -0
  29. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/synodic_client/application/screen/install.py +0 -0
  30. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/synodic_client/application/screen/install_workers.py +0 -0
  31. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/synodic_client/application/screen/log_panel.py +0 -0
  32. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/synodic_client/application/screen/plugin_row.py +0 -0
  33. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/synodic_client/application/screen/projects.py +0 -0
  34. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/synodic_client/application/screen/schema.py +0 -0
  35. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/synodic_client/application/screen/sidebar.py +0 -0
  36. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/synodic_client/application/screen/tray.py +0 -0
  37. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/synodic_client/application/screen/update_banner.py +0 -0
  38. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/synodic_client/application/theme.py +0 -0
  39. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/synodic_client/application/uri.py +0 -0
  40. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/synodic_client/application/workers.py +0 -0
  41. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/synodic_client/cli.py +0 -0
  42. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/synodic_client/client.py +0 -0
  43. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/synodic_client/config.py +0 -0
  44. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/synodic_client/protocol.py +0 -0
  45. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/synodic_client/py.typed +0 -0
  46. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/synodic_client/resolution.py +0 -0
  47. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/synodic_client/schema.py +0 -0
  48. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/synodic_client/startup.py +0 -0
  49. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/synodic_client/updater.py +0 -0
  50. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/tests/__init__.py +0 -0
  51. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/tests/conftest.py +0 -0
  52. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/tests/unit/__init__.py +0 -0
  53. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/tests/unit/qt/__init__.py +0 -0
  54. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/tests/unit/qt/conftest.py +0 -0
  55. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/tests/unit/qt/test_action_card.py +0 -0
  56. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/tests/unit/qt/test_install_preview.py +0 -0
  57. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/tests/unit/qt/test_log_panel.py +0 -0
  58. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/tests/unit/qt/test_preview_model.py +0 -0
  59. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/tests/unit/qt/test_sidebar.py +0 -0
  60. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/tests/unit/qt/test_tray_window_show.py +0 -0
  61. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/tests/unit/qt/test_update_banner.py +0 -0
  62. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/tests/unit/qt/test_update_feedback.py +0 -0
  63. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/tests/unit/test_cli.py +0 -0
  64. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/tests/unit/test_client_updater.py +0 -0
  65. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/tests/unit/test_client_version.py +0 -0
  66. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/tests/unit/test_config.py +0 -0
  67. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/tests/unit/test_examples.py +0 -0
  68. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/tests/unit/test_install.py +0 -0
  69. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/tests/unit/test_resolution.py +0 -0
  70. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/tests/unit/test_updater.py +0 -0
  71. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/tests/unit/test_uri.py +0 -0
  72. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/tests/unit/test_workers.py +0 -0
  73. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/tests/unit/windows/__init__.py +0 -0
  74. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/tests/unit/windows/conftest.py +0 -0
  75. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/tests/unit/windows/test_protocol.py +0 -0
  76. {synodic_client-0.0.1.dev57 → synodic_client-0.0.1.dev62}/tests/unit/windows/test_startup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: synodic_client
3
- Version: 0.0.1.dev57
3
+ Version: 0.0.1.dev62
4
4
  Author-Email: Synodic Software <contact@synodic.software>
5
5
  License: LGPL-3.0-or-later
6
6
  Project-URL: homepage, https://github.com/synodic/synodic-client
@@ -15,7 +15,7 @@ dependencies = [
15
15
  "velopack>=0.0.1444.dev49733",
16
16
  "typer>=0.24.1",
17
17
  ]
18
- version = "0.0.1.dev57"
18
+ version = "0.0.1.dev62"
19
19
 
20
20
  [project.license]
21
21
  text = "LGPL-3.0-or-later"
@@ -53,12 +53,16 @@ def run_startup_preamble(exe_path: str | None = None) -> None:
53
53
  # Seed user config from the build config (one-time propagation).
54
54
  seed_user_config_from_build()
55
55
 
56
- register_protocol(exe_path)
56
+ frozen = getattr(sys, 'frozen', False)
57
+
58
+ if frozen:
59
+ register_protocol(exe_path)
57
60
 
58
61
  config = resolve_config()
59
- if config.auto_start:
60
- register_startup(exe_path)
61
- else:
62
- remove_startup()
62
+ if frozen:
63
+ if config.auto_start:
64
+ register_startup(exe_path)
65
+ else:
66
+ remove_startup()
63
67
 
64
- logger.info('Startup preamble complete (auto_start=%s)', config.auto_start)
68
+ logger.info('Startup preamble complete (auto_start=%s, frozen=%s)', config.auto_start, frozen)
@@ -315,11 +315,10 @@ class ToolsView(QWidget):
315
315
  pkg_tasks: dict[str, asyncio.Task] = {}
316
316
  for plugin in updatable_plugins:
317
317
  if plugin.name in runtime_probed:
318
- # Only gather venv/project packages (skip global)
319
- if directories:
320
- pkg_tasks[plugin.name] = tg.create_task(
321
- self._gather_packages(plugin.name, directories, skip_global=True),
322
- )
318
+ # Runtime-probed plugins show only per-runtime
319
+ # packages; venv/project packages belong in
320
+ # ProjectsView and are intentionally skipped here.
321
+ continue
323
322
  else:
324
323
  pkg_tasks[plugin.name] = tg.create_task(
325
324
  self._gather_packages(
@@ -390,10 +389,6 @@ class ToolsView(QWidget):
390
389
  for plugin in kind_buckets[kind]:
391
390
  if plugin.name in data.runtime_packages:
392
391
  self._build_runtime_sections(plugin, data, auto_update_map)
393
- # Also emit venv packages (if any) as a separate
394
- # provider header without a runtime tag.
395
- if data.packages_map.get(plugin.name):
396
- self._build_plugin_section(plugin, data, auto_update_map)
397
392
  else:
398
393
  self._build_plugin_section(plugin, data, auto_update_map)
399
394
 
@@ -120,6 +120,7 @@ class SettingsWindow(QMainWindow):
120
120
 
121
121
  scroll.setWidget(container)
122
122
  self.setCentralWidget(scroll)
123
+ self._scroll_content = container
123
124
 
124
125
  def _build_updates_section(self) -> CardFrame:
125
126
  """Construct the *Updates* settings card."""
@@ -221,6 +222,9 @@ class SettingsWindow(QMainWindow):
221
222
  card = CardFrame('Startup')
222
223
  self._auto_start_check = QCheckBox('Start with Windows')
223
224
  self._auto_start_check.toggled.connect(self._on_auto_start_changed)
225
+ if not getattr(sys, 'frozen', False):
226
+ self._auto_start_check.setEnabled(False)
227
+ self._auto_start_check.setToolTip('Auto-start is only available for installed builds')
224
228
  card.content_layout.addWidget(self._auto_start_check)
225
229
  return card
226
230
 
@@ -301,6 +305,15 @@ class SettingsWindow(QMainWindow):
301
305
  """Re-enable the *Check for Updates* button after a check completes."""
302
306
  self._check_updates_btn.setEnabled(True)
303
307
 
308
+ def update_config(self, config: ResolvedConfig) -> None:
309
+ """Replace the internal config snapshot without emitting signals.
310
+
311
+ Called by controllers that persist timestamps so that the next
312
+ :meth:`sync_from_config` sees fresh data instead of the stale
313
+ snapshot captured at construction time.
314
+ """
315
+ self._config = config
316
+
304
317
  def set_last_checked(self, timestamp: str) -> None:
305
318
  """Update the *last updated* label from an ISO 8601 timestamp."""
306
319
  relative = _format_relative_time(timestamp)
@@ -314,8 +327,14 @@ class SettingsWindow(QMainWindow):
314
327
  def show(self) -> None:
315
328
  """Sync controls from config, size to content, then show the window."""
316
329
  self.sync_from_config()
317
- # Let the layout determine the ideal size, clamped to the minimum.
318
- self.adjustSize()
330
+ # QScrollArea doesn't propagate its content's sizeHint, so
331
+ # adjustSize() only reaches the minimum. Compute the ideal
332
+ # height from the content widget directly.
333
+ content_hint = self._scroll_content.sizeHint()
334
+ margins = self._scroll_content.layout().contentsMargins()
335
+ ideal_w = max(content_hint.width() + margins.left() + margins.right(), self.minimumWidth())
336
+ ideal_h = max(content_hint.height() + margins.top() + margins.bottom(), self.minimumHeight())
337
+ self.resize(ideal_w, ideal_h)
319
338
  super().show()
320
339
  self.raise_()
321
340
  self.activateWindow()
@@ -388,10 +407,11 @@ class SettingsWindow(QMainWindow):
388
407
 
389
408
  def _on_auto_start_changed(self, checked: bool) -> None:
390
409
  self._config = update_user_config(auto_start=checked)
391
- if checked:
392
- register_startup(sys.executable)
393
- else:
394
- remove_startup()
410
+ if getattr(sys, 'frozen', False):
411
+ if checked:
412
+ register_startup(sys.executable)
413
+ else:
414
+ remove_startup()
395
415
  self.settings_changed.emit(self._config)
396
416
 
397
417
  def _on_debug_logging_changed(self, checked: bool) -> None:
@@ -128,6 +128,7 @@ class SpinnerWidget(QWidget):
128
128
  # Auto-overlay: track parent geometry via event filter
129
129
  if parent is not None:
130
130
  self.setAutoFillBackground(True)
131
+ self.setStyleSheet('background: palette(window);')
131
132
  self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
132
133
  parent.installEventFilter(self)
133
134
  self.setGeometry(parent.rect())
@@ -382,7 +382,12 @@ class ToolUpdateOrchestrator:
382
382
  for pkg_name in result.updated_packages:
383
383
  key = f'{plugin_name}/{pkg_name}' if plugin_name else pkg_name
384
384
  existing[key] = now
385
- update_user_config(last_tool_updates=existing)
385
+ resolved = update_user_config(last_tool_updates=existing)
386
+ # Refresh the config on the tools view so the next rebuild
387
+ # picks up the updated timestamps instead of stale data.
388
+ tools_view_ref = self._window.tools_view
389
+ if tools_view_ref is not None:
390
+ tools_view_ref._config = resolved
386
391
 
387
392
  # Clear updating state on widgets
388
393
  tools_view = self._window.tools_view
@@ -132,7 +132,8 @@ class UpdateController:
132
132
  def _persist_check_timestamp(self) -> None:
133
133
  """Persist the current time as *last_client_update* and refresh the label."""
134
134
  ts = datetime.now(UTC).isoformat()
135
- update_user_config(last_client_update=ts)
135
+ resolved = update_user_config(last_client_update=ts)
136
+ self._settings_window.update_config(resolved)
136
137
  self._settings_window.set_last_checked(ts)
137
138
 
138
139
  def _report_error(self, message: str, *, silent: bool) -> None:
@@ -329,7 +330,9 @@ class UpdateController:
329
330
 
330
331
  # Persist the client-update timestamp (actual update downloaded)
331
332
  ts = datetime.now(UTC).isoformat()
332
- update_user_config(last_client_update=ts)
333
+ resolved = update_user_config(last_client_update=ts)
334
+ self._settings_window.update_config(resolved)
335
+ self._settings_window.set_last_checked(ts)
333
336
 
334
337
  self._pending_version = version
335
338
 
@@ -6,15 +6,14 @@ log-level switching via :func:`set_debug_level`.
6
6
 
7
7
  import logging
8
8
  import sys
9
- import tempfile
10
9
  from logging.handlers import RotatingFileHandler
11
10
  from pathlib import Path
12
11
 
13
- from synodic_client.config import is_dev_mode
12
+ from synodic_client.config import config_dir, is_dev_mode
14
13
 
15
14
  _LOG_FILENAME = 'synodic.log'
16
15
  _LOG_FILENAME_DEV = 'synodic-dev.log'
17
- _MAX_BYTES = 5_242_880 # 5 MB
16
+ _MAX_BYTES = 1_048_576 # 1 MB
18
17
  _BACKUP_COUNT = 3
19
18
  _FORMAT = '%(asctime)s [%(levelname)s] %(name)s: %(message)s'
20
19
 
@@ -24,13 +23,13 @@ _debug_active: bool = False
24
23
  def log_path() -> Path:
25
24
  """Return the path to the application log file.
26
25
 
27
- The file lives in the system temp directory so it is cleaned up
28
- automatically by the OS and avoids permission issues.
26
+ The file lives under ``config_dir() / 'logs'`` so that agents and
27
+ developers can find it at a deterministic, well-known location.
29
28
 
30
29
  Returns:
31
30
  Path to the log file.
32
31
  """
33
- return Path(tempfile.gettempdir()) / (_LOG_FILENAME_DEV if is_dev_mode() else _LOG_FILENAME)
32
+ return config_dir() / 'logs' / (_LOG_FILENAME_DEV if is_dev_mode() else _LOG_FILENAME)
34
33
 
35
34
 
36
35
  class EagerRotatingFileHandler(RotatingFileHandler):
@@ -68,6 +67,8 @@ def configure_logging(*, debug: bool = False) -> None:
68
67
 
69
68
  logging.basicConfig(level=logging.INFO)
70
69
 
70
+ log_path().parent.mkdir(parents=True, exist_ok=True)
71
+
71
72
  handler = EagerRotatingFileHandler(
72
73
  str(log_path()),
73
74
  maxBytes=_MAX_BYTES,
@@ -922,7 +922,6 @@ class TestRuntimePluginSupport:
922
922
 
923
923
  # Expected widget counts (avoids PLR2004)
924
924
  _EXPECTED_RUNTIME_PROVIDERS = 2
925
- _EXPECTED_RUNTIME_PROVIDERS_WITH_VENV = 3
926
925
  _EXPECTED_DEFAULT_RT_PACKAGES = 2
927
926
  _EXPECTED_NON_DEFAULT_RT_PACKAGES = 1
928
927
 
@@ -1050,8 +1049,8 @@ class TestPerRuntimeDisplay:
1050
1049
  assert len(non_default_rows) == _EXPECTED_NON_DEFAULT_RT_PACKAGES
1051
1050
  assert non_default_rows[0]._package_name == 'django'
1052
1051
 
1053
- def test_venv_packages_separate_from_runtime(self) -> None:
1054
- """Venv packages appear in a separate section without runtime tag."""
1052
+ def test_venv_packages_excluded_for_runtime_probed_plugin(self) -> None:
1053
+ """Venv packages must not appear in ToolsView for runtime-probed plugins."""
1055
1054
  view = ToolsView(_make_porringer(), _make_config())
1056
1055
  default_exe = Path('C:/Python314/python.exe')
1057
1056
  plugin = self._pip_plugin()
@@ -1078,13 +1077,8 @@ class TestPerRuntimeDisplay:
1078
1077
  view._build_widget_tree(data)
1079
1078
 
1080
1079
  providers = [w for w in view._section_widgets if isinstance(w, PluginProviderHeader)]
1081
- # 2 runtime providers + 1 venv provider = 3
1082
- assert len(providers) == _EXPECTED_RUNTIME_PROVIDERS_WITH_VENV
1083
-
1084
- # The last provider should NOT have a runtime tag
1085
- last_provider = providers[-1]
1086
- runtime_tags = [w for w in last_provider.findChildren(QLabel) if 'Python' in w.text()]
1087
- assert len(runtime_tags) == 0, 'Venv provider should not have a runtime tag'
1080
+ # Only the 2 runtime providers no extra venv provider
1081
+ assert len(providers) == _EXPECTED_RUNTIME_PROVIDERS
1088
1082
 
1089
1083
  def test_runtime_tag_uses_default_style(self) -> None:
1090
1084
  """The default runtime tag uses the green highlight style."""
@@ -2,12 +2,11 @@
2
2
 
3
3
  import logging
4
4
  import sys
5
- import tempfile
6
5
  from pathlib import Path
7
6
  from unittest.mock import patch
8
7
 
9
8
  from synodic_client.application.screen.settings import SettingsWindow
10
- from synodic_client.config import set_dev_mode
9
+ from synodic_client.config import config_dir, set_dev_mode
11
10
  from synodic_client.logging import (
12
11
  EagerRotatingFileHandler,
13
12
  configure_logging,
@@ -19,10 +18,10 @@ class TestLogPath:
19
18
  """Tests for log_path()."""
20
19
 
21
20
  @staticmethod
22
- def test_returns_path_in_temp_dir() -> None:
23
- """log_path() should resolve inside the system temp directory."""
21
+ def test_returns_path_in_config_logs_dir() -> None:
22
+ """log_path() should resolve inside config_dir() / 'logs'."""
24
23
  path = log_path()
25
- assert path.parent == Path(tempfile.gettempdir())
24
+ assert path.parent == config_dir() / 'logs'
26
25
 
27
26
  @staticmethod
28
27
  def test_filename() -> None:
@@ -272,8 +272,8 @@ class TestSettingsCallbacks:
272
272
  signal_spy.assert_called_once_with(new_config)
273
273
 
274
274
  @staticmethod
275
- def test_auto_start_registers_startup() -> None:
276
- """Enabling auto-start calls register_startup."""
275
+ def test_auto_start_registers_startup_when_frozen() -> None:
276
+ """Enabling auto-start calls register_startup in frozen builds."""
277
277
  config = _make_config()
278
278
  window = _make_window(config)
279
279
 
@@ -282,14 +282,15 @@ class TestSettingsCallbacks:
282
282
  patch('synodic_client.application.screen.settings.update_user_config', return_value=new_config),
283
283
  patch('synodic_client.application.screen.settings.register_startup') as mock_register,
284
284
  patch('synodic_client.application.screen.settings.is_startup_registered', return_value=False),
285
+ patch('synodic_client.application.screen.settings.getattr', return_value=True),
285
286
  ):
286
287
  window._auto_start_check.setChecked(True)
287
288
 
288
289
  mock_register.assert_called_once()
289
290
 
290
291
  @staticmethod
291
- def test_auto_start_removes_startup() -> None:
292
- """Disabling auto-start calls remove_startup."""
292
+ def test_auto_start_removes_startup_when_frozen() -> None:
293
+ """Disabling auto-start calls remove_startup in frozen builds."""
293
294
  config = _make_config(auto_start=True)
294
295
  window = _make_window(config)
295
296
  # Manually set initial state without triggering signals
@@ -301,11 +302,31 @@ class TestSettingsCallbacks:
301
302
  with (
302
303
  patch('synodic_client.application.screen.settings.update_user_config', return_value=new_config),
303
304
  patch('synodic_client.application.screen.settings.remove_startup') as mock_remove,
305
+ patch('synodic_client.application.screen.settings.getattr', return_value=True),
304
306
  ):
305
307
  window._auto_start_check.setChecked(False)
306
308
 
307
309
  mock_remove.assert_called_once()
308
310
 
311
+ @staticmethod
312
+ def test_auto_start_skips_registry_when_not_frozen() -> None:
313
+ """Auto-start toggle persists config but skips registry in non-frozen builds."""
314
+ config = _make_config()
315
+ window = _make_window(config)
316
+
317
+ new_config = _make_config(auto_start=True)
318
+ with (
319
+ patch('synodic_client.application.screen.settings.update_user_config', return_value=new_config),
320
+ patch('synodic_client.application.screen.settings.register_startup') as mock_register,
321
+ patch('synodic_client.application.screen.settings.remove_startup') as mock_remove,
322
+ patch('synodic_client.application.screen.settings.is_startup_registered', return_value=False),
323
+ patch('synodic_client.application.screen.settings.getattr', return_value=False),
324
+ ):
325
+ window._auto_start_check.setChecked(True)
326
+
327
+ mock_register.assert_not_called()
328
+ mock_remove.assert_not_called()
329
+
309
330
 
310
331
  # ---------------------------------------------------------------------------
311
332
  # sync_from_config does not emit signals
@@ -383,3 +404,46 @@ class TestCheckForUpdatesButton:
383
404
  window.set_checking()
384
405
  assert window._check_updates_btn.isEnabled() is False
385
406
  assert window._update_status_label.text() == 'Checking\u2026'
407
+
408
+
409
+ # ---------------------------------------------------------------------------
410
+ # update_config — silent config refresh
411
+ # ---------------------------------------------------------------------------
412
+
413
+
414
+ class TestUpdateConfig:
415
+ """Verify that update_config refreshes _config without emitting signals."""
416
+
417
+ @staticmethod
418
+ def test_update_config_replaces_internal_config() -> None:
419
+ """update_config should replace _config with the new snapshot."""
420
+ window = _make_window(_make_config(last_client_update=None))
421
+ new_config = _make_config(last_client_update='2026-03-09T12:00:00+00:00')
422
+
423
+ window.update_config(new_config)
424
+
425
+ assert window._config is new_config
426
+ assert window._config.last_client_update == '2026-03-09T12:00:00+00:00'
427
+
428
+ @staticmethod
429
+ def test_update_config_does_not_emit_settings_changed() -> None:
430
+ """update_config must NOT emit settings_changed to avoid circular reinit."""
431
+ window = _make_window()
432
+ signal_spy = MagicMock()
433
+ window.settings_changed.connect(signal_spy)
434
+
435
+ window.update_config(_make_config(update_channel='dev'))
436
+
437
+ signal_spy.assert_not_called()
438
+
439
+ @staticmethod
440
+ def test_sync_after_update_config_uses_new_timestamp() -> None:
441
+ """sync_from_config after update_config should display the refreshed timestamp."""
442
+ window = _make_window(_make_config(last_client_update=None))
443
+ assert window._last_client_update_label.text() == ''
444
+
445
+ new_config = _make_config(last_client_update='2026-03-09T12:00:00+00:00')
446
+ window.update_config(new_config)
447
+ window.sync_from_config()
448
+
449
+ assert 'Last updated:' in window._last_client_update_label.text()
@@ -414,3 +414,57 @@ class TestCheckError:
414
414
  ctrl._on_check_error('timeout', silent=True)
415
415
 
416
416
  assert banner.state.name == 'HIDDEN'
417
+
418
+
419
+ # ---------------------------------------------------------------------------
420
+ # Timestamp sync — _persist_check_timestamp updates config
421
+ # ---------------------------------------------------------------------------
422
+
423
+
424
+ class TestPersistCheckTimestamp:
425
+ """Verify _persist_check_timestamp syncs the settings config."""
426
+
427
+ @staticmethod
428
+ def test_persist_updates_settings_config() -> None:
429
+ """_persist_check_timestamp should call update_config on the settings window."""
430
+ ctrl, _app, _client, _banner, settings = _make_controller()
431
+
432
+ fake_resolved = _make_config(last_client_update='2026-03-09T00:00:00+00:00')
433
+ with patch(
434
+ 'synodic_client.application.update_controller.update_user_config',
435
+ return_value=fake_resolved,
436
+ ):
437
+ ctrl._persist_check_timestamp()
438
+
439
+ settings.update_config.assert_called_once_with(fake_resolved)
440
+ settings.set_last_checked.assert_called_once()
441
+
442
+ @staticmethod
443
+ def test_on_check_finished_success_syncs_config() -> None:
444
+ """A successful check should persist timestamp AND sync settings config."""
445
+ ctrl, _app, _client, _banner, settings = _make_controller()
446
+ result = UpdateInfo(available=False, current_version=Version('1.0.0'))
447
+
448
+ fake_resolved = _make_config(last_client_update='2026-03-09T00:00:00+00:00')
449
+ with patch(
450
+ 'synodic_client.application.update_controller.update_user_config',
451
+ return_value=fake_resolved,
452
+ ):
453
+ ctrl._on_check_finished(result, silent=True)
454
+
455
+ settings.update_config.assert_called_once_with(fake_resolved)
456
+
457
+ @staticmethod
458
+ def test_download_finished_syncs_config_and_label() -> None:
459
+ """_on_download_finished should sync config and update the label."""
460
+ ctrl, _app, _client, _banner, settings = _make_controller(auto_apply=False)
461
+
462
+ fake_resolved = _make_config(last_client_update='2026-03-09T00:00:00+00:00')
463
+ with patch(
464
+ 'synodic_client.application.update_controller.update_user_config',
465
+ return_value=fake_resolved,
466
+ ):
467
+ ctrl._on_download_finished(True, '2.0.0')
468
+
469
+ settings.update_config.assert_called_once_with(fake_resolved)
470
+ settings.set_last_checked.assert_called_once()
@@ -28,6 +28,7 @@ class TestRunStartupPreamble:
28
28
  patch(f'{_MODULE}.resolve_config') as mock_resolve,
29
29
  patch(f'{_MODULE}.register_startup'),
30
30
  patch(f'{_MODULE}.remove_startup'),
31
+ patch(f'{_MODULE}.getattr', return_value=True),
31
32
  ):
32
33
  mock_resolve.return_value = MagicMock(auto_start=True)
33
34
  run_startup_preamble(r'C:\app\synodic.exe')
@@ -38,13 +39,14 @@ class TestRunStartupPreamble:
38
39
 
39
40
  @staticmethod
40
41
  def test_registers_startup_when_auto_start_true() -> None:
41
- """register_startup is called when auto_start is True."""
42
+ """register_startup is called when auto_start is True and frozen."""
42
43
  with (
43
44
  patch(f'{_MODULE}.seed_user_config_from_build'),
44
45
  patch(f'{_MODULE}.register_protocol'),
45
46
  patch(f'{_MODULE}.resolve_config') as mock_resolve,
46
47
  patch(f'{_MODULE}.register_startup') as mock_register,
47
48
  patch(f'{_MODULE}.remove_startup') as mock_remove,
49
+ patch(f'{_MODULE}.getattr', return_value=True),
48
50
  ):
49
51
  mock_resolve.return_value = MagicMock(auto_start=True)
50
52
  run_startup_preamble(r'C:\app\synodic.exe')
@@ -54,13 +56,14 @@ class TestRunStartupPreamble:
54
56
 
55
57
  @staticmethod
56
58
  def test_removes_startup_when_auto_start_false() -> None:
57
- """remove_startup is called when auto_start is False."""
59
+ """remove_startup is called when auto_start is False and frozen."""
58
60
  with (
59
61
  patch(f'{_MODULE}.seed_user_config_from_build'),
60
62
  patch(f'{_MODULE}.register_protocol'),
61
63
  patch(f'{_MODULE}.resolve_config') as mock_resolve,
62
64
  patch(f'{_MODULE}.register_startup') as mock_register,
63
65
  patch(f'{_MODULE}.remove_startup') as mock_remove,
66
+ patch(f'{_MODULE}.getattr', return_value=True),
64
67
  ):
65
68
  mock_resolve.return_value = MagicMock(auto_start=False)
66
69
  run_startup_preamble(r'C:\app\synodic.exe')
@@ -78,6 +81,7 @@ class TestRunStartupPreamble:
78
81
  patch(f'{_MODULE}.register_startup') as mock_register,
79
82
  patch(f'{_MODULE}.remove_startup'),
80
83
  patch(f'{_MODULE}.sys') as mock_sys,
84
+ patch(f'{_MODULE}.getattr', return_value=True),
81
85
  ):
82
86
  mock_sys.executable = r'C:\Python\python.exe'
83
87
  mock_resolve.return_value = MagicMock(auto_start=True)
@@ -86,6 +90,24 @@ class TestRunStartupPreamble:
86
90
  mock_proto.assert_called_once_with(r'C:\Python\python.exe')
87
91
  mock_register.assert_called_once_with(r'C:\Python\python.exe')
88
92
 
93
+ @staticmethod
94
+ def test_skips_registry_when_not_frozen() -> None:
95
+ """Protocol and startup registration are skipped in non-frozen builds."""
96
+ with (
97
+ patch(f'{_MODULE}.seed_user_config_from_build'),
98
+ patch(f'{_MODULE}.register_protocol') as mock_proto,
99
+ patch(f'{_MODULE}.resolve_config') as mock_resolve,
100
+ patch(f'{_MODULE}.register_startup') as mock_register,
101
+ patch(f'{_MODULE}.remove_startup') as mock_remove,
102
+ patch(f'{_MODULE}.getattr', return_value=False),
103
+ ):
104
+ mock_resolve.return_value = MagicMock(auto_start=True)
105
+ run_startup_preamble(r'C:\Python\python.exe')
106
+
107
+ mock_proto.assert_not_called()
108
+ mock_register.assert_not_called()
109
+ mock_remove.assert_not_called()
110
+
89
111
  @staticmethod
90
112
  def test_idempotent_on_second_call() -> None:
91
113
  """A second call is a no-op; the preamble runs only once."""