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