diff --git a/images/lego2.jpg b/images/lego2.jpg deleted file mode 100644 index 2bc82f9..0000000 Binary files a/images/lego2.jpg and /dev/null differ diff --git a/images/lego2.jpg.license b/images/lego2.jpg.license deleted file mode 100644 index be1729c..0000000 --- a/images/lego2.jpg.license +++ /dev/null @@ -1,3 +0,0 @@ -SPDX-FileCopyrightText: 2026 'CERN' - -SPDX-License-Identifier: LGPL-2.1-or-later diff --git a/images/pypts_quick_run.gif b/images/pypts_quick_run.gif index 300c667..a5b3c9f 100644 Binary files a/images/pypts_quick_run.gif and b/images/pypts_quick_run.gif differ diff --git a/src/pypts/event_proxy.py b/src/pypts/event_proxy.py index ce3be0e..ae2b813 100644 --- a/src/pypts/event_proxy.py +++ b/src/pypts/event_proxy.py @@ -2,17 +2,23 @@ # # SPDX-License-Identifier: LGPL-2.1-or-later -# src/pypts/event_proxy.py -from pypts.utils import get_project_root, find_resource_path, get_step_result_colors, resolve_package_resource import logging -from PySide6.QtCore import QObject, Signal, Slot -from queue import SimpleQueue from contextlib import suppress +from queue import SimpleQueue + +from PySide6.QtCore import QObject, Signal, Slot + from pypts import recipe -import uuid, queue +from pypts.utils import ( + find_resource_path, + get_project_root, + get_step_result_colors, + resolve_package_resource, +) logger = logging.getLogger(__name__) + class RecipeEventProxy(QObject): """Proxies events from the recipe execution thread's event queue to Qt signals for the GUI thread. @@ -73,19 +79,17 @@ def _process_event(self, event): event_dict = {} if event_name == "post_run_step": step_result: recipe.StepResult = event_data[0] # event_data is a tuple - # Ignore events from SequenceStep itself as they aren't in the table - if not isinstance(step_result.step, recipe.SequenceStep): - result_type = step_result.get_result() - - background_color, text_color = get_step_result_colors(result_type, recipe.ResultType) + step = step_result.step + result_type = step_result.get_result() - event_dict = { - "step_uuid": step_result.step.id, - "status_text": str(result_type), - "status_color": background_color, - "text_color": text_color, - "step_result": step_result, - } + background_color, text_color = get_step_result_colors(result_type, recipe.ResultType) + event_dict = { + "step_uuid": step.id, + "status_text": str(result_type), + "status_color": background_color, + "text_color": text_color, + "step_result": step_result, + } elif event_name == "pre_run_recipe": event_dict = { "recipe_name": event_data[0], diff --git a/src/pypts/gui.py b/src/pypts/gui.py index e2ee9a0..c30f302 100644 --- a/src/pypts/gui.py +++ b/src/pypts/gui.py @@ -5,32 +5,26 @@ from __future__ import annotations import logging -import os import subprocess import sys import webbrowser -from importlib.resources import files from queue import SimpleQueue -from typing import List import serial import serial.tools.list_ports -from PySide6.QtCore import QAbstractItemModel, QEventLoop, QModelIndex, QObject, QSize, Qt, QThread, QTimer, Signal -from PySide6.QtGui import QAction, QColor, QFont +from PySide6.QtCore import QEventLoop, QObject, Qt, QThread, QTimer, Signal +from PySide6.QtGui import QAction from PySide6.QtWidgets import ( - QAbstractItemView, QApplication, QComboBox, QDialog, QDialogButtonBox, QFileDialog, - QHBoxLayout, QInputDialog, QLabel, QMainWindow, QMenuBar, - QMessageBox, - QPlainTextEdit, + QProgressBar, QPushButton, QSplitter, QStackedWidget, @@ -50,8 +44,7 @@ from pypts.gui_components.styles import CERN_BLUE, MTA_BLUE, get_stylesheet from pypts.gui_components.toolbar import PtsToolBar from pypts.gui_theme import detect_system_dark_mode, install_system_theme_sync -from pypts.utils import WAIT_FOR_TERMINATION, find_resource_path, get_project_root, get_step_result_colors, resolve_package_resource - +from pypts.utils import WAIT_FOR_TERMINATION, resolve_package_resource logger = logging.getLogger(__name__) @@ -113,6 +106,8 @@ def __init__(self, *args, **kwargs): self._current_recipe_name = None self._current_recipe_description = None self._dark_mode = detect_system_dark_mode() + self._progress_total = 0 + self._progress_completed = 0 self.cern_logo = load_cern_logo_pixmap() @@ -250,6 +245,11 @@ def _build_central(self): self.message_box = self._interaction_panel.message_label right_layout.addWidget(self._interaction_panel, stretch=1) + self.progress_bar = QProgressBar() + self.progress_bar.setTextVisible(True) + right_layout.addWidget(self.progress_bar) + self._set_progress_total(0) + log_label = QLabel("Log Output") log_label.setObjectName("sectionLabel") right_layout.addWidget(log_label) @@ -287,9 +287,9 @@ def _set_dark_mode(self, dark: bool): def _update_tab_style(self): bg = "#2b2b2b" if self._dark_mode else CERN_BLUE if self._paused: - hover = f"QTabBar::tab:hover:!selected {{ background:rgba(255,255,255,0.10); }}" + hover = "QTabBar::tab:hover:!selected { background:rgba(255,255,255,0.10); }" else: - hover = f"QTabBar::tab:hover:!selected {{ background:transparent; }}" + hover = "QTabBar::tab:hover:!selected { background:transparent; }" self.screen_tab_bar.setStyleSheet( f"QTabBar {{ background:{bg}; }}" f"QTabBar::tab {{ background:transparent; color:#B3CFF0; padding:6px 16px; border:none;" @@ -505,14 +505,40 @@ def reset_gui(self): self.message_box.clear() self._interaction_panel.set_idle() self.clear_interaction_buttons() + self._set_progress_total(0) self._switch_screen(SCREEN_IDLE) def load_recipe(self): recipe_to_run = recipe.Recipe(self.recipe_file) self.recipe_to_run = recipe_to_run + self._set_progress_total(recipe_to_run.total_steps) sequence = recipe_to_run.sequences[recipe_to_run.main_sequence] self.update_sequence({"sequence": sequence}) + def _set_progress_total(self, total: int): + self._progress_total = max(0, total) + self._progress_completed = 0 + if self._progress_total == 0: + # A 0..0 range is Qt's indeterminate/busy state, so retain a + # determinate range and supply the zero-total text explicitly. + self.progress_bar.setRange(0, 1) + self.progress_bar.setValue(0) + self.progress_bar.setFormat("0 / 0 steps (0%)") + return + + self.progress_bar.setRange(0, self._progress_total) + self.progress_bar.setValue(0) + self.progress_bar.setFormat("%v / %m steps (%p%)") + + def _advance_progress(self, units: int): + if self._progress_total <= 0 or units <= 0: + return + self._progress_completed = min( + self._progress_total, + self._progress_completed + units, + ) + self.progress_bar.setValue(self._progress_completed) + def add_interaction_button(self, label, value=None): self._interaction_panel.add_button(label, value or label) @@ -610,9 +636,18 @@ def update_sequence(self, event_dict): self.statusBar().showMessage("Recipe loaded and ready to start") def update_step_result(self, step_status_vm: dict): + self._advance_progress(1) + step_result = step_status_vm.get("step_result") - if step_result is not None: + is_sequence_container = ( + step_result is not None + and isinstance(step_result.step, recipe.SequenceStep) + ) + if step_result is not None and not is_sequence_container: self._partial_results.append(step_result) + if is_sequence_container: + return + updated = self.step_list.update_step_status( str(step_status_vm["step_uuid"]), step_status_vm["status_text"], @@ -626,7 +661,7 @@ def update_step_result(self, step_status_vm: dict): ) def show_results(self, event_dict): - results: List[recipe.StepResult] = event_dict["results"] + results: list[recipe.StepResult] = event_dict["results"] self._partial_results.clear() self._results_panel.set_results(results) self.running = False diff --git a/src/pypts/recipe.py b/src/pypts/recipe.py index e6fc6e7..0a11903 100644 --- a/src/pypts/recipe.py +++ b/src/pypts/recipe.py @@ -302,6 +302,39 @@ def _load_definition(self, definition: RecipeDefinition, source_name: str) -> No self.test_package = header.test_package logger.info("Loaded recipe %s version %s.", self.name, self.version) + @property + def total_steps(self) -> int: + """Count the result rows this recipe is expected to produce.""" + active_sequences = set() + + def count_sequence(name): + if name in active_sequences: + raise ValueError(f"Recursive sequence reference detected: {name}") + active_sequences.add(name) + sequence = self.sequences[name] + try: + return sum(count_step(step) for step in [*sequence.steps, *sequence.teardown_steps]) + finally: + active_sequences.remove(name) + + def count_step(step): + if step.is_skipped(): + return 1 + if isinstance(step, IndexedStep): + indexed_values = [ + config["value"] + for config in step.template_step.input_mapping.values() + if config.get("indexed", False) + ] + repeats = min(map(len, indexed_values), default=1) + return 1 + repeats * count_step(step.template_step) + if isinstance(step, SequenceStep): + return 1 + count_sequence(step.sequence_config["name"]) + return 1 + + # Recipe.run creates one top-level SequenceStep result. + return 1 + count_sequence(self.main_sequence) + def run(self, runtime: Runtime, sequence_name: str | None = None): """Executes the main sequence of the recipe. diff --git a/src/pypts/recipes/simple_recipe.yml b/src/pypts/recipes/simple_recipe.yml index 057152c..5427e9b 100644 --- a/src/pypts/recipes/simple_recipe.yml +++ b/src/pypts/recipes/simple_recipe.yml @@ -37,7 +37,7 @@ steps: ' image_path: type: direct - value: lego2.jpg + value: Front_Panel_Test.jpg options: type: direct value: diff --git a/tests/unit_tests/test_a_gui.py b/tests/unit_tests/test_a_gui.py index 285f46c..c71d0a9 100644 --- a/tests/unit_tests/test_a_gui.py +++ b/tests/unit_tests/test_a_gui.py @@ -7,15 +7,16 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") -import pytest from unittest.mock import Mock, patch + +import pytest from PySide6.QtCore import Qt from pypts import gui, recipe -from pypts.recipe_language import Sequence as SequenceDefinition -from pypts.startup import create_and_start_gui from pypts.gui_components import interaction_panel from pypts.gui_components.results_panel import StepResultModel +from pypts.recipe_language import Sequence as SequenceDefinition +from pypts.startup import create_and_start_gui @pytest.fixture @@ -267,3 +268,33 @@ def test_update_sequence_shows_ready_to_start_when_loaded_not_running(main_windo assert main_window._screen_idx == gui.SCREEN_IDLE assert main_window.recipe_label.text() == "Loaded Test Sequence\nReady to start" assert main_window.statusBar().currentMessage() == "Recipe loaded and ready to start" + + +def test_progress_bar_is_below_interaction_and_above_log(main_window): + right_layout = main_window.log_text_box.parentWidget().layout() + + assert right_layout.indexOf(main_window.progress_bar) == right_layout.indexOf(main_window._interaction_panel) + 1 + assert right_layout.indexOf(main_window.progress_bar) < right_layout.indexOf(main_window.log_text_box) + assert main_window.progress_bar.format() == "0 / 0 steps (0%)" + + main_window._set_progress_total(4) + main_window._advance_progress(1) + + assert main_window.progress_bar.maximum() == 4 + assert main_window.progress_bar.value() == 1 + assert main_window.progress_bar.format() == "%v / %m steps (%p%)" + + +def test_progress_is_clamped_preserved_on_idle_and_reset_for_new_run(main_window): + main_window._set_progress_total(3) + main_window._advance_progress(2) + main_window._switch_screen(gui.SCREEN_IDLE) + + assert main_window.progress_bar.value() == 2 + + main_window._advance_progress(5) + assert main_window.progress_bar.value() == 3 + + main_window.reset_gui() + assert main_window.progress_bar.value() == 0 + assert main_window.progress_bar.format() == "0 / 0 steps (0%)" diff --git a/tests/unit_tests/test_event_proxy.py b/tests/unit_tests/test_event_proxy.py index df82e96..1bc77ce 100644 --- a/tests/unit_tests/test_event_proxy.py +++ b/tests/unit_tests/test_event_proxy.py @@ -5,14 +5,15 @@ """Tests for RecipeEventProxy — covers all signal types, user interaction, unsupported events, and stop/sentinel behaviour.""" -import pytest import uuid -from unittest.mock import MagicMock, patch from queue import SimpleQueue +from unittest.mock import MagicMock, patch + +import pytest from PySide6.QtCore import QCoreApplication -from pypts.event_proxy import RecipeEventProxy -from pypts import recipe +from pypts import recipe +from pypts.event_proxy import RecipeEventProxy # ============================================================ # Fixtures @@ -124,15 +125,22 @@ def test_emits_for_non_sequence_step(self, proxy, event_q): assert emitted["status_text"] == "PASS" assert "status_color" in emitted - def test_ignores_sequence_step(self, proxy, event_q): - """Verify that 'post_run_step' does NOT emit for SequenceStep instances.""" + def test_sequence_step_emits_progress_only_event(self, proxy, event_q): + """Sequence containers reach progress accounting but not the step table.""" mock_step_result = MagicMock() - mock_step_result.step = MagicMock(spec=recipe.SequenceStep) + mock_step_result.step = recipe.SequenceStep( + sequence={"type": "internal", "name": "Child"}, + step_name="Run child", + input_mapping={}, + output_mapping={}, + ) + mock_step_result.get_result.return_value = recipe.ResultType.PASS event_q.put(("post_run_step", (mock_step_result,))) proxy.run_once() - proxy.post_run_step_signal.emit.assert_not_called() + emitted = proxy.post_run_step_signal.emit.call_args[0][0] + assert emitted["step_result"] is mock_step_result # ============================================================ diff --git a/tests/unit_tests/test_recipe.py b/tests/unit_tests/test_recipe.py index b14981e..4f56e47 100644 --- a/tests/unit_tests/test_recipe.py +++ b/tests/unit_tests/test_recipe.py @@ -141,6 +141,7 @@ def test_recipe_from_definition_constructs_typed_runtime_state_without_reparse() assert recipe.recipe_file_name == "fixture.yml" assert list(recipe.sequences) == ["Main", "Sub"] assert isinstance(recipe.sequences["Main"].steps[1], IndexedStep) + assert recipe.total_steps == 8 def test_recipe_path_requires_v2_and_raises_structured_error(tmp_path): @@ -228,3 +229,11 @@ def test_recipe_run_constructs_top_level_sequence_step_directly(monkeypatch): results = recipe.run(active_runtime) assert results assert active_runtime.recipe_name == recipe.name + + pending = list(results) + result_count = 0 + while pending: + result = pending.pop() + result_count += 1 + pending.extend(result.subresults) + assert result_count == recipe.total_steps