Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file removed images/lego2.jpg
Binary file not shown.
3 changes: 0 additions & 3 deletions images/lego2.jpg.license

This file was deleted.

Binary file modified images/pypts_quick_run.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
38 changes: 21 additions & 17 deletions src/pypts/event_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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],
Expand Down
65 changes: 50 additions & 15 deletions src/pypts/gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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__)

Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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;"
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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"],
Expand All @@ -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
Expand Down
33 changes: 33 additions & 0 deletions src/pypts/recipe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion src/pypts/recipes/simple_recipe.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ steps:
'
image_path:
type: direct
value: lego2.jpg
value: Front_Panel_Test.jpg
options:
type: direct
value:
Expand Down
37 changes: 34 additions & 3 deletions tests/unit_tests/test_a_gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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%)"
24 changes: 16 additions & 8 deletions tests/unit_tests/test_event_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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


# ============================================================
Expand Down
9 changes: 9 additions & 0 deletions tests/unit_tests/test_recipe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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