solstone-linux 0.3.2__tar.gz → 0.3.3__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 (66) hide show
  1. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/CHANGELOG.md +5 -0
  2. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/PKG-INFO +1 -1
  3. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/pyproject.toml +1 -1
  4. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/src/solstone_linux/__init__.py +1 -1
  5. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/src/solstone_linux/dbusmenu.py +10 -2
  6. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/src/solstone_linux/tray.py +27 -6
  7. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/tests/test_dbusmenu.py +22 -1
  8. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/tests/test_tray.py +149 -2
  9. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/.gitignore +0 -0
  10. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/AGENTS.md +0 -0
  11. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/CLAUDE.md +0 -0
  12. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/INSTALL.md +0 -0
  13. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/LICENSE +0 -0
  14. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/Makefile +0 -0
  15. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/README.md +0 -0
  16. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/contrib/icons/hicolor/scalable/status/solstone-error.svg +0 -0
  17. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/contrib/icons/hicolor/scalable/status/solstone-paused.svg +0 -0
  18. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/contrib/icons/hicolor/scalable/status/solstone-recording.svg +0 -0
  19. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/contrib/icons/hicolor/scalable/status/solstone-syncing.svg +0 -0
  20. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/scripts/extract_changelog.sh +0 -0
  21. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/scripts/release.sh +0 -0
  22. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/src/solstone_linux/activity.py +0 -0
  23. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/src/solstone_linux/audio_detect.py +0 -0
  24. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/src/solstone_linux/audio_mute.py +0 -0
  25. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/src/solstone_linux/audio_recorder.py +0 -0
  26. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/src/solstone_linux/chat_bridge.py +0 -0
  27. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/src/solstone_linux/cli.py +0 -0
  28. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/src/solstone_linux/config.py +0 -0
  29. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/src/solstone_linux/dbus_service.py +0 -0
  30. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/src/solstone_linux/doctor.py +0 -0
  31. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/src/solstone_linux/icons/hicolor/scalable/status/solstone-error.svg +0 -0
  32. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/src/solstone_linux/icons/hicolor/scalable/status/solstone-paused.svg +0 -0
  33. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/src/solstone_linux/icons/hicolor/scalable/status/solstone-recording.svg +0 -0
  34. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/src/solstone_linux/icons/hicolor/scalable/status/solstone-syncing.svg +0 -0
  35. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/src/solstone_linux/install_guard.py +0 -0
  36. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/src/solstone_linux/monitor_positions.py +0 -0
  37. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/src/solstone_linux/observer.py +0 -0
  38. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/src/solstone_linux/recovery.py +0 -0
  39. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/src/solstone_linux/screencast.py +0 -0
  40. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/src/solstone_linux/session_env.py +0 -0
  41. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/src/solstone_linux/sni.py +0 -0
  42. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/src/solstone_linux/solstone-linux.service.in +0 -0
  43. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/src/solstone_linux/streams.py +0 -0
  44. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/src/solstone_linux/sync.py +0 -0
  45. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/src/solstone_linux/sync_health.py +0 -0
  46. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/src/solstone_linux/upload.py +0 -0
  47. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/tests/__init__.py +0 -0
  48. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/tests/test_activity.py +0 -0
  49. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/tests/test_chat_bridge.py +0 -0
  50. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/tests/test_cli.py +0 -0
  51. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/tests/test_config.py +0 -0
  52. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/tests/test_dbus_service.py +0 -0
  53. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/tests/test_doctor.py +0 -0
  54. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/tests/test_extract_changelog.py +0 -0
  55. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/tests/test_install_guard.py +0 -0
  56. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/tests/test_monitor_positions.py +0 -0
  57. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/tests/test_observer.py +0 -0
  58. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/tests/test_observer_emits_stream_silent_event.py +0 -0
  59. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/tests/test_screencast.py +0 -0
  60. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/tests/test_screencast_stop_filters_silent_streams.py +0 -0
  61. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/tests/test_session_env.py +0 -0
  62. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/tests/test_streams.py +0 -0
  63. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/tests/test_sync.py +0 -0
  64. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/tests/test_sync_health.py +0 -0
  65. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/tests/test_sync_health_surfaces.py +0 -0
  66. {solstone_linux-0.3.2 → solstone_linux-0.3.3}/tests/test_upload.py +0 -0
