diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ee2a958..baa114a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -38,4 +38,4 @@ jobs: - name: Verify Codex plugin bundle run: .venv/bin/python -m pytest tests/test_plugin_bundle.py -q - name: Run tests - run: .venv/bin/python -m pytest -q + run: .venv/bin/python -m pytest -n 4 --dist loadfile -q diff --git a/docs/DESKTOP_UX.md b/docs/DESKTOP_UX.md index 0382b78..30aa564 100644 --- a/docs/DESKTOP_UX.md +++ b/docs/DESKTOP_UX.md @@ -18,7 +18,7 @@ are ignored. There are no local Advanced toggles. | History | Recording status/control, broker/topic/time filters, pagination, payload and truncation warnings | Adds page size, byte columns, receipt provenance and counters | Recording and message browsing remain core tasks. | | Health | Overview, actionable checks, expectation conditions and failure history | Adds Changes, failure occurrence counts and history deletion | Understand and resolve failures without diagnostic administration. | | Expectations | Name, condition, required values/encoding, enabled state, save/delete | Adds action settings, description and revision details | All condition types remain usable, including existing binary rules. Nondefault action settings are summarized and preserved on save. | -| Snapshot | Compact notice of configured bounds and essential empty-state recovery actions | Full Snapshot destination, filters, rendering bounds and diagnostics | Existing view limits stay active and visible as a summary. | +| Snapshot | Snapshot destination with configured bounds and essential recovery actions | Adds detailed diagnostics | Existing view limits stay active and visible as a summary. | | Storage and profiles | Specialist destinations hidden | Stored observations, cache administration, retention/pruning and Diagnostic profiles | Keep administration out of everyday navigation. | | Help | Broker recovery, MCP setup, support export and About | Also includes log console | Setup and support stay available in both modes. | @@ -27,8 +27,9 @@ boundary**. Switching never connects or disconnects, publishes, changes recordin expectations or retention, or deletes data. Existing configurations remain active. History recording is changed only through the explicit **Record messages** action. -The setting updates existing panes and subsequent navigation. Snapshot falls back -to Health; Health → Changes falls back to Overview. Broker/topic context, history +The setting updates existing panes and subsequent navigation. Snapshot remains +available in both modes so its configured bounds can be inspected or reset and +**Reconnect & observe** remains reachable; Health → Changes falls back to Overview. Broker/topic context, history filters/results and unsent publish text remain intact. Specialist menu actions, shortcuts and the log dock's context-menu action are unavailable in simplified mode. diff --git a/docs/images/observation-history-after.png b/docs/images/observation-history-after.png new file mode 100644 index 0000000..205439c Binary files /dev/null and b/docs/images/observation-history-after.png differ diff --git a/docs/images/observation-history-settings.png b/docs/images/observation-history-settings.png new file mode 100644 index 0000000..93117bc Binary files /dev/null and b/docs/images/observation-history-settings.png differ diff --git a/pyproject.toml b/pyproject.toml index 75fc4b6..b323606 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,6 +55,7 @@ apps = [ test = [ "keyrings.alt", "pytest", + "pytest-xdist", "pytest-asyncio", ] diff --git a/src/topicgate/gui/components/event_history_widget.py b/src/topicgate/gui/components/event_history_widget.py index a828792..08ad4dd 100644 --- a/src/topicgate/gui/components/event_history_widget.py +++ b/src/topicgate/gui/components/event_history_widget.py @@ -150,7 +150,11 @@ def _request_recording(self, enabled: bool) -> None: self.render_recording() def _recording_loaded(self) -> None: - if self._view_model.history_settings_broker == self.broker.currentData(): + broker_id = self.broker.currentData() + if ( + self._view_model.history_recording_status_for(broker_id) is not None + or self._view_model.history_settings_error_for(broker_id) is not None + ): self._recording_loading = False self._recording_pending = False self.render_recording() @@ -163,10 +167,10 @@ def request_recording_status(self) -> None: def render_recording(self) -> None: vm = self._view_model broker = self.broker.currentData() - status = vm.history_recording_status - known = vm.history_settings_broker == broker and status is not None and status.broker_id == broker + status = vm.history_recording_status_for(broker) + known = status is not None and status.broker_id == broker busy = vm.is_busy("history-settings") or self._recording_pending - error = vm.history_settings_error if vm.history_settings_broker == broker else None + error = vm.history_settings_error_for(broker) self.enable_recording.setEnabled(known and not busy and not error and not self._recording_loading) self.enable_recording.setChecked(bool(known and status.enabled)) self.reload_recording.setVisible(bool(error) or not known or self._recording_loading) diff --git a/src/topicgate/gui/main_view_model.py b/src/topicgate/gui/main_view_model.py index 4ec2afd..dc1f022 100644 --- a/src/topicgate/gui/main_view_model.py +++ b/src/topicgate/gui/main_view_model.py @@ -136,11 +136,13 @@ def __init__( self._runtime = runtime self.history_policy: HistoryRetentionPolicy | None = None self.history_recording_status: HistoryRecordingStatus | None = None + self._history_recording_statuses: dict[UUID, HistoryRecordingStatus] = {} + self._history_settings_errors: dict[UUID, str] = {} self.history_usage: HistoryUsage | None = None self.history_settings_broker: UUID | None = None self.history_settings_error: str | None = None self.history_settings_feedback = "" - self._history_settings_generation = 0 + self._history_settings_generations: dict[UUID, int] = {} self.event_history_result: TopicHistoryResult | None = None self.event_history_error: str | None = None self.event_history_busy = False @@ -951,8 +953,8 @@ async def query_event_history( async def load_history_settings(self, broker_id: UUID) -> None: self.history_settings_feedback = "" - self._history_settings_generation += 1 - generation = self._history_settings_generation + generation = self._history_settings_generations.get(broker_id, 0) + 1 + self._history_settings_generations[broker_id] = generation try: policy, status, usage = await asyncio.gather( asyncio.to_thread(self._runtime.get_history_retention_policy), @@ -960,19 +962,31 @@ async def load_history_settings(self, broker_id: UUID) -> None: asyncio.to_thread(self._runtime.get_history_usage, broker_id), ) except Exception: - if generation == self._history_settings_generation: + if generation == self._history_settings_generations[broker_id]: self.history_settings_broker = broker_id self.history_settings_error = "History settings could not be loaded. Reload to retry." self.history_policy = None + self._history_settings_errors[broker_id] = self.history_settings_error self.history_settings_changed.emit() return - if generation != self._history_settings_generation: + if generation != self._history_settings_generations[broker_id]: return self.history_settings_broker = broker_id self.history_policy, self.history_recording_status, self.history_usage = policy, status, usage self.history_settings_error = None + self._history_recording_statuses[broker_id] = status + self._history_settings_errors.pop(broker_id, None) self.history_settings_changed.emit() + def history_recording_status_for( + self, + broker_id: UUID, + ) -> HistoryRecordingStatus | None: + return self._history_recording_statuses.get(broker_id) + + def history_settings_error_for(self, broker_id: UUID) -> str | None: + return self._history_settings_errors.get(broker_id) + async def set_history_recording(self, broker_id: UUID, enabled: bool) -> None: """Record future receipts without changing retention limits.""" async with self._operation("history-settings"): @@ -981,6 +995,7 @@ async def set_history_recording(self, broker_id: UUID, enabled: bool) -> None: except Exception: self.history_settings_broker = broker_id self.history_settings_error = "Recording could not be changed. Reload status to retry." + self._history_settings_errors[broker_id] = self.history_settings_error self.history_settings_changed.emit() return await self.load_history_settings(broker_id) diff --git a/src/topicgate/gui/main_window.py b/src/topicgate/gui/main_window.py index 79dcf0c..387397b 100644 --- a/src/topicgate/gui/main_window.py +++ b/src/topicgate/gui/main_window.py @@ -308,10 +308,6 @@ def _set_context_panel_visible(self, visible: bool) -> None: ) def _show_snapshot(self) -> None: - if not self._advanced_mode: - self._inspector_stack.setCurrentWidget(self._health_inspector) - self._context_panel.setHidden(True) - return self._inspector_stack.setCurrentIndex(0) self._context_panel.setHidden(True) @@ -604,8 +600,8 @@ def _request_advanced_mode(self, advanced: bool) -> None: def _apply_advanced_mode(self) -> None: advanced = self._advanced_mode self._destination_tabs.blockSignals(True) - self._destination_tabs.setTabVisible(2, advanced) - self._destination_tabs.setTabEnabled(2, advanced) + self._destination_tabs.setTabVisible(2, True) + self._destination_tabs.setTabEnabled(2, True) self._destination_tabs.blockSignals(False) if not advanced and self._inspector_stack.currentIndex() == 0: self._show_snapshot() @@ -1215,7 +1211,10 @@ async def _switch_broker_profile( await self._view_model.activate_broker_profile(profile_id, mqtt_config) finally: if self._view_model.active_broker_profile.id != previous_profile_id: - self._show_snapshot() + if self._advanced_mode: + self._show_snapshot() + else: + self._show_health() self._render_connection_controls() def _confirm_delete_broker_profile( diff --git a/tests/test_event_history_gui.py b/tests/test_event_history_gui.py index 26a4519..3412c70 100644 --- a/tests/test_event_history_gui.py +++ b/tests/test_event_history_gui.py @@ -321,6 +321,48 @@ def read_status(broker): app.processEvents() +async def test_recording_status_loads_complete_independently_per_broker(): + app = QApplication.instance() or QApplication([]) + runtime = runtime_for(FakeGuiRepository()) + vm = MainViewModel(runtime) + workspace = EventHistoryWidget(vm) + other = EventHistoryWidget(vm) + first = workspace.broker.currentData() + other.broker.setCurrentIndex(1) + second = other.broker.currentData() + started, release = Event(), Event() + + def read_status(broker): + if broker == first: + started.set() + assert release.wait(5) + return HistoryRecordingStatus(broker, enabled=broker == first) + + runtime.get_history_recording_status = Mock(side_effect=read_status) + runtime.get_history_retention_policy = Mock(return_value=HistoryRetentionPolicy()) + runtime.get_history_usage = Mock( + side_effect=lambda broker: HistoryUsage(broker, 0, 0, None, None, 0) + ) + task = asyncio.create_task(vm.load_history_settings(first)) + try: + assert await asyncio.to_thread(started.wait, 2) + await vm.load_history_settings(second) + assert not workspace._recording_loading + assert not workspace.enable_recording.isEnabled() + assert other.enable_recording.isEnabled() + release.set() + await task + assert workspace.enable_recording.isEnabled() + assert workspace.enable_recording.isChecked() + assert not other.enable_recording.isChecked() + finally: + release.set() + await task + workspace.close() + other.close() + app.processEvents() + + def test_history_mode_switch_preserves_page_selection_and_payload(): app = QApplication.instance() or QApplication([]) vm = MainViewModel(runtime_for(FakeGuiRepository())) diff --git a/tests/test_gui.py b/tests/test_gui.py index 165795d..ab40732 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -3312,7 +3312,7 @@ def test_global_advanced_mode_defaults_and_persists(tmp_path, saved_mode) -> Non window.close() restored = MainWindow(MainViewModel(runtime_for(FakeGuiRepository())), settings) assert restored._advanced_mode_action.isChecked() is expected - assert restored._destination_tabs.isTabVisible(2) is expected + assert restored._destination_tabs.isTabVisible(2) restored.close() application.processEvents() @@ -3329,8 +3329,8 @@ def test_global_mode_visibility_fallback_and_stale_routes() -> None: for advanced in (False, True, False): window._advanced_mode_action.setChecked(advanced) assert window._advanced_mode is advanced - assert window._destination_tabs.isTabVisible(2) is advanced - assert window._destination_tabs.isTabEnabled(2) is advanced + assert window._destination_tabs.isTabVisible(2) + assert window._destination_tabs.isTabEnabled(2) for action in (window._stored_observations_action, window._console_action, window._diagnostic_profiles_action): assert action.isVisible() is advanced @@ -3363,7 +3363,8 @@ def test_global_mode_visibility_fallback_and_stale_routes() -> None: window._diagnostic_profiles_action.trigger() window._console_action.trigger() window._navigate(2) - assert window._inspector_stack.currentWidget() is window._health_inspector + assert window._inspector_stack.currentWidget() is not window._health_inspector + assert window._inspector_stack.currentIndex() == 0 assert window._stored_observations_dialog is None assert window._diagnostic_profile_editor_window is None assert not window._log_dock.isVisible() diff --git a/uv.lock b/uv.lock index 1983dab..8c10e3a 100644 --- a/uv.lock +++ b/uv.lock @@ -382,6 +382,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + [[package]] name = "fastmcp" version = "3.4.7" @@ -1243,6 +1252,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, ] +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.2" @@ -1641,6 +1663,7 @@ test = [ { name = "keyrings-alt" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-xdist" }, ] [package.metadata] @@ -1655,6 +1678,7 @@ requires-dist = [ { name = "pyside6" }, { name = "pytest", marker = "extra == 'test'" }, { name = "pytest-asyncio", marker = "extra == 'test'" }, + { name = "pytest-xdist", marker = "extra == 'test'" }, { name = "qasync" }, { name = "sqlalchemy" }, ]