diff --git a/bec_widgets/widgets/progress/bec_progressbar/bec_progressbar.py b/bec_widgets/widgets/progress/bec_progressbar/bec_progressbar.py index 15ea7357..4f75c4d1 100644 --- a/bec_widgets/widgets/progress/bec_progressbar/bec_progressbar.py +++ b/bec_widgets/widgets/progress/bec_progressbar/bec_progressbar.py @@ -14,6 +14,7 @@ from bec_widgets.utils.error_popups import SafeProperty, SafeSlot class ProgressState(Enum): NORMAL = "normal" PAUSED = "paused" + WARNING = "warning" INTERRUPTED = "interrupted" COMPLETED = "completed" @@ -84,7 +85,8 @@ class BECProgressBar(BECWidget, QWidget): self._state_colors = { ProgressState.NORMAL: accent_colors.default, - ProgressState.PAUSED: accent_colors.warning, + ProgressState.PAUSED: accent_colors.highlight, + ProgressState.WARNING: accent_colors.warning, ProgressState.INTERRUPTED: accent_colors.emergency, ProgressState.COMPLETED: accent_colors.success, } @@ -128,7 +130,8 @@ class BECProgressBar(BECWidget, QWidget): accent_colors = get_accent_colors() self._state_colors = { ProgressState.NORMAL: accent_colors.default, - ProgressState.PAUSED: accent_colors.warning, + ProgressState.PAUSED: accent_colors.highlight, + ProgressState.WARNING: accent_colors.warning, ProgressState.INTERRUPTED: accent_colors.emergency, ProgressState.COMPLETED: accent_colors.success, } @@ -176,7 +179,11 @@ class BECProgressBar(BECWidget, QWidget): if self._enable_dynamic_stylesheet and self._value < previous_value: self._chunk_radius = None # Update state automatically unless paused or interrupted - if self._state not in (ProgressState.PAUSED, ProgressState.INTERRUPTED): + if self._state not in ( + ProgressState.PAUSED, + ProgressState.WARNING, + ProgressState.INTERRUPTED, + ): self._state = ( ProgressState.COMPLETED if self._user_value >= self._user_maximum @@ -358,12 +365,15 @@ class BECProgressBar(BECWidget, QWidget): if chunk_radius != self._chunk_radius: self._chunk_radius = chunk_radius self._setup_style_sheet(chunk_radius=chunk_radius) + self._apply_state_palette() def _apply_state_style(self) -> None: if self._chunk_radius is None: self._chunk_radius = self._current_chunk_radius() self._setup_style_sheet(chunk_radius=self._chunk_radius) + self._apply_state_palette() + def _apply_state_palette(self) -> None: color = self._state_colors[self._current_visual_state()] palette = self.progressbar.palette() palette.setColor(QPalette.ColorRole.Highlight, color) @@ -393,7 +403,7 @@ class BECProgressBar(BECWidget, QWidget): return min(target_radius, max(1, int(fill_width / 2))) def _current_visual_state(self) -> ProgressState: - if self._state in (ProgressState.PAUSED, ProgressState.INTERRUPTED): + if self._state in (ProgressState.PAUSED, ProgressState.WARNING, ProgressState.INTERRUPTED): return self._state if self._state == ProgressState.COMPLETED or self._value >= self._maximum: return ProgressState.COMPLETED diff --git a/bec_widgets/widgets/progress/progress_backend.py b/bec_widgets/widgets/progress/progress_backend.py index 23d6d14a..dc9f01f2 100644 --- a/bec_widgets/widgets/progress/progress_backend.py +++ b/bec_widgets/widgets/progress/progress_backend.py @@ -8,6 +8,8 @@ import numpy as np from bec_lib.endpoints import MessageEndpoints from qtpy.QtCore import QObject, QTimer, Signal +from bec_widgets.utils.error_popups import SafeSlot + @dataclass(frozen=True) class ProgressSnapshot: @@ -130,6 +132,8 @@ class BECProgressTracker(QObject): self.scan_number: int | None = None self._active_scan_id: str | None = None self._active_rid: str | None = None + self._last_reset_scan_id: str | None = None + self._last_progress_scan_id: str | None = None def start(self) -> None: if self._connected: @@ -137,6 +141,9 @@ class BECProgressTracker(QObject): self.bec_dispatcher.connect_slot( self.process_progress_message, MessageEndpoints.scan_progress() ) + self.bec_dispatcher.connect_slot( + self.process_scan_status_message, MessageEndpoints.scan_status() + ) self._connected = True def _start_task(self, scan_id: str | None, rid: str | None = None) -> None: @@ -183,6 +190,7 @@ class BECProgressTracker(QObject): ) ) + @SafeSlot(dict, dict) def process_progress_message( self, msg_content: dict, metadata: dict ) -> ProgressSnapshot | None: @@ -197,6 +205,8 @@ class BECProgressTracker(QObject): scan_number = metadata.get("scan_number") if scan_number is not None: self.scan_number = scan_number + if scan_id is not None: + self._last_progress_scan_id = scan_id is_new_scan = False previous_scan_id = self._active_scan_id previous_rid = self._active_rid @@ -235,10 +245,44 @@ class BECProgressTracker(QObject): self.clear_task() return snapshot + @SafeSlot(dict, dict) + def process_scan_status_message( + self, msg_content: dict, metadata: dict + ) -> ProgressSnapshot | None: + if msg_content.get("status") != "open": + return None + scan_id = msg_content.get("scan_id") or metadata.get("scan_id") or metadata.get("RID") + if scan_id is None or scan_id == self._last_reset_scan_id: + return None + if scan_id == self._last_progress_scan_id: + self._last_reset_scan_id = scan_id + return None + + self.clear_task(emit_finished=False) + self._last_reset_scan_id = scan_id + self.scan_number = msg_content.get("scan_number") + snapshot = ProgressSnapshot( + value=0, + max_value=100, + done=False, + status="open", + scan_id=scan_id, + scan_number=self.scan_number, + rid=metadata.get("RID"), + is_new_scan=True, + ) + self.progress_updated.emit(snapshot) + return snapshot + def cleanup(self) -> None: self.clear_task(emit_finished=False) if self._connected: self.bec_dispatcher.disconnect_slot( self.process_progress_message, MessageEndpoints.scan_progress() ) + self.bec_dispatcher.disconnect_slot( + self.process_scan_status_message, MessageEndpoints.scan_status() + ) self._connected = False + self._last_reset_scan_id = None + self._last_progress_scan_id = None diff --git a/bec_widgets/widgets/progress/scan_progressbar/scan_progressbar.py b/bec_widgets/widgets/progress/scan_progressbar/scan_progressbar.py index adeb30ee..bfcd9250 100644 --- a/bec_widgets/widgets/progress/scan_progressbar/scan_progressbar.py +++ b/bec_widgets/widgets/progress/scan_progressbar/scan_progressbar.py @@ -17,10 +17,10 @@ logger = bec_logger.logger BEC_STATUS_TO_PROGRESS_STATE = { "open": ProgressState.NORMAL, "paused": ProgressState.PAUSED, - "aborted": ProgressState.INTERRUPTED, - "halted": ProgressState.PAUSED, + "aborted": ProgressState.WARNING, + "halted": ProgressState.INTERRUPTED, "closed": ProgressState.COMPLETED, - "user_completed": ProgressState.PAUSED, + "user_completed": ProgressState.COMPLETED, } @@ -87,12 +87,15 @@ class ScanProgressBar(BECWidget, QWidget): def _on_progress_snapshot(self, snapshot: ProgressSnapshot): self.update_labels() + if snapshot.is_new_scan and self.progress_tracker.task is None: + self.ui.elapsed_time_label.setText("00:00:00") + self.ui.remaining_time_label.setText("00:00:00") self.update_source_label() self.progressbar.set_maximum(snapshot.max_value) + self.progressbar.set_value(snapshot.value) self.progressbar.state = BEC_STATUS_TO_PROGRESS_STATE.get( snapshot.status.lower(), ProgressState.NORMAL ) - self.progressbar.set_value(snapshot.value) @SafeProperty(bool) def show_elapsed_time(self): diff --git a/tests/unit_tests/test_bec_progressbar.py b/tests/unit_tests/test_bec_progressbar.py index ec9253f8..eb0d338b 100644 --- a/tests/unit_tests/test_bec_progressbar.py +++ b/tests/unit_tests/test_bec_progressbar.py @@ -172,3 +172,43 @@ def test_progressbar_state_setter(progressbar): """Setting .state reflects internally.""" progressbar.state = ProgressState.PAUSED assert progressbar.state is ProgressState.PAUSED + + +def test_progressbar_warning_state_has_own_color_and_persists_on_value_update(progressbar): + assert ( + progressbar._state_colors[ProgressState.PAUSED] + != progressbar._state_colors[ProgressState.WARNING] + ) + assert ( + progressbar._state_colors[ProgressState.WARNING] + != progressbar._state_colors[ProgressState.INTERRUPTED] + ) + + progressbar.state = ProgressState.WARNING + progressbar.set_value(50) + + assert progressbar.state is ProgressState.WARNING + assert ( + progressbar.progressbar.palette().color(QPalette.ColorRole.Highlight) + == progressbar._state_colors[ProgressState.WARNING] + ) + + +def test_progressbar_warning_state_has_own_color_and_persists_on_value_update(progressbar): + assert ( + progressbar._state_colors[ProgressState.PAUSED] + != progressbar._state_colors[ProgressState.WARNING] + ) + assert ( + progressbar._state_colors[ProgressState.WARNING] + != progressbar._state_colors[ProgressState.INTERRUPTED] + ) + + progressbar.state = ProgressState.WARNING + progressbar.set_value(50) + + assert progressbar.state is ProgressState.WARNING + assert ( + progressbar.progressbar.palette().color(QPalette.ColorRole.Highlight) + == progressbar._state_colors[ProgressState.WARNING] + ) diff --git a/tests/unit_tests/test_progress_backend.py b/tests/unit_tests/test_progress_backend.py index 1f839125..ff7cb2a0 100644 --- a/tests/unit_tests/test_progress_backend.py +++ b/tests/unit_tests/test_progress_backend.py @@ -16,9 +16,10 @@ def test_tracker_subscribes_to_scan_progress_immediately(): tracker.start() - dispatcher.connect_slot.assert_called_once_with( - tracker.process_progress_message, MessageEndpoints.scan_progress() - ) + assert dispatcher.connect_slot.call_args_list == [ + mock.call(tracker.process_progress_message, MessageEndpoints.scan_progress()), + mock.call(tracker.process_scan_status_message, MessageEndpoints.scan_status()), + ] tracker.cleanup() @@ -49,12 +50,98 @@ def test_tracker_switches_sources_idempotently(): tracker.start() tracker.start() - assert dispatcher.connect_slot.call_count == 1 + assert dispatcher.connect_slot.call_count == 2 assert dispatcher.disconnect_slot.call_count == 0 tracker.cleanup() +def test_tracker_resets_progress_on_new_open_scan_status(): + dispatcher = _dispatcher() + tracker = BECProgressTracker(dispatcher) + snapshots = [] + tracker.progress_updated.connect(snapshots.append) + tracker.start() + + snapshot = tracker.process_scan_status_message( + {"scan_id": "scan-1", "scan_number": 7, "status": "open"}, {} + ) + + assert snapshot is not None + assert snapshot.value == 0 + assert snapshot.max_value == 100 + assert snapshot.status == "open" + assert snapshot.scan_id == "scan-1" + assert snapshot.scan_number == 7 + assert snapshot.is_new_scan is True + assert tracker.task is None + assert tracker.scan_number == 7 + assert snapshots[-1] == snapshot + + tracker.cleanup() + + +def test_tracker_ignores_duplicate_open_scan_status(): + dispatcher = _dispatcher() + tracker = BECProgressTracker(dispatcher) + snapshots = [] + tracker.progress_updated.connect(snapshots.append) + tracker.start() + + tracker.process_scan_status_message({"scan_id": "scan-1", "status": "open"}, {}) + tracker.process_scan_status_message({"scan_id": "scan-1", "status": "open"}, {}) + + assert len(snapshots) == 1 + + tracker.cleanup() + + +def test_tracker_ignores_open_scan_status_after_progress_for_same_scan(): + dispatcher = _dispatcher() + tracker = BECProgressTracker(dispatcher) + snapshots = [] + tracker.progress_updated.connect(snapshots.append) + tracker.start() + + tracker.process_progress_message( + {"value": 3, "max_value": 10}, {"scan_id": "scan-1", "RID": "rid-1", "status": "open"} + ) + snapshot = tracker.process_scan_status_message( + {"scan_id": "scan-1", "scan_number": 7, "status": "open"}, {} + ) + + assert snapshot is None + assert len(snapshots) == 1 + assert snapshots[-1].value == 3 + assert tracker.task is not None + assert tracker.task.value == 3 + + tracker.cleanup() + + +def test_tracker_ignores_open_scan_status_after_done_progress_for_same_scan(): + dispatcher = _dispatcher() + tracker = BECProgressTracker(dispatcher) + snapshots = [] + tracker.progress_updated.connect(snapshots.append) + tracker.start() + + tracker.process_progress_message( + {"value": 10, "max_value": 10, "done": True}, + {"scan_id": "scan-1", "RID": "rid-1", "status": "closed"}, + ) + snapshot = tracker.process_scan_status_message( + {"scan_id": "scan-1", "scan_number": 7, "status": "open"}, {} + ) + + assert snapshot is None + assert len(snapshots) == 1 + assert snapshots[-1].value == 10 + assert tracker.task is None + + tracker.cleanup() + + def test_tracker_marks_new_scan_only_when_rid_changes(): dispatcher = _dispatcher() tracker = BECProgressTracker(dispatcher) diff --git a/tests/unit_tests/test_ring_progress_bar_ring.py b/tests/unit_tests/test_ring_progress_bar_ring.py index 743ee211..37661d83 100644 --- a/tests/unit_tests/test_ring_progress_bar_ring.py +++ b/tests/unit_tests/test_ring_progress_bar_ring.py @@ -1,6 +1,6 @@ # pylint: disable=missing-function-docstring, missing-module-docstring -from unittest.mock import MagicMock +from unittest.mock import MagicMock, call import pytest from bec_lib.endpoints import MessageEndpoints @@ -77,11 +77,14 @@ def test_set_update_to_scan(ring_widget): ring_widget.set_update("scan") assert ring_widget.config.mode == "scan" - # Verify that connect_slot was called - ring_widget.bec_dispatcher.connect_slot.assert_called_once() - call_args = ring_widget.bec_dispatcher.connect_slot.call_args - assert call_args[0][0] == ring_widget.progress_tracker.process_progress_message - assert "scan_progress" in str(call_args[0][1]) + assert ring_widget.bec_dispatcher.connect_slot.call_args_list == [ + call( + ring_widget.progress_tracker.process_progress_message, MessageEndpoints.scan_progress() + ), + call( + ring_widget.progress_tracker.process_scan_status_message, MessageEndpoints.scan_status() + ), + ] def test_set_update_from_scan_to_manual(ring_widget): @@ -98,10 +101,14 @@ def test_set_update_from_scan_to_manual(ring_widget): assert ring_widget.config.mode == "manual" assert ring_widget.registered_slot is None - ring_widget.bec_dispatcher.disconnect_slot.assert_called_once() - call_args = ring_widget.bec_dispatcher.disconnect_slot.call_args - assert call_args[0][0] == ring_widget.progress_tracker.process_progress_message - assert call_args[0][1] == MessageEndpoints.scan_progress() + assert ring_widget.bec_dispatcher.disconnect_slot.call_args_list == [ + call( + ring_widget.progress_tracker.process_progress_message, MessageEndpoints.scan_progress() + ), + call( + ring_widget.progress_tracker.process_scan_status_message, MessageEndpoints.scan_status() + ), + ] def test_set_update_to_device(ring_widget_with_device): @@ -594,6 +601,19 @@ def test_scan_progress_updates_value(ring_widget): assert ring_widget.config.value == 42 +def test_scan_status_open_resets_scan_progress_value(ring_widget): + ring_widget.set_min_max_values(0, 200) + ring_widget.set_value(80) + + ring_widget.progress_tracker.process_scan_status_message( + {"scan_id": "scan-1", "scan_number": 7, "status": "open"}, {} + ) + + assert ring_widget.config.min_value == 0 + assert ring_widget.config.max_value == 100 + assert ring_widget.config.value == 0 + + def test_scan_progress_updates_min_max_on_new_rid(ring_widget): msg = {"value": 50, "max_value": 200} meta = {"RID": "new_rid"} diff --git a/tests/unit_tests/test_scan_progress_bar.py b/tests/unit_tests/test_scan_progress_bar.py index 92ed0f72..65a18fad 100644 --- a/tests/unit_tests/test_scan_progress_bar.py +++ b/tests/unit_tests/test_scan_progress_bar.py @@ -2,6 +2,7 @@ from unittest import mock import numpy as np import pytest +from bec_lib.endpoints import MessageEndpoints from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.widgets.progress.bec_progressbar.bec_progressbar import ( @@ -115,15 +116,57 @@ def test_progress_update(qtbot, scan_progressbar): assert bar.state is ProgressState.NORMAL +def test_scan_status_open_resets_progress_before_first_progress_update(scan_progressbar): + scan_progressbar.progressbar.set_maximum(50) + scan_progressbar.progressbar.set_value(25) + scan_progressbar.progressbar.state = ProgressState.INTERRUPTED + scan_progressbar.ui.elapsed_time_label.setText("00:00:12") + scan_progressbar.ui.remaining_time_label.setText("00:00:34") + + scan_progressbar.progress_tracker.process_scan_status_message( + {"scan_id": "scan-1", "scan_number": 7, "status": "open"}, {"RID": "rid-1"} + ) + + assert scan_progressbar.progressbar._user_value == 0 + assert scan_progressbar.progressbar._user_maximum == 100 + assert scan_progressbar.progressbar.state is ProgressState.NORMAL + assert scan_progressbar.ui.elapsed_time_label.text() == "00:00:00" + assert scan_progressbar.ui.remaining_time_label.text() == "00:00:00" + assert scan_progressbar.ui.source_label.text() == "Scan 7" + + +def test_scan_status_open_reset_ignores_same_scan(scan_progressbar): + scan_progressbar.progress_tracker.process_scan_status_message( + {"scan_id": "scan-1", "status": "open"}, {} + ) + scan_progressbar.progressbar.set_value(20) + + scan_progressbar.progress_tracker.process_scan_status_message( + {"scan_id": "scan-1", "status": "open"}, {} + ) + + assert scan_progressbar.progressbar._user_value == 20 + + +def test_scan_status_non_open_does_not_reset_progress(scan_progressbar): + scan_progressbar.progressbar.set_value(25) + + scan_progressbar.progress_tracker.process_scan_status_message( + {"scan_id": "scan-1", "status": "closed"}, {} + ) + + assert scan_progressbar.progressbar._user_value == 25 + + @pytest.mark.parametrize( "status, value, max_val, expected_state", [ ("open", 10, 100, ProgressState.NORMAL), ("paused", 25, 100, ProgressState.PAUSED), - ("aborted", 30, 100, ProgressState.INTERRUPTED), - ("halted", 40, 100, ProgressState.PAUSED), + ("aborted", 30, 100, ProgressState.WARNING), + ("halted", 40, 100, ProgressState.INTERRUPTED), ("closed", 100, 100, ProgressState.COMPLETED), - ("user_completed", 40, 100, ProgressState.PAUSED), + ("user_completed", 40, 100, ProgressState.COMPLETED), ("UNKNOWN", 10, 100, ProgressState.NORMAL), ], ) @@ -151,7 +194,7 @@ def test_aborted_done_scan_keeps_partial_progress(scan_progressbar): assert scan_progressbar.progressbar._user_value == 4 assert scan_progressbar.progressbar._user_maximum == 10 - assert scan_progressbar.progressbar.state is ProgressState.INTERRUPTED + assert scan_progressbar.progressbar.state is ProgressState.WARNING assert scan_progressbar.progress_tracker.task is None @@ -197,7 +240,7 @@ def test_cleanup_disconnects_active_scan_subscription(scan_progressbar, monkeypa ): ScanProgressBar.cleanup(scan_progressbar) - assert len(disconnect_calls) == 1 + assert disconnect_calls == [MessageEndpoints.scan_progress(), MessageEndpoints.scan_status()] assert scan_progressbar.progress_tracker._connected is False close_mock.assert_not_called() delete_later_mock.assert_not_called()