@@ -4,6 +4,11 @@ All notable changes to solstone-linux are documented here.
4
4
  The format is based on Keep a Changelog (https://keepachangelog.com/),
5
5
  and this project adheres to Semantic Versioning.
6
6
 
7
+ ## [0.3.3] - 2026-06-16
8
+
9
+ ### Fixed
10
+ - the tray status submenu now refreshes its values every time you open it. the segment countdown, cache size, captures today, uptime, and sync line had been showing stale values on reopen on some desktops; they now reflect the current state each time you open the menu.
11
+
7
12
  ## [0.3.2] - 2026-06-16
8
13
 
9
14
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: solstone-linux
3
- Version: 0.3.2
3
+ Version: 0.3.3
4
4
  Summary: Standalone Linux desktop observer for solstone
5
5
  License-Expression: AGPL-3.0-only
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "solstone-linux"
3
- version = "0.3.2"
3
+ version = "0.3.3"
4
4
  description = "Standalone Linux desktop observer for solstone"
5
5
  readme = "README.md"
6
6
  license = "AGPL-3.0-only"
@@ -3,4 +3,4 @@
3
3
 
4
4
  """Standalone Linux desktop observer for solstone."""
5
5
 
6
- __version__ = "0.3.2"
6
+ __version__ = "0.3.3"
@@ -86,6 +86,8 @@ class DBusMenu(ServiceInterface):
86
86
 
87
87
  def __init__(self):
88
88
  super().__init__("com.canonical.dbusmenu")
89
+ self.on_about_to_show = None
90
+ self._props_emitted = 0
89
91
  self._revision = 1
90
92
  self._root = MenuItem() # id 0 is root
91
93
  self._root.id = 0
@@ -106,6 +108,7 @@ class DBusMenu(ServiceInterface):
106
108
  return
107
109
 
108
110
  updated = {name: self._property_variant(item, name) for name in names}
111
+ self._props_emitted += 1
109
112
  self.ItemsPropertiesUpdated([[item.id, updated]], [])
110
113
 
111
114
  def _register_items(self, items: list[MenuItem]):
@@ -198,11 +201,16 @@ class DBusMenu(ServiceInterface):
198
201
 
199
202
  @method()
200
203
  def AboutToShow(self, item_id: "i") -> "b":
201
- return False # GetLayout always returns fresh state; no pending unsignaled changes.
204
+ if self.on_about_to_show is None:
205
+ return False
206
+ return bool(self.on_about_to_show())
202
207
 
203
208
  @method()
204
209
  def AboutToShowGroup(self, ids: "ai") -> "aiai":
205
- return [[], []] # no updates, no errors
210
+ if self.on_about_to_show is None:
211
+ return [[], []]
212
+ changed = bool(self.on_about_to_show())
213
+ return [list(ids), []] if changed else [[], []]
206
214
 
207
215
  # ── D-Bus Properties ──
208
216
 
@@ -160,9 +160,10 @@ class TrayApp:
160
160
 
161
161
  return True
162
162
 
163
- def update(self):
163
+ def update(self, force_stats=False):
164
164
  """Read observer state and update tray display."""
165
165
  obs = self._observer
166
+ now = time.monotonic()
166
167
 
167
168
  # Determine status
168
169
  if obs._paused:
@@ -178,18 +179,17 @@ class TrayApp:
178
179
  if obs._paused or obs.segment_dir is None:
179
180
  segment_timer = 0
180
181
  else:
181
- remaining = obs.interval - (time.monotonic() - obs.start_at_mono)
182
+ remaining = obs.interval - (now - obs.start_at_mono)
182
183
  segment_timer = max(0, int(remaining))
183
184
 
184
185
  # Pause remaining
185
186
  if not obs._paused or obs._pause_until <= 0:
186
187
  pause_remaining = 0
187
188
  else:
188
- pause_remaining = max(0, int(obs._pause_until - time.monotonic()))
189
+ pause_remaining = max(0, int(obs._pause_until - now))
189
190
 
190
191
  # Compute stats (throttled — filesystem walk every 60s)
191
- now = time.monotonic()
192
- if now - self._last_stats_time >= 60:
192
+ if force_stats or now - self._last_stats_time >= 60:
193
193
  self._last_stats_time = now
194
194
  captures_today = 0
195
195
  total_size = 0
@@ -220,7 +220,7 @@ class TrayApp:
220
220
  pass
221
221
 
222
222
  total_size_mb = int(total_size / (1024 * 1024))
223
- uptime_seconds = int(time.monotonic() - obs._start_mono)
223
+ uptime_seconds = int(now - obs._start_mono)
224
224
 
225
225
  self.stats = {
226
226
  "captures_today": captures_today,
@@ -234,6 +234,19 @@ class TrayApp:
234
234
  self._update_live_stats(segment_timer, pause_remaining)
235
235
  self.paused_remaining = pause_remaining
236
236
 
237
+ def _on_about_to_show(self) -> bool:
238
+ """Full recompute on menu open; returns True if any item changed.
239
+
240
+ Runs outside _refresh_tray so a failure here never tears down the tray.
241
+ """
242
+ before = self.menu._props_emitted
243
+ try:
244
+ self.update(force_stats=True)
245
+ except Exception:
246
+ log.warning("Tray on-open recompute failed", exc_info=True)
247
+ return False
248
+ return self.menu._props_emitted > before
249
+
237
250
  def _build_menu(self):
238
251
  """Build the full tray menu structure."""
239
252
 
@@ -361,6 +374,7 @@ class TrayApp:
361
374
  service_hint,
362
375
  ]
363
376
  )
