diff --git a/pyproject.toml b/pyproject.toml
index 1862909..160960a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "rascal2"
-version = "0.0.0"
+dynamic = ["version"]
dependencies = [
"RATapi",
"PyQt6",
@@ -17,6 +17,9 @@ license = {file = "LICENSE"}
Documentation = "https://rascalsoftware.github.io/RAT/master/index.html"
Repository = "https://github.com/RascalSoftware/RasCAL-2"
+[tool.setuptools.dynamic]
+version = {attr = "rascal2.RASCAL2_VERSION"}
+
[tool.ruff]
line-length = 120
diff --git a/rascal2/__init__.py b/rascal2/__init__.py
index e69de29..91a1cf5 100644
--- a/rascal2/__init__.py
+++ b/rascal2/__init__.py
@@ -0,0 +1 @@
+RASCAL2_VERSION = "0.0.0"
diff --git a/rascal2/config.py b/rascal2/config.py
index 9b71fb9..9f33659 100644
--- a/rascal2/config.py
+++ b/rascal2/config.py
@@ -59,7 +59,7 @@ def setup_settings(project_path: str | PathLike) -> Settings:
return Settings()
-def setup_logging(log_path: str | PathLike, level: int = logging.INFO) -> logging.Logger:
+def setup_logging(log_path: str | PathLike, terminal, level: int = logging.INFO) -> logging.Logger:
"""Set up logging for the project.
The default logging path and level are defined in the settings.
@@ -68,6 +68,8 @@ def setup_logging(log_path: str | PathLike, level: int = logging.INFO) -> loggin
----------
log_path : str | PathLike
The path to where the log file will be written.
+ terminal : TerminalWidget
+ The TerminalWidget instance which acts as an IO stream.
level : int, default logging.INFO
The debug level for the logger.
@@ -77,11 +79,17 @@ def setup_logging(log_path: str | PathLike, level: int = logging.INFO) -> loggin
logger.setLevel(level)
logger.handlers.clear()
- # TODO add console print handler when console is added
- # https://github.com/RascalSoftware/RasCAL-2/issues/5
log_filehandler = logging.FileHandler(path)
+ file_formatting = logging.Formatter("%(asctime)s - %(threadName)s - %(name)s - %(levelname)s - %(message)s")
+ log_filehandler.setFormatter(file_formatting)
logger.addHandler(log_filehandler)
+ # handler that logs to terminal widget
+ log_termhandler = logging.StreamHandler(stream=terminal)
+ term_formatting = logging.Formatter("%(levelname)s - %(message)s")
+ log_termhandler.setFormatter(term_formatting)
+ logger.addHandler(log_termhandler)
+
return logger
diff --git a/rascal2/core/__init__.py b/rascal2/core/__init__.py
index 47592e4..c1b9a02 100644
--- a/rascal2/core/__init__.py
+++ b/rascal2/core/__init__.py
@@ -1,3 +1,4 @@
+from rascal2.core.runner import RATRunner
from rascal2.core.settings import Settings, get_global_settings
-__all__ = ["Settings", "get_global_settings"]
+__all__ = ["RATRunner", "get_global_settings", "Settings"]
diff --git a/rascal2/core/runner.py b/rascal2/core/runner.py
new file mode 100644
index 0000000..c7dc4f3
--- /dev/null
+++ b/rascal2/core/runner.py
@@ -0,0 +1,107 @@
+"""QObject for running RAT."""
+
+from dataclasses import dataclass
+from logging import INFO
+from multiprocessing import Process, Queue
+
+import RATapi as RAT
+from PyQt6 import QtCore
+from RATapi.utils.enums import Procedures
+
+
+class RATRunner(QtCore.QObject):
+ """Class for running RAT."""
+
+ event_received = QtCore.pyqtSignal()
+ finished = QtCore.pyqtSignal()
+ stopped = QtCore.pyqtSignal()
+
+ def __init__(self, rat_inputs, procedure: Procedures, display_on: bool):
+ super().__init__()
+ self.timer = QtCore.QTimer()
+ self.timer.setInterval(1)
+ self.timer.timeout.connect(self.check_queue)
+
+ # this queue handles both event data and results
+ self.queue = Queue()
+
+ self.process = Process(target=run, args=(self.queue, rat_inputs, procedure, display_on))
+
+ self.updated_problem = None
+ self.results = None
+ self.error = None
+ self.events = []
+
+ def start(self):
+ """Start the calculation."""
+ self.process.start()
+ self.timer.start()
+
+ def interrupt(self):
+ """Interrupt the running process."""
+ self.timer.stop()
+ self.process.kill()
+ self.stopped.emit()
+
+ def check_queue(self):
+ """Check for new data in the queue."""
+ if not self.process.is_alive():
+ self.timer.stop()
+ self.queue.put(None)
+ for item in iter(self.queue.get, None):
+ if isinstance(item, tuple):
+ self.updated_problem, self.results = item
+ self.finished.emit()
+ elif isinstance(item, Exception):
+ self.error = item
+ self.stopped.emit()
+ else: # else, assume item is an event
+ self.events.append(item)
+ self.event_received.emit()
+
+
+def run(queue, rat_inputs: tuple, procedure: str, display: bool):
+ """Run RAT and put the result into the queue.
+
+ Parameters
+ ----------
+ queue : Queue
+ The interprocess queue for the RATRunner.
+ rat_inputs : tuple
+ The C++ inputs for RAT.
+ procedure : str
+ The optimisation procedure.
+ display : bool
+ Whether to display events.
+
+ """
+ problem_definition, cells, limits, priors, cpp_controls = rat_inputs
+
+ if display:
+ RAT.events.register(RAT.events.EventTypes.Message, queue.put)
+ RAT.events.register(RAT.events.EventTypes.Progress, queue.put)
+ queue.put(LogData(INFO, "Starting RAT"))
+
+ try:
+ problem_definition, output_results, bayes_results = RAT.rat_core.RATMain(
+ problem_definition, cells, limits, cpp_controls, priors
+ )
+ results = RAT.outputs.make_results(procedure, output_results, bayes_results)
+ except Exception as err:
+ queue.put(err)
+ return
+
+ if display:
+ queue.put(LogData(INFO, "Finished RAT"))
+ RAT.events.clear()
+
+ queue.put((problem_definition, results))
+ return
+
+
+@dataclass
+class LogData:
+ """Dataclass for logging data."""
+
+ level: int
+ msg: str
diff --git a/rascal2/core/settings.py b/rascal2/core/settings.py
index 45cb7f0..5dee05c 100644
--- a/rascal2/core/settings.py
+++ b/rascal2/core/settings.py
@@ -39,6 +39,7 @@ class SettingsGroups(StrEnum):
General = "General"
Logging = "Logging"
+ Terminal = "Terminal"
Windows = "Windows"
@@ -105,11 +106,15 @@ class Settings(BaseModel, validate_assignment=True, arbitrary_types_allowed=True
# The global settings are read and written via this object using `set_global_settings`.
style: Styles = Field(default=Styles.Light, title=SettingsGroups.General, description="Style")
editor_fontsize: int = Field(default=12, title=SettingsGroups.General, description="Editor Font Size", gt=0)
- terminal_fontsize: int = Field(default=12, title=SettingsGroups.General, description="Terminal Font Size", gt=0)
log_path: str = Field(default="logs/rascal.log", title=SettingsGroups.Logging, description="Path to Log File")
log_level: LogLevels = Field(default=LogLevels.Info, title=SettingsGroups.Logging, description="Minimum Log Level")
+ clear_terminal: bool = Field(
+ default=True, title=SettingsGroups.Terminal, description="Clear Terminal when Run Starts"
+ )
+ terminal_fontsize: int = Field(default=12, title=SettingsGroups.Terminal, description="Terminal Font Size", gt=0)
+
mdi_defaults: MDIGeometries = Field(
default=None, title=SettingsGroups.Windows, description="Default Window Geometries"
)
diff --git a/rascal2/ui/model.py b/rascal2/ui/model.py
index 4e533dd..56ee1c1 100644
--- a/rascal2/ui/model.py
+++ b/rascal2/ui/model.py
@@ -27,3 +27,19 @@ def create_project(self, name: str, save_path: str):
self.project = RAT.Project(name=name)
self.controls = RAT.Controls()
self.save_path = save_path
+
+ def update_project(self, problem_definition: RAT.rat_core.ProblemDefinition):
+ """Update the project given a set of results."""
+ parameter_field = {
+ "parameters": "params",
+ "bulk_in": "bulkIn",
+ "bulk_out": "bulkOut",
+ "scalefactors": "scalefactors",
+ "domain_ratios": "domainRatio",
+ "background_parameters": "backgroundParams",
+ "resolution_parameters": "resolutionParams",
+ }
+
+ for class_list in RAT.project.parameter_class_lists:
+ for index, value in enumerate(getattr(problem_definition, parameter_field[class_list])):
+ getattr(self.project, class_list)[index].value = value
diff --git a/rascal2/ui/presenter.py b/rascal2/ui/presenter.py
index faab068..ce570d1 100644
--- a/rascal2/ui/presenter.py
+++ b/rascal2/ui/presenter.py
@@ -1,7 +1,11 @@
+import re
import warnings
from typing import Any
+import RATapi as RAT
+
from rascal2.core import commands
+from rascal2.core.runner import LogData, RATRunner
from .model import MainWindowModel
@@ -71,7 +75,81 @@ def edit_controls(self, setting: str, value: Any):
return True
def interrupt_terminal(self):
- """Sends an interrupt signal to the terminal."""
- # TODO: stub for when issue #9 is resolved
- # https://github.com/RascalSoftware/RasCAL-2/issues/9
- pass
+ """Sends an interrupt signal to the RAT runner."""
+ self.runner.interrupt()
+
+ def run(self):
+ """Run RAT."""
+ # reset terminal
+ self.view.terminal_widget.progress_bar.setVisible(False)
+ if self.view.settings.clear_terminal:
+ self.view.terminal_widget.clear()
+
+ rat_inputs = RAT.inputs.make_input(self.model.project, self.model.controls)
+ display_on = self.model.controls.display != RAT.utils.enums.Display.Off
+
+ self.runner = RATRunner(rat_inputs, self.model.controls.procedure, display_on)
+ self.runner.finished.connect(self.handle_results)
+ self.runner.stopped.connect(self.handle_interrupt)
+ self.runner.event_received.connect(self.handle_event)
+ self.runner.start()
+
+ def handle_results(self):
+ """Handle a RAT run being finished."""
+ self.model.update_project(self.runner.updated_problem)
+ self.view.handle_results(self.runner.results)
+
+ def handle_interrupt(self):
+ """Handle a RAT run being interrupted."""
+ if self.runner.error is None:
+ self.view.logging.info("RAT run interrupted!")
+ else:
+ self.view.logging.error(f"RAT run failed with exception:\n{self.runner.error}")
+ self.view.reset_widgets()
+
+ def handle_event(self):
+ """Handle event data produced by the RAT run."""
+ event = self.runner.events.pop(0)
+ if isinstance(event, str):
+ self.view.terminal_widget.write(event)
+ chi_squared = get_live_chi_squared(event, str(self.model.controls.procedure))
+ if chi_squared is not None:
+ self.view.controls_widget.chi_squared.setText(chi_squared)
+ elif isinstance(event, RAT.events.ProgressEventData):
+ self.view.terminal_widget.update_progress(event)
+ elif isinstance(event, LogData):
+ self.view.logging.log(event.level, event.msg)
+
+
+# '\d+\.\d+' is the regex for
+# 'some integer, then a decimal point, then another integer'
+# the parentheses () mean it is put in capture group 1,
+# which is what we return as the chi-squared value
+# we compile these regexes on import to make `get_live_chi_squared` basically instant
+chi_squared_patterns = {
+ "simplex": re.compile(r"(\d+\.\d+)"),
+ "de": re.compile(r"Best: (\d+\.\d+)"),
+}
+
+
+def get_live_chi_squared(item: str, procedure: str) -> str | None:
+ """Get the chi-squared value from iteration message data.
+
+ Parameters
+ ----------
+ item : str
+ The iteration message.
+ procedure : str
+ The procedure currently running.
+
+ Returns
+ -------
+ str or None
+ The chi-squared value from that procedure's message data in string form,
+ or None if one has not been found.
+
+ """
+ if procedure not in chi_squared_patterns:
+ return None
+ # match returns None if no match found, so whether one is found can be checked via 'if match'
+ return match.group(1) if (match := chi_squared_patterns[procedure].search(item)) else None
diff --git a/rascal2/ui/view.py b/rascal2/ui/view.py
index 79ad6eb..0fbec6e 100644
--- a/rascal2/ui/view.py
+++ b/rascal2/ui/view.py
@@ -6,7 +6,7 @@
from rascal2.core.settings import MDIGeometries, Settings
from rascal2.dialogs.project_dialog import ProjectDialog
from rascal2.dialogs.settings_dialog import SettingsDialog
-from rascal2.widgets import ControlsWidget
+from rascal2.widgets import ControlsWidget, TerminalWidget
from rascal2.widgets.startup_widget import StartUpWidget
from .presenter import MainWindowPresenter
@@ -37,7 +37,7 @@ def __init__(self):
# https://github.com/RascalSoftware/RasCAL-2/issues/7
# project: NO ISSUE YET
self.plotting_widget = QtWidgets.QWidget()
- self.terminal_widget = QtWidgets.QWidget()
+ self.terminal_widget = TerminalWidget(self)
self.controls_widget = ControlsWidget(self)
self.project_widget = QtWidgets.QWidget()
@@ -163,6 +163,11 @@ def create_actions(self):
self.save_default_windows_action.setEnabled(False)
self.disabled_elements.append(self.save_default_windows_action)
+ # Terminal menu actions
+ self.clear_terminal_action = QtGui.QAction("Clear Terminal", self)
+ self.clear_terminal_action.setStatusTip("Clear text in the terminal")
+ self.clear_terminal_action.triggered.connect(self.terminal_widget.clear)
+
def create_menus(self):
"""Creates the main menu and sub menus"""
self.main_menu = self.menuBar()
@@ -189,8 +194,11 @@ def create_menus(self):
self.windows_menu.setEnabled(False)
self.disabled_elements.append(self.windows_menu)
- self.help_menu = self.main_menu.addMenu("&Help")
- self.help_menu.addAction(self.open_help_action)
+ terminal_menu = self.main_menu.addMenu("&Terminal")
+ terminal_menu.addAction(self.clear_terminal_action)
+
+ help_menu = self.main_menu.addMenu("&Help")
+ help_menu.addAction(self.open_help_action)
def open_docs(self):
"""Opens the documentation"""
@@ -283,10 +291,19 @@ def init_settings_and_log(self, save_path: str):
log_path = proj_path / log_path
log_path.parents[0].mkdir(parents=True, exist_ok=True)
- self.logging = setup_logging(log_path, level=self.settings.log_level)
+ self.logging = setup_logging(log_path, self.terminal_widget, level=self.settings.log_level)
def enable_elements(self):
"""Enable the elements that are disabled on startup."""
for element in self.disabled_elements:
element.setEnabled(True)
self.disabled_elements = []
+
+ def handle_results(self, results):
+ """Handle the results of a RAT run."""
+ self.reset_widgets()
+ self.controls_widget.chi_squared.setText(f"{results.calculationResults.sumChi:.6g}")
+
+ def reset_widgets(self):
+ """Reset widgets after a run."""
+ self.controls_widget.run_button.setChecked(False)
diff --git a/rascal2/widgets/__init__.py b/rascal2/widgets/__init__.py
index 6e56c79..a2142ed 100644
--- a/rascal2/widgets/__init__.py
+++ b/rascal2/widgets/__init__.py
@@ -1,4 +1,5 @@
from rascal2.widgets.controls import ControlsWidget
from rascal2.widgets.inputs import AdaptiveDoubleSpinBox, ValidatedInputWidget
+from rascal2.widgets.terminal import TerminalWidget
-__all__ = ["ControlsWidget", "ValidatedInputWidget", "AdaptiveDoubleSpinBox"]
+__all__ = ["ControlsWidget", "TerminalWidget", "ValidatedInputWidget", "AdaptiveDoubleSpinBox"]
diff --git a/rascal2/widgets/controls.py b/rascal2/widgets/controls.py
index 2246258..0bbe7b5 100644
--- a/rascal2/widgets/controls.py
+++ b/rascal2/widgets/controls.py
@@ -32,7 +32,6 @@ def __init__(self, parent):
self.stop_button = QtWidgets.QPushButton(icon=QtGui.QIcon(path_for("stop.png")), text="Stop")
self.stop_button.pressed.connect(self.presenter.interrupt_terminal)
- self.stop_button.pressed.connect(self.run_button.toggle)
self.stop_button.setStyleSheet("background-color: red;")
self.stop_button.setEnabled(False)
@@ -48,7 +47,7 @@ def __init__(self, parent):
chi_layout = QtWidgets.QHBoxLayout()
# TODO hook this up when we can actually run
# https://github.com/RascalSoftware/RasCAL-2/issues/9
- self.chi_squared = QtWidgets.QLineEdit("1.060")
+ self.chi_squared = QtWidgets.QLineEdit()
self.chi_squared.setReadOnly(True)
chi_layout.addWidget(QtWidgets.QLabel("Current chi-squared:"))
chi_layout.addWidget(self.chi_squared)
@@ -145,11 +144,7 @@ def toggle_run_button(self, toggled: bool):
self.procedure_dropdown.setEnabled(False)
self.run_button.setEnabled(False)
self.stop_button.setEnabled(True)
- # TODO some functional stuff... issue #9
- # self.presenter.run() etc.
- # presenter should send a signal when run is completed,
- # which then toggles the button back
- # https://github.com/RascalSoftware/RasCAL-2/issues/9
+ self.presenter.run()
else:
self.fit_settings.setEnabled(True)
self.procedure_dropdown.setEnabled(True)
diff --git a/rascal2/widgets/terminal.py b/rascal2/widgets/terminal.py
new file mode 100644
index 0000000..51d5468
--- /dev/null
+++ b/rascal2/widgets/terminal.py
@@ -0,0 +1,95 @@
+"""Widget for terminal display."""
+
+from PyQt6 import QtGui, QtWidgets
+
+from rascal2 import RASCAL2_VERSION
+
+
+class TerminalWidget(QtWidgets.QWidget):
+ """Widget for displaying program output."""
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+
+ self.text_area = QtWidgets.QPlainTextEdit()
+ self.text_area.setReadOnly(True)
+ font = QtGui.QFont()
+ font.setFamily("Courier")
+ font.setStyleHint(font.StyleHint.Monospace)
+ self.text_area.setFont(font)
+ self.text_area.setLineWrapMode(self.text_area.LineWrapMode.NoWrap)
+
+ widget_layout = QtWidgets.QVBoxLayout()
+
+ widget_layout.addWidget(self.text_area)
+
+ self.progress_bar = QtWidgets.QProgressBar()
+ self.progress_bar.setMaximumHeight(15)
+ self.progress_bar.setMinimumHeight(10)
+ self.progress_bar.setMaximum(100)
+ self.progress_bar.setMinimum(0)
+ self.progress_bar.setVisible(False)
+ widget_layout.addWidget(self.progress_bar)
+
+ widget_layout.setContentsMargins(0, 0, 0, 0)
+ self.setLayout(widget_layout)
+
+ self.write(
+ """
+ ███████████ █████████ █████████ █████
+░░███░░░░░███ ███░░░░░███ ███░░░░░███ ░░███
+ ░███ ░███ ██████ █████ ███ ░░░ ░███ ░███ ░███
+ ░██████████ ░░░░░███ ███░░ ░███ ░███████████ ░███
+ ░███░░░░░███ ███████ ░░█████ ░███ ░███░░░░░███ ░███
+ ░███ ░███ ███░░███ ░░░░███░░███ ███ ░███ ░███ ░███ █
+ █████ █████░░████████ ██████ ░░█████████ █████ █████ ███████████
+░░░░░ ░░░░░ ░░░░░░░░ ░░░░░░ ░░░░░░░░░ ░░░░░ ░░░░░ ░░░░░░░░░░░
+"""
+ )
+ self.write_html(f"\nRasCAL-2: software for neutron reflectivity calculations v{RASCAL2_VERSION}")
+
+ # set text area to be scrolled to the left at start
+ self.text_area.moveCursor(QtGui.QTextCursor.MoveOperation.StartOfLine, QtGui.QTextCursor.MoveMode.MoveAnchor)
+
+ def write(self, text: str):
+ """Append plain text to the terminal.
+
+ Parameters
+ ----------
+ text : str
+ The text to append.
+
+ """
+ self.text_area.appendPlainText(text.rstrip())
+
+ def write_html(self, text: str):
+ """Append HTML text to the terminal.
+
+ Parameters
+ ----------
+ text : str
+ The HTML to append.
+
+ """
+ self.text_area.appendHtml(text.rstrip())
+
+ def clear(self):
+ """Clear the text in the terminal."""
+ self.text_area.setPlainText("")
+ self.update()
+
+ def update_progress(self, event):
+ """Update the progress bar from event data.
+
+ Parameters
+ ----------
+ event : ProgressEventData
+ The data for the current event.
+
+ """
+ self.progress_bar.setVisible(True)
+ self.progress_bar.setValue(int(event.percent * 100))
+
+ # added to make TerminalWidget an IO stream
+ def flush(self):
+ pass
diff --git a/tests/core/test_runner.py b/tests/core/test_runner.py
new file mode 100644
index 0000000..78c7c6d
--- /dev/null
+++ b/tests/core/test_runner.py
@@ -0,0 +1,143 @@
+"""Tests for the RATRunner class."""
+
+from queue import Queue # we need a non-multiprocessing queue because mocks cannot be serialised
+from unittest.mock import MagicMock, patch
+
+import pytest
+import RATapi as RAT
+
+from rascal2.core.runner import LogData, RATRunner, run
+
+
+def make_progress_event(percent):
+ event = RAT.events.ProgressEventData()
+ event.percent = percent
+ return event
+
+
+def mock_rat_main(*args, **kwargs):
+ """Mock of RAT main that produces some signals."""
+
+ RAT.events.notify(RAT.events.EventTypes.Progress, make_progress_event(0.2))
+ RAT.events.notify(RAT.events.EventTypes.Progress, make_progress_event(0.5))
+ RAT.events.notify(RAT.events.EventTypes.Message, "test message")
+ RAT.events.notify(RAT.events.EventTypes.Message, "test message 2")
+ RAT.events.notify(RAT.events.EventTypes.Progress, make_progress_event(0.7))
+ return 1, 2, 3
+
+
+@patch("rascal2.core.runner.Process")
+def test_start(mock_process):
+ """Test that `start` creates and starts a process and timer."""
+ runner = RATRunner([], "", True)
+ runner.start()
+
+ runner.process.start.assert_called_once()
+ assert runner.timer.isActive()
+
+
+@patch("rascal2.core.runner.Process")
+def test_interrupt(mock_process):
+ """Test that `interrupt` kills the process and stops the timer."""
+ runner = RATRunner([], "", True)
+ runner.interrupt()
+
+ runner.process.kill.assert_called_once()
+ assert not runner.timer.isActive()
+
+
+@pytest.mark.parametrize(
+ "queue_items",
+ [
+ ["message!"],
+ ["message!", (MagicMock(spec=RAT.rat_core.ProblemDefinition), MagicMock(spec=RAT.outputs.Results))],
+ [(MagicMock(spec=RAT.rat_core.ProblemDefinition), MagicMock(spec=RAT.outputs.BayesResults))],
+ [make_progress_event(0.6)],
+ [make_progress_event(0.5), ValueError("Runner error!")],
+ ["message 1!", make_progress_event(0.4), "message 2!"],
+ ],
+)
+@patch("rascal2.core.runner.Process")
+def test_check_queue(mock_process, queue_items):
+ """Test that queue data is appropriately assigned."""
+ runner = RATRunner([], "", True)
+ runner.queue = Queue()
+
+ for item in queue_items:
+ runner.queue.put(item)
+
+ runner.check_queue()
+
+ assert len(runner.events) == len([x for x in queue_items if not isinstance(x, (tuple, Exception))])
+ for i, item in enumerate(runner.events):
+ if isinstance(item, RAT.events.ProgressEventData):
+ assert item.percent == queue_items[i].percent
+ else:
+ assert item == queue_items[i]
+
+ if isinstance(queue_items[-1], tuple):
+ assert isinstance(runner.updated_problem, RAT.rat_core.ProblemDefinition)
+ assert isinstance(runner.results, RAT.outputs.Results)
+ if isinstance(queue_items[-1], Exception):
+ assert isinstance(runner.error, ValueError)
+ assert str(runner.error) == "Runner error!"
+
+
+@patch("rascal2.core.runner.Process")
+def test_empty_queue(mock_process):
+ """Test that nothing happens if the queue is empty."""
+ runner = RATRunner([], "", True)
+ runner.check_queue()
+
+ assert len(runner.events) == 0
+ assert runner.results is None
+
+
+@pytest.mark.parametrize("display", [True, False])
+@patch("RATapi.rat_core.RATMain", new=mock_rat_main)
+@patch("RATapi.outputs.make_results", new=MagicMock(spec=RAT.outputs.Results))
+def test_run(display):
+ """Test that a run puts the correct items in the queue."""
+ queue = Queue()
+ run(queue, [0, 1, 2, 3, 4], "", display)
+ expected_display = [
+ LogData(20, "Starting RAT"),
+ 0.2,
+ 0.5,
+ "test message",
+ "test message 2",
+ 0.7,
+ LogData(20, "Finished RAT"),
+ ]
+
+ while not queue.empty():
+ item = queue.get()
+ if isinstance(item, tuple):
+ # ensure results were the last item to be added
+ assert queue.empty()
+ else:
+ expected_item = expected_display.pop(0)
+ if isinstance(item, RAT.events.ProgressEventData):
+ assert item.percent == expected_item
+ else:
+ assert item == expected_item
+
+
+def test_run_error():
+ """If RATMain produces an error, it should be added to the queue."""
+
+ def erroring_ratmain(*args):
+ """A RATMain mock that raises an error."""
+ raise ValueError("RAT Main Error!")
+
+ queue = Queue()
+ with patch("RATapi.rat_core.RATMain", new=erroring_ratmain):
+ run(queue, [0, 1, 2, 3, 4], "", True)
+
+ queue.put(None)
+ queue_contents = list(iter(queue.get, None))
+ assert len(queue_contents) == 2
+ assert isinstance(queue_contents[0], LogData)
+ error = queue_contents[1]
+ assert isinstance(error, ValueError)
+ assert str(error) == "RAT Main Error!"
diff --git a/tests/test_settings.py b/tests/core/test_settings.py
similarity index 95%
rename from tests/test_settings.py
rename to tests/core/test_settings.py
index fe901b8..253931c 100644
--- a/tests/test_settings.py
+++ b/tests/core/test_settings.py
@@ -13,7 +13,7 @@ class MockGlobalSettings:
"""A mock of the global settings."""
def __init__(self):
- self.settings = {"General/editor_fontsize": 15, "General/terminal_fontsize": 28}
+ self.settings = {"General/editor_fontsize": 15, "Terminal/terminal_fontsize": 28}
def value(self, key):
return self.settings[key]
@@ -87,4 +87,4 @@ def test_set_global(mock):
settings = Settings(editor_fontsize=18, terminal_fontsize=3)
settings.set_global_settings()
mock.assert_any_call("General/editor_fontsize", 18)
- mock.assert_any_call("General/terminal_fontsize", 3)
+ mock.assert_any_call("Terminal/terminal_fontsize", 3)
diff --git a/tests/test_config.py b/tests/test_config.py
index 04f4987..3d39ecf 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -3,6 +3,7 @@
import tempfile
from logging import CRITICAL, INFO, WARNING
from pathlib import Path
+from unittest.mock import MagicMock
import pytest
@@ -25,7 +26,8 @@ def test_setup_settings():
def test_setup_logging(level):
"""Test that the logger is set up correctly."""
tmp = tempfile.mkdtemp()
- log = setup_logging(Path(tmp, "rascal.log"), level)
+ terminal = MagicMock()
+ log = setup_logging(Path(tmp, "rascal.log"), terminal, level)
assert Path(tmp, "rascal.log").is_file()
assert log.level == level
diff --git a/tests/test_dialogs.py b/tests/test_dialogs.py
index e55fcac..d6a1316 100644
--- a/tests/test_dialogs.py
+++ b/tests/test_dialogs.py
@@ -210,7 +210,7 @@ def test_settings_dialog_reset_button(settings_dialog_with_parent):
@pytest.mark.parametrize(
"tab_group, settings_labels",
[
- (SettingsGroups.General, ["Style", "Editor Fontsize", "Terminal Fontsize"]),
+ (SettingsGroups.General, ["Style", "Editor Fontsize"]),
],
)
def test_settings_dialog_tabs(settings_dialog_with_parent, tab_group, settings_labels):
diff --git a/tests/test_presenter.py b/tests/test_presenter.py
index 30a8c30..da666d1 100644
--- a/tests/test_presenter.py
+++ b/tests/test_presenter.py
@@ -1,12 +1,14 @@
"""Tests for the Presenter."""
-from unittest.mock import MagicMock
+from unittest.mock import MagicMock, patch
import pytest
from pydantic import ValidationError
from PyQt6 import QtWidgets
from RATapi import Controls
+from RATapi.events import ProgressEventData
+from rascal2.core.runner import LogData
from rascal2.ui.presenter import MainWindowPresenter
@@ -26,11 +28,18 @@ class MockWindowView(QtWidgets.QMainWindow):
def __init__(self):
super().__init__()
self.undo_stack = MockUndoStack()
+ self.controls_widget = MagicMock()
+ self.terminal_widget = MagicMock()
+ self.handle_results = MagicMock()
+ self.reset_widgets = MagicMock()
+ self.logging = MagicMock()
+ self.settings = MagicMock()
@pytest.fixture
def presenter():
pr = MainWindowPresenter(MockWindowView())
+ pr.runner = MagicMock()
pr.model = MagicMock()
pr.model.controls = Controls()
@@ -56,3 +65,74 @@ def test_controls_validation_error(presenter, param, value):
raise err
else:
raise AssertionError("Invalid data did not raise error!")
+
+
+@patch("RATapi.inputs.make_input")
+@patch("rascal2.ui.presenter.RATRunner")
+def test_run_and_interrupt(mock_runner, mock_inputs, presenter):
+ """Test that the runner can be started and interrupted."""
+ presenter.run()
+ presenter.interrupt_terminal()
+
+ mock_inputs.assert_called_once()
+ presenter.runner.start.assert_called_once()
+ presenter.runner.interrupt.assert_called_once()
+
+
+def test_handle_results(presenter):
+ """Test that results are handed to the view correctly."""
+ presenter.runner = MagicMock()
+ presenter.runner.results = "TEST RESULTS"
+ presenter.handle_results()
+
+ presenter.view.handle_results.assert_called_once_with("TEST RESULTS")
+
+
+def test_stop_run(presenter):
+ """Test that log info is emitted and the run is stopped when stop_run is called."""
+ presenter.runner = MagicMock()
+ presenter.runner.error = None
+ presenter.handle_interrupt()
+ presenter.view.logging.info.assert_called_once_with("RAT run interrupted!")
+ presenter.view.reset_widgets.assert_called_once()
+
+
+def test_run_error(presenter):
+ """Test that a critical log is emitted if stop_run is called with an error."""
+ presenter.runner = MagicMock()
+ presenter.runner.error = ValueError("Test error!")
+ presenter.handle_interrupt()
+ presenter.view.logging.error.assert_called_once_with("RAT run failed with exception:\nTest error!")
+
+
+@pytest.mark.parametrize(
+ ("procedure", "string"),
+ [
+ ("calculate", "Test message!"),
+ ("simplex", "some stuff, 3443, 10.5, 9"),
+ ("de", "things: 54, Best: 10.5, test... ... N: 65.3"),
+ ],
+)
+def test_handle_message_chisquared(presenter, procedure, string):
+ """Test that messages are handled correctly, including chi-squared data."""
+ presenter.runner.events = [string]
+ presenter.model.controls.procedure = procedure
+ presenter.handle_event()
+ presenter.view.terminal_widget.write.assert_called_with(string)
+ if procedure in ["simplex", "de"]:
+ presenter.view.controls_widget.chi_squared.setText.assert_called_with("10.5")
+ else:
+ presenter.view.controls_widget.chi_squared.setText.assert_not_called()
+
+
+def test_handle_progress_event(presenter):
+ """Test that progress events are handled correctly."""
+ presenter.runner.events = [ProgressEventData()]
+ presenter.handle_event()
+ presenter.view.terminal_widget.update_progress.assert_called()
+
+
+def test_handle_log_data(presenter):
+ presenter.runner.events = [LogData(10, "Test log!")]
+ presenter.handle_event()
+ presenter.view.logging.log.assert_called_with(10, "Test log!")
diff --git a/tests/test_terminal.py b/tests/test_terminal.py
new file mode 100644
index 0000000..2ecf3bd
--- /dev/null
+++ b/tests/test_terminal.py
@@ -0,0 +1,45 @@
+"""Tests for the terminal widget."""
+
+from RATapi.events import ProgressEventData
+
+from rascal2.widgets.terminal import TerminalWidget
+
+
+def test_write():
+ """Test that text can be successfully written to the terminal."""
+ wg = TerminalWidget()
+ wg.write("test text")
+ assert "test text" in wg.text_area.toPlainText()
+
+
+def test_append_html():
+ """Test that HTML can be written to the terminal as formatted text."""
+ wg = TerminalWidget()
+ wg.write_html("HTML bold text!")
+ assert "HTML bold text!" in wg.text_area.toPlainText()
+ assert "" not in wg.text_area.toPlainText()
+
+
+def test_clear():
+ """Test that the terminal clearing works."""
+ wg = TerminalWidget()
+ wg.write("test text")
+ wg.clear()
+ assert wg.text_area.toPlainText() == ""
+
+
+def test_progress_bar():
+ """Test that the progress bar is shown when a progress event is given."""
+ wg = TerminalWidget()
+ assert not wg.progress_bar.isVisibleTo(wg)
+
+ event = ProgressEventData()
+ event.percent = 0.2
+ wg.update_progress(event)
+ assert wg.progress_bar.isVisibleTo(wg)
+ assert wg.progress_bar.value() == 20
+
+ event.percent = 0.65
+ wg.update_progress(event)
+ assert wg.progress_bar.isVisibleTo(wg)
+ assert wg.progress_bar.value() == 65