377
+ self.menu.on_about_to_show = self._on_about_to_show
364
378
 
365
379
  def _icon_for_health(self, status: str, health: SyncHealth) -> str:
366
380
  if self.error:
@@ -420,6 +434,7 @@ class TrayApp:
420
434
  self._status_header.label = label
421
435
  self._status_item.label = label
422
436
  self.menu.update_properties(self._status_header, "label")
437
+ self.menu.update_properties(self._status_item, "label")
423
438
 
424
439
  def _update_sync(self, health: SyncHealth):
425
440
  """Update sync status display."""
@@ -427,6 +442,7 @@ class TrayApp:
427
442
  return
428
443
  self.health = health
429
444
  self._sync_item.label = health.sync_line
445
+ self.menu.update_properties(self._sync_item, "label")
430
446
 
431
447
  if not self.error:
432
448
  self.sni.set_icon(self._icon_for_health(self.status, health))
@@ -446,6 +462,7 @@ class TrayApp:
446
462
  new_label = f"segment: {mins}:{secs:02d} remaining"
447
463
  if self._segment_item.label != new_label:
448
464
  self._segment_item.label = new_label
465
+ self.menu.update_properties(self._segment_item, "label")
449
466
 
450
467
  # Stats (computed in update())
451
468
  if self.stats:
@@ -462,10 +479,13 @@ class TrayApp:
462
479
 
463
480
  if self._cache_item.label != new_cache:
464
481
  self._cache_item.label = new_cache
482
+ self.menu.update_properties(self._cache_item, "label")
465
483
  if self._captures_item.label != new_captures:
466
484
  self._captures_item.label = new_captures
485
+ self.menu.update_properties(self._captures_item, "label")
467
486
  if self._uptime_item.label != new_uptime:
468
487
  self._uptime_item.label = new_uptime
488
+ self.menu.update_properties(self._uptime_item, "label")
469
489
 
470
490
  # Update pause remaining in resume button
471
491
  if self.status == "paused" and pause_remaining > 0:
@@ -473,6 +493,7 @@ class TrayApp:
473
493
  new_resume = f"resume ({pr_mins}m remaining)"
474
494
  if self._resume_item.label != new_resume:
475
495
  self._resume_item.label = new_resume
496
+ self.menu.update_properties(self._resume_item, "label")
476
497
 
477
498
  def _build_tooltip(self, health: SyncHealth | None = None) -> str:
478
499
  """Build plain-text tooltip body (cross-DE compatible)."""
@@ -55,6 +55,7 @@ def test_update_properties_emits_items_properties_updated():
55
55
  menu.ItemsPropertiesUpdated.assert_called_once()
56
56
  menu.LayoutUpdated.assert_not_called()
57
57
  assert menu._revision == revision
58
+ assert menu._props_emitted == 1
58
59
 
59
60
  updated_props, removed_props = menu.ItemsPropertiesUpdated.call_args.args
60
61
  assert removed_props == []
@@ -79,9 +80,29 @@ def test_update_properties_noop_when_no_names():
79
80
  menu.ItemsPropertiesUpdated.assert_not_called()
80
81
  menu.LayoutUpdated.assert_not_called()
81
82
  assert menu._revision == revision
83
+ assert menu._props_emitted == 0
82
84
 
83
85
 
84
- def test_about_to_show_returns_false():
86
+ def test_about_to_show_uses_optional_hook():
85
87
  menu = DBusMenu()
86
88
 
87
89
  assert DBusMenu.AboutToShow.__wrapped__(menu, 0) is False
90
+
91
+ menu.on_about_to_show = lambda: True
92
+ assert DBusMenu.AboutToShow.__wrapped__(menu, 0) is True
93
+
94
+ menu.on_about_to_show = lambda: False
95
+ assert DBusMenu.AboutToShow.__wrapped__(menu, 0) is False
96
+
97
+
98
+ def test_about_to_show_group_uses_optional_hook():
99
+ menu = DBusMenu()
100
+ ids = [1, 2, 3]
101
+
102
+ assert DBusMenu.AboutToShowGroup.__wrapped__(menu, ids) == [[], []]
103
+
104
+ menu.on_about_to_show = lambda: True
105
+ assert DBusMenu.AboutToShowGroup.__wrapped__(menu, ids) == [ids, []]
106
+
107
+ menu.on_about_to_show = lambda: False
108
+ assert DBusMenu.AboutToShowGroup.__wrapped__(menu, ids) == [[], []]
@@ -2,6 +2,7 @@
2
2
  # Copyright (c) 2026 sol pbc
3
3
 
4
4
  import time
5
+ from datetime import datetime
5
6
  from pathlib import Path
6
7
  from unittest.mock import call
7
8
  from unittest.mock import MagicMock
@@ -10,7 +11,7 @@ from unittest.mock import patch
10
11
  import pytest
11
12
 
12
13
  from solstone_linux.config import Config
13
- from solstone_linux.dbusmenu import MenuItem, separator
14
+ from solstone_linux.dbusmenu import DBusMenu, MenuItem, separator
14
15
  from solstone_linux.sni import StatusNotifierItem
15
16
  from solstone_linux.sync_health import ErrorType, HealthState, SyncFacts, derive_health
16
17
  from solstone_linux.tray import (
@@ -60,6 +61,28 @@ def _offline_health():
60
61
  return _health(SyncFacts(last_error_class=ErrorType.TRANSIENT))
61
62
 
62
63
 
64
+ def _create_capture_segment(app, size=1024 * 1024):
65
+ today = datetime.now().strftime("%Y%m%d")
66
+ segment_dir = app.config.captures_dir / today / "test-stream" / "120000_300"
67
+ segment_dir.mkdir(parents=True)
68
+ (segment_dir / "screen.mp4").write_bytes(b"x" * size)
69
+ return segment_dir
70
+
71
+
72
+ def _prepare_open_refresh_state(app, now):
73
+ segment_dir = _create_capture_segment(app)
74
+ app._observer.current_mode = "screencast"
75
+ app._observer._paused = False
76
+ app._observer.segment_dir = segment_dir
77
+ app._observer.start_at_mono = now - 75
78
+ app._observer._start_mono = now - 3661
79
+ app._observer.interval = 300
80
+ app._observer._sync = MagicMock()
81
+ app._observer._sync.health = _connected_health()
82
+ app._last_stats_time = now - 10
83
+ return segment_dir
84
+
85
+
63
86
  class TestResolveIconThemePath:
64
87
  def test_resolve_icon_theme_path_prefers_installed(self, tmp_path):
65
88
  installed_icon = (
@@ -158,6 +181,22 @@ class TestUpdateStatus:
158
181
 
159
182
 
160
183
  class TestUpdateSync:
184
+ def test_update_sync_signals_label_change_only_once(self):
185
+ app = _make_app()
186
+ app._build_menu()
187
+ app.menu.update_properties = MagicMock()
188
+ health = _connected_health()
189
+
190
+ app._update_sync(health)
191
+
192
+ app.menu.update_properties.assert_called_once_with(app._sync_item, "label")
193
+
194
+ app.menu.update_properties.reset_mock()
195
+
196
+ app._update_sync(health)
197
+
198
+ app.menu.update_properties.assert_not_called()
199
+
161
200
  def test_update_sync_synced(self):
162
201
  app = _make_app()
163
202
  app._build_menu()
@@ -223,12 +262,40 @@ class TestUpdateLiveStats:
223
262
  }
224
263
 
225
264
  app._update_live_stats(245, 0)
265
+
266
+ assert app.menu.update_properties.call_args_list == [
267
+ call(app._segment_item, "label"),
268
+ call(app._cache_item, "label"),
269
+ call(app._captures_item, "label"),
270
+ call(app._uptime_item, "label"),
271
+ ]
272
+
226
273
  app.menu.update_properties.reset_mock()
227
274
 
228
275
  app._update_live_stats(245, 0)
229
276
 
230
277
  app.menu.update_properties.assert_not_called()
231
278
 
279
+ def test_update_live_stats_signals_resume_countdown_change_only_once(self):
280
+ app = _make_app()
281
+ app._build_menu()
282
+ app.status = "paused"
283
+ app._segment_item.label = "segment: 0:00 remaining"
284
+ app.menu.update_properties = MagicMock()
285
+
286
+ app._update_live_stats(0, 600)
287
+
288
+ app.menu.update_properties.assert_called_once_with(
289
+ app._resume_item,
290
+ "label",
291
+ )
292
+
293
+ app.menu.update_properties.reset_mock()
294
+
295
+ app._update_live_stats(0, 600)
296
+
297
+ app.menu.update_properties.assert_not_called()
298
+
232
299
 
233
300
  class TestHeaderLabel:
234
301
  def test_update_header_emits_label_property_update(self):
@@ -243,8 +310,21 @@ class TestHeaderLabel:
243
310
 
244
311
  app._update_header(0, _connected_health())
245
312
 
246
- app.menu.update_properties.assert_called_with(app._status_header, "label")
313
+ assert (
314
+ call(app._status_header, "label")
315
+ in app.menu.update_properties.call_args_list
316
+ )
317
+ assert (
318
+ call(app._status_item, "label") in app.menu.update_properties.call_args_list
319
+ )
247
320
  assert app._status_header.label == "observing — connected"
321
+ assert app._status_item.label == "observing — connected"
322
+
323
+ app.menu.update_properties.reset_mock()
324
+
325
+ app._update_header(0, _connected_health())
326
+
327
+ app.menu.update_properties.assert_not_called()
248
328
 
249
329
  def test_header_recording_connected(self):
250
330
  app = _make_app()
@@ -354,6 +434,73 @@ class TestStatusNotifierItem:
354
434
 
355
435
 
356
436
  class TestUpdate:
437
+ def test_on_about_to_show_forces_recompute(self, tmp_path):
438
+ app = _make_app(tmp_path)
439
+ app._build_menu()
440
+ now = 10_000.0
441
+ _prepare_open_refresh_state(app, now)
442
+
443
+ with patch("solstone_linux.tray.time.monotonic", return_value=now):
444
+ changed = app._on_about_to_show()
445
+
446
+ assert changed is True
447
+ assert app.stats == {
448
+ "captures_today": 1,
449
+ "total_size_mb": 1,
450
+ "uptime_seconds": 3661,
451
+ }
452
+ assert app._segment_item.label == "segment: 3:45 remaining"
453
+ assert app._cache_item.label == "cache: 1 MB"
454
+ assert app._captures_item.label == "captures today: 1 segments"
455
+ assert app._uptime_item.label == "uptime: 1h 1m"
456
+ assert app._sync_item.label == "sync: up to date"
457
+ assert app._status_item.label == "observing — connected"
458
+
459
+ def test_about_to_show_returns_true_and_layout_has_refreshed_labels(self, tmp_path):
460
+ app = _make_app(tmp_path)
461
+ app._build_menu()
462
+ now = 10_000.0
463
+ _prepare_open_refresh_state(app, now)
464
+
465
+ with patch("solstone_linux.tray.time.monotonic", return_value=now):
466
+ assert DBusMenu.AboutToShow.__wrapped__(app.menu, 0) is True
467
+
468
+ row_items = [
469
+ app._status_item,
470
+ app._sync_item,
471
+ app._segment_item,
472
+ app._cache_item,
473
+ app._captures_item,
474
+ app._uptime_item,
475
+ ]
476
+ props_by_id = {
477
+ item_id: props
478
+ for item_id, props in DBusMenu.GetGroupProperties.__wrapped__(
479
+ app.menu,
480
+ [item.id for item in row_items],
481
+ [],
482
+ )
483
+ }
484
+
485
+ for item in row_items:
486
+ assert props_by_id[item.id]["label"].value == item.label
487
+
488
+ def test_on_about_to_show_failure_keeps_tray_and_last_known_layout(self):
489
+ app = _make_app()
490
+ app._build_menu()
491
+ app._observer._tray = app
492
+ app.update = MagicMock(side_effect=RuntimeError("boom"))
493
+
494
+ assert app._on_about_to_show() is False
495
+ assert app._observer._tray is app
496
+
497
+ props = DBusMenu.GetGroupProperties.__wrapped__(
498
+ app.menu,
499
+ [app._status_item.id],
500
+ [],
501
+ )
502
+ assert props[0][1]["label"].value == "observing"
503
+
357
504
  def test_first_update_clears_starting_tooltip(self):
358
505
  """Tray tooltip must not stay on 'starting...' after first update."""
359
506
  app = _make_app()
File without changes
File without changes
File without changes
File without changes
File without changes