diff --git a/rascal2/dialogs/startup_dialog.py b/rascal2/dialogs/startup_dialog.py index 832df8e2..8cb76798 100644 --- a/rascal2/dialogs/startup_dialog.py +++ b/rascal2/dialogs/startup_dialog.py @@ -1,4 +1,5 @@ import os +import traceback from pathlib import Path from PyQt6 import QtCore, QtWidgets @@ -184,7 +185,17 @@ def project_start_failed(self, exception, args): error = str(exception).strip().replace("\n", "") message = f"The Project ({folder_name}) could not be opened because:\n\n{error}" LOGGER.error(message, exc_info=exception) - QtWidgets.QMessageBox.critical(self, self.windowTitle(), message) + + message_box = QtWidgets.QMessageBox(self) + message_box.setStyleSheet("QMessageBox QTextEdit{color: red; font-family: monospace; font-weight:500;}") + message_box.setWindowTitle(self.windowTitle()) + message_box.setIcon(QtWidgets.QMessageBox.Icon.Critical) + message_box.setText(message) + + reverse_tb = "\n".join(reversed(traceback.format_tb(exception.__traceback__))) + message_box.setDetailedText(reverse_tb) + + message_box.exec() class NewProjectDialog(StartupDialog): @@ -253,7 +264,6 @@ def __init__(self, title, desc): title_widget = QtWidgets.QLabel(title) title_widget.setObjectName("title") desc_widget = QtWidgets.QLabel(desc) - desc_widget.setObjectName("desc") layout.addWidget(title_widget) layout.addWidget(desc_widget) self.setLayout(layout) diff --git a/rascal2/static/style.css b/rascal2/static/style.css index 37f492b9..97b6e0b5 100644 --- a/rascal2/static/style.css +++ b/rascal2/static/style.css @@ -47,6 +47,7 @@ QPushButton:pressed { QPushButton:hover, QPushButton:pressed { background-color: @Highlight; + color: @HighlightedText; } QPushButton:disabled, @@ -64,14 +65,14 @@ QPushButton:checked { QLineEdit { color: @Text; background-color: @Base; - selection-color: @Text; + selection-color: @HighlightedText; selection-background-color: @Highlight; border: 1px solid darkgray; border-radius: 3px; } QLineEdit:read-only { - selection-color: @Text; + selection-color: @HighlightedText; selection-background-color: @Highlight; background-color: transparent; } @@ -170,6 +171,8 @@ StartupDialog QLineEdit[error="true"] { border: 1px solid #E34234; } +StartupDialog QListWidget::item{color:green;} + StartupDialog QListWidget::item:hover { background-color: @Highlight; } @@ -201,10 +204,6 @@ DisplayWidget #title{ font-size: 16px; } -DisplayWidget #desc{ - color: @text -} - /***************************** ProjectWidget Styles *****************************/ @@ -422,8 +421,13 @@ AbstractProjectListWidget QPushButton { } AbstractProjectListWidget QTableView { - border: 1px solid @Midlight; - background-color: @Window; + selection-color: @HighlightedText; + selection-background-color: @Highlight; +} + +AbstractProjectListWidget QTableView::item:selected:!active{ + background-color: @Highlight; + color: @HighlightText; } #CountHeader::section { @@ -439,6 +443,18 @@ AbstractProjectListWidget QTableView { font-weight: 600; } +ContrastWidget QListView{ + show-decoration-selected: 1; +} + +ContrastWidget QListView::item { + border-bottom: 1px solid @Midlight; +} + +ContrastWidget QListView::item:selected { + background-color: @Highlight; + color: @HighlightedText; +} /***************************** BayesPlotsDialog Styles diff --git a/rascal2/theme.py b/rascal2/theme.py index 0eaf6976..c1938a23 100644 --- a/rascal2/theme.py +++ b/rascal2/theme.py @@ -13,6 +13,7 @@ def set_stylesheet(app): replacements = { "@Path": IMAGES_PATH.as_posix(), "@Window": palette.window().color().name(), + "@HighlightedText": palette.highlightedText().color().name(), "@Highlight": palette.highlight().color().name(), "@Midlight": palette.midlight().color().name(), "@Text": palette.text().color().name(), diff --git a/rascal2/widgets/delegates.py b/rascal2/widgets/delegates.py index 205322cf..a4a9c74a 100644 --- a/rascal2/widgets/delegates.py +++ b/rascal2/widgets/delegates.py @@ -1,5 +1,6 @@ """Delegates for items in Qt tables.""" +from enum import Enum from typing import Literal from PyQt6 import QtCore, QtGui, QtWidgets @@ -33,6 +34,9 @@ def createEditor(self, parent, option, index): widget.editor.open_on_show = True widget.editor.text_changed.connect(self.commit_and_close_editor) + if issubclass(self.field_info.annotation, Enum): + widget.editor.activated.connect(self.commit_and_close_editor) + self.widget = widget # Using the BaseInputWidget directly did not style properly, # this uses the editor widget while holding a reference to BaseInputWidget. @@ -147,6 +151,7 @@ def createEditor(self, parent, option, index): names = [""] + names widget.addItems(names) widget.setCurrentText(index.data(QtCore.Qt.ItemDataRole.DisplayRole)) + widget.currentTextChanged.connect(self.commit_and_close_editor) return widget @@ -158,6 +163,11 @@ def setModelData(self, editor, model, index): data = editor.currentText() model.setData(index, data, QtCore.Qt.ItemDataRole.EditRole) + def commit_and_close_editor(self): + editor = self.sender() + self.commitData.emit(editor) + self.closeEditor.emit(editor) + class SignalSourceDelegate(QtWidgets.QStyledItemDelegate): """Item delegate to choose from draft project parameters, with a check for different source types.""" diff --git a/rascal2/widgets/inputs.py b/rascal2/widgets/inputs.py index 9a5ee3df..a04c3f2f 100644 --- a/rascal2/widgets/inputs.py +++ b/rascal2/widgets/inputs.py @@ -101,7 +101,7 @@ class IntInputWidget(BaseInputWidget): def create_editor(self, field_info: FieldInfo) -> QtWidgets.QWidget: editor = QtWidgets.QSpinBox(self) # default max and min are 99 and 0 - # there is no 'integer infinity' so we just set them to biggest possible numbers + # there is no 'integer infinity' so we just set them to the biggest possible numbers editor.setMaximum(2**31 - 1) editor.setMinimum(-(2**31)) for item in field_info.metadata: diff --git a/rascal2/widgets/project/lists.py b/rascal2/widgets/project/lists.py index f6181e61..9cce5df3 100644 --- a/rascal2/widgets/project/lists.py +++ b/rascal2/widgets/project/lists.py @@ -37,6 +37,7 @@ def __init__(self, classlist: ratapi.ClassList[T], parent: QtWidgets.QWidget): self.classlist = classlist self.item_type = classlist._class_handle self.edit_mode = False + self.renamed_entries = {} def rowCount(self, parent=None) -> int: return len(self.classlist) @@ -75,6 +76,10 @@ def set_data(self, row: int, param: str, value: Any): The value to set the parameter to. """ + if param == "name" and self.classlist[row].name != value: + self.renamed_entries[self.classlist[row].name] = value + self.renamed_entries.pop(value, None) # avoid cycle + setattr(self.classlist[row], param, value) self.endResetModel() @@ -100,6 +105,7 @@ def delete_item(self, row: int): """ if len(self.classlist) == 0: return + self.renamed_entries[self.classlist[row].name] = "" self.classlist.pop(row) self.endResetModel() @@ -641,19 +647,32 @@ def data_combobox(field: str) -> QtWidgets.QWidget: return widget case "model": if self.project_widget.draft_project["model"] == LayerModels.StandardLayers: - widget = StandardLayerModelWidget(current_data, self) + model_field_name = "domain_contrasts" if self.model.domains else "layers" + changes = self.parent.parent.renamed_parameters.get(model_field_name, {}) + clean_data = [changes.get(item, item) for item in current_data] + + widget = StandardLayerModelWidget(clean_data, self) widget.model.dataChanged.connect( lambda: self.model.set_data(i, field, widget.model.stringList()) ) widget.model.rowsMoved.connect(lambda: self.model.set_data(i, field, widget.model.stringList())) + if current_data != clean_data: + # Data is missing so updated model to get better error message + self.model.set_data(i, field, clean_data) return widget else: widget = QtWidgets.QComboBox(self) - widget.addItem("", []) + model_items = [] for file in self.project_widget.draft_project["custom_files"]: widget.addItem(file.name, [file.name]) + model_items.append(file.name) if current_data: - widget.setCurrentText(current_data[0]) + changes = self.parent.parent.renamed_parameters.get("custom_files", {}) + widget.setCurrentText(changes.get(current_data[0], current_data[0])) + if current_data[0] not in model_items: + # Data is missing so updated model to get better error message + widget.setCurrentIndex(-1) + self.model.set_data(i, field, []) else: widget.setCurrentText("") widget.currentTextChanged.connect(lambda: self.model.set_data(i, field, widget.currentData())) @@ -666,21 +685,28 @@ def data_combobox(field: str) -> QtWidgets.QWidget: project_field_name = field + "s" pass + changes = self.parent.parent.renamed_parameters.get(project_field_name, {}) project_field = self.project_widget.draft_project[project_field_name] combobox = QtWidgets.QComboBox(self) - items = [""] + [item.name for item in project_field] + items = [item.name for item in project_field] combobox.addItems(items) - combobox.setCurrentText(current_data) + combobox.setCurrentText(changes.get(current_data, current_data)) combobox.currentTextChanged.connect(lambda: self.model.set_data(i, field, combobox.currentText())) combobox.setSizePolicy(QtWidgets.QSizePolicy.Policy.MinimumExpanding, QtWidgets.QSizePolicy.Policy.Fixed) - + if current_data not in items: + # Data is missing so update model to get better error message + combobox.setCurrentIndex(-1) + self.model.set_data(i, field, combobox.currentText()) return combobox return self.compose_widget(i, data_combobox) def update_project(self, index, prop, value): """Update parent project data and recalculate plots.""" + selected = self.list.selectionModel().currentIndex() + # This clears the selection so we cache the selected index self.model.set_data(index, prop, value) + self.list.selectionModel().setCurrentIndex(selected, self.list.selectionModel().SelectionFlag.ClearAndSelect) if not self.edit_mode: presenter = self.parent.parent.parent.presenter presenter.model.blockSignals(True) diff --git a/rascal2/widgets/project/project.py b/rascal2/widgets/project/project.py index 5600b260..8674004c 100644 --- a/rascal2/widgets/project/project.py +++ b/rascal2/widgets/project/project.py @@ -65,6 +65,7 @@ def __init__(self, parent): # for making model type changes non-destructive self.old_contrast_models = {} self.old_layers = [] + self.renamed_parameters = {} project_view = self.create_project_view() project_edit = self.create_edit_view() @@ -238,6 +239,12 @@ def create_edit_view(self) -> QtWidgets.QWidget: for table in self.edit_tabs[tab].tables.values(): table.edited.connect(lambda: self.edit_tabs["Contrasts"].tables["contrasts"].update_item_view()) + for tab in ["Backgrounds", "Resolutions"]: + # This ensures that changes made to the background or resolution parameter tables + # force an update in the Background or Resolution tables respectively. + table = self.edit_tabs[tab].tables[self.tabs[tab][0]] + table.edited.connect(self.edit_tabs[tab].tables[self.tabs[tab][1]].setFocus) + main_layout.addWidget(self.edit_project_tab) edit_project_widget.setLayout(main_layout) @@ -285,6 +292,9 @@ def update_project_view(self, update_tab_index=None) -> None: self.view_tabs[tab].update_model(self.draft_project) self.edit_tabs[tab].update_model(self.draft_project) + for table_name, table in self.edit_tabs[tab].tables.items(): + self.renamed_parameters[table_name] = table.model.renamed_entries + self.absorption_checkbox.setChecked(self.parent_model.project.absorption) self.calculation_type.setText(self.parent_model.project.calculation) self.model_type.setText(self.parent_model.project.model) @@ -434,8 +444,34 @@ def save_changes(self) -> None: def validate_draft_project(self) -> Generator[str, None, None]: """Get all errors with the draft project.""" yield from self.validate_layers() - yield from self.validate_contrasts() + yield from self.validate_background_and_resolutions() yield from self.validate_custom_file() + yield from self.validate_contrasts() + + def validate_background_and_resolutions(self) -> Generator[str, None, None]: + """Ensure that all background and resolution in the draft project are valid, and yield errors if not. + + Yields + ------ + str + The message for each error in background and resolutions. + + """ + project = self.draft_project + for field in ["backgrounds", "resolutions"]: + for i, entry in enumerate(project[field]): + missing_params = [] + if not entry.source and not (field == "resolutions" and entry.type == "data"): + missing_params.append("source") + + if missing_params: + action = ( + "Please update the missing entry with a valid value" + if len(missing_params) == 1 + else "Please update the missing entries with valid values" + ) + msg = f"{field.title()} {i + 1} ({entry.name}) is missing: {', '.join(missing_params)}. {action}" + yield msg def validate_custom_file(self) -> Generator[str, None, None]: """Ensure that all custom files in the draft project are valid, and yield errors if not. @@ -471,27 +507,16 @@ def validate_layers(self) -> Generator[str, None, None]: layer_attrs = list(project["layers"][0].model_fields) layer_attrs.remove("name") layer_attrs.remove("hydrate_with") - # ensure all layer parameters have been filled in, and all names are parameters that exist - valid_params = [p.name for p in project["parameters"]] + [""] for i, layer in enumerate(project["layers"]): missing_params = [] - invalid_params = [] for attr in layer_attrs: param = getattr(layer, attr) if param == "" and attr != "hydration": # hydration is allowed to be blank missing_params.append(attr) - elif param not in valid_params: - invalid_params.append((attr, param)) if missing_params: noun = "a parameter" if len(missing_params) == 1 else "parameters" - msg = f"Layer '{layer.name}' (row {i + 1}) is missing {noun}: {', '.join(missing_params)}" - yield msg - if invalid_params: - noun = "an invalid value" if len(invalid_params) == 1 else "invalid values" - msg = f"Layer '{layer.name}' (row {i + 1}) has {noun}: {{0}}".format( - ",\n ".join(f'"{v}" for parameter {p}' for p, v in invalid_params) - ) + msg = f"Layer {i + 1} ({layer.name}) is missing {noun}: {', '.join(missing_params)}" yield msg def validate_contrasts(self) -> Generator[str, None, None]: @@ -513,50 +538,42 @@ def validate_contrasts(self) -> Generator[str, None, None]: contrast_attrs.remove("repeat_layers") for i, contrast in enumerate(project["contrasts"]): missing_params = [] - invalid_params = [] for attr in contrast_attrs: - project_field_name = attr if attr in ["data", "bulk_in", "bulk_out"] else attr + "s" - valid_params = [p.name for p in project[project_field_name]] param = getattr(contrast, attr) if param == "": missing_params.append(attr) - elif param not in valid_params: - invalid_params.append((attr, param)) if missing_params: - msg = f"Contrast '{contrast.name}' (row {i + 1}) is missing: {', '.join(missing_params)}" - yield msg - if invalid_params: - noun = "an invalid value" if len(invalid_params) == 1 else "invalid values" - msg = f"Contrast '{contrast.name}' (row {i + 1}) has {noun}: {{0}}".format( - ",\n ".join(f'"{v}" for field {p}' for p, v in invalid_params) + action = ( + "Update the missing entry with a valid value" + if len(missing_params) == 1 + else "Please update the missing entries with valid values" ) + msg = f"Contrast {i + 1} ({contrast.name}) is missing: {', '.join(missing_params)}. {action}" yield msg model = contrast.model if project["model"] == LayerModels.StandardLayers: if project["calculation"] == Calculations.Domains: - model_field_name = "domain_contrasts" + model_field_name = "domain contrast" else: - model_field_name = "layers" - valid_params = [p.name for p in project[model_field_name]] - # strip out empty items - model = [item for item in model if item != ""] - invalid_model_vals = [item for item in model if item not in valid_params] - # this is the fastest way to get all unique items from a list without changing the order... - invalid_model_vals = list(dict.fromkeys(invalid_model_vals)) - if invalid_model_vals: - noun = "an invalid model value" if len(invalid_model_vals) == 1 else "invalid model values" - msg = f"Contrast '{contrast.name}' (row {i + 1}) has {noun}: {{0}}".format( - ", ".join(invalid_model_vals) - ) + model_field_name = "layer" + missing_params = [item for item in model if not item] + if missing_params: + if len(missing_params) == 1: + noun = "an empty entry" + action = f"Please update the empty entry with a valid {model_field_name}" + else: + noun = "multiple empty entries" + action = f"Please update the empty entries with valid {model_field_name}s" + msg = f"Contrast {i + 1} ({contrast.name}) has {noun} in the model. {action}" yield msg else: if not model: - msg = f"Contrast '{contrast.name}' (row {i + 1}) has no model set" - yield msg - elif model[0] not in [f.name for f in project["custom_files"]]: - msg = f"Contrast '{contrast.name}' (row {i + 1}) has invalid model: {model[0]}" + msg = ( + f"Contrast {i + 1} ({contrast.name}) has no model. " + f"Please select a valid model for the contrast" + ) yield msg def set_editing_enabled(self, enabled: bool): diff --git a/rascal2/widgets/project/tables.py b/rascal2/widgets/project/tables.py index b765af7c..2a74ed8d 100644 --- a/rascal2/widgets/project/tables.py +++ b/rascal2/widgets/project/tables.py @@ -4,6 +4,7 @@ import os import re import shutil +import warnings from enum import Enum from pathlib import Path @@ -41,6 +42,7 @@ def __init__(self, classlist: ratapi.ClassList, parent: QtWidgets.QWidget): self.item_type: type self.headers: list[str] + self.renamed_entries = {} self.setup_classlist(classlist) self.edit_mode = False self.col_offset = 1 @@ -65,8 +67,6 @@ def data(self, index, role=QtCore.Qt.ItemDataRole.DisplayRole): if param is None: return None - data = getattr(self.classlist[index.row()], param) - if role == QtCore.Qt.ItemDataRole.DisplayRole and self.index_header(index) != "fit": data = getattr(self.classlist[index.row()], param) # pyqt can't automatically coerce enums to strings... @@ -76,6 +76,7 @@ def data(self, index, role=QtCore.Qt.ItemDataRole.DisplayRole): return ", ".join(data) return data elif role == QtCore.Qt.ItemDataRole.CheckStateRole and self.index_header(index) == "fit": + data = getattr(self.classlist[index.row()], param) return QtCore.Qt.CheckState.Checked if data else QtCore.Qt.CheckState.Unchecked def setData(self, index, value, role=QtCore.Qt.ItemDataRole.EditRole) -> bool: @@ -95,13 +96,17 @@ def setData(self, index, value, role=QtCore.Qt.ItemDataRole.EditRole) -> bool: param = self.index_header(index) if param == "fit": value = QtCore.Qt.CheckState(value) == QtCore.Qt.CheckState.Checked + if param == "name" and self.classlist[row].name != value: + self.renamed_entries[self.classlist[row].name] = value + self.renamed_entries.pop(value, None) # avoid cycle if param is not None: current_value = getattr(self.classlist[index.row()], param) if current_value == value: # No change return False try: - with contextlib.suppress(UserWarning): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) setattr(self.classlist[row], param, value) except pydantic.ValidationError: return False @@ -162,6 +167,7 @@ def delete_item(self, row: int): The row containing the item to delete. """ + self.renamed_entries[self.classlist[row].name] = "" self.classlist.pop(row) self.endResetModel() @@ -520,6 +526,23 @@ def flags(self, index): flags |= QtCore.Qt.ItemFlag.ItemIsEditable return flags + def data(self, index, role=QtCore.Qt.ItemDataRole.DisplayRole): + param = self.index_header(index) + + if param is None: + return None + + if role == QtCore.Qt.ItemDataRole.DisplayRole: + data = getattr(self.classlist[index.row()], param) + if isinstance(data, Enum): + return str(data) + + changes = self.parent.parent.parent.renamed_parameters.get("parameters", {}) + if param != "name" and data in changes: + data = changes[data] + setattr(self.classlist[index.row()], param, data) + return data + def append_item(self): kwargs = {"thickness": "", "SLD": "", "roughness": ""} if self.absorption: @@ -622,6 +645,27 @@ def flags(self, index): flags |= QtCore.Qt.ItemFlag.ItemIsEditable return flags + def data(self, index, role=QtCore.Qt.ItemDataRole.DisplayRole): + param = self.index_header(index) + + if param is None: + return None + + if role == QtCore.Qt.ItemDataRole.DisplayRole: + data = getattr(self.classlist[index.row()], param) + + changes = self.parent.parent.parent.renamed_parameters.get("layers", {}) + if param != "name": + data_list = [] + for item in data: + new_item = changes.get(item, item) + if new_item: + data_list.append(new_item) + + data = ", ".join(data_list) + setattr(self.classlist[index.row()], param, data_list) + return data + class DomainContrastWidget(ProjectFieldWidget): """Subclass of field widgets for domain contrasts.""" @@ -887,6 +931,34 @@ def set_item_delegates(self): class AbstractSignalModel(ClassListTableModel): """Model for Signal objects (backgrounds and resolutions).""" + def data(self, index, role=QtCore.Qt.ItemDataRole.DisplayRole): + param = self.index_header(index) + + if param is None: + return None + + if role == QtCore.Qt.ItemDataRole.DisplayRole or role == QtCore.Qt.ItemDataRole.EditRole: + data = getattr(self.classlist[index.row()], param) + if isinstance(data, Enum): + return str(data) + + if param == "source" or param.startswith("value_"): + signal_type = self.classlist[index.row()].type + if signal_type == TypeOptions.Data and param == "source": + changes = self.parent.parent.parent.renamed_parameters.get("data", {}) + elif signal_type == TypeOptions.Function and param == "source": + changes = self.parent.parent.parent.renamed_parameters.get("custom_files", {}) + else: + name_key = ( + "background_parameters" if isinstance(self, BackgroundsModel) else "resolution_parameters" + ) + changes = self.parent.parent.parent.renamed_parameters.get(name_key, {}) + if data and data in changes: + data = changes[data] + setattr(self.classlist[index.row()], param, data) + + return data + def flags(self, index): flags = super().flags(index) if self.edit_mode: diff --git a/rascal2/widgets/terminal.py b/rascal2/widgets/terminal.py index a55c1cf3..362f2087 100644 --- a/rascal2/widgets/terminal.py +++ b/rascal2/widgets/terminal.py @@ -93,7 +93,7 @@ def write_error(self, text: str): The text to append. """ - self.write_html(f'
{text}
') + self.write_html(f'

{text}
') def clear(self): """Clear the text in the terminal.""" diff --git a/tests/widgets/project/test_models.py b/tests/widgets/project/test_models.py index 3ff88355..97121de3 100644 --- a/tests/widgets/project/test_models.py +++ b/tests/widgets/project/test_models.py @@ -30,6 +30,8 @@ def __init__(self): super().__init__() self.presenter = MagicMock() self.update_project = MagicMock() + self.parent = MagicMock() + self.parent.renamed_parameters = {} class DataModel(pydantic.BaseModel, validate_assignment=True): diff --git a/tests/widgets/project/test_project.py b/tests/widgets/project/test_project.py index 493e4b43..bc345489 100644 --- a/tests/widgets/project/test_project.py +++ b/tests/widgets/project/test_project.py @@ -254,15 +254,15 @@ def test_project_tab_update_model(classlist, param_classlist, edit_mode): @pytest.mark.parametrize( "input_params", [ - ([0, 1, 1, 2, 1], [0, 0, 3, 0, 1]), + ([0, 1, 1, 2, 1], [0, 0, 2, 0, 1]), ([0, 0, 0, 1, 0], [0, 0, 1, 1, 0]), - ([3, 3, 3, 2, 0], [0, 0, 3, 0, 1]), + ([2, 1, 1, 2, 0], [0, 0, 2, 0, 1]), ], ) @pytest.mark.parametrize("absorption", [True, False]) def test_project_tab_validate_layers(input_params, absorption): """Test that the project tab produces the correct result for validating the layers tab.""" - params = ["Param 1", "Param 2", "Invalid Param", ""] + params = ["Param 1", "Param 2", ""] if absorption: attrs = ["thickness", "SLD_real", "SLD_imaginary", "roughness", "hydration"] layer_class = ratapi.models.AbsorptionLayer @@ -278,17 +278,11 @@ def test_project_tab_validate_layers(input_params, absorption): expected_err = [] for i, layer in enumerate(layers): - missing_params = [p for j, p in enumerate(attrs) if input_params[i][j] == 3] - invalid_params = [p for j, p in enumerate(attrs) if input_params[i][j] == 2] + missing_params = [p for j, p in enumerate(attrs) if input_params[i][j] == 2 if p != "hydration"] if missing_params: noun = "a parameter" if len(missing_params) == 1 else "parameters" - msg = f"Layer '{layer.name}' (row {i + 1}) is missing {noun}: {', '.join(missing_params)}" - expected_err.append(msg) - if invalid_params: - noun = "an invalid value" if len(invalid_params) == 1 else "invalid values" - inner_msg = [f'"Invalid Param" for parameter {p}' for p in invalid_params] - msg = f"Layer '{layer.name}' (row {i + 1}) has {noun}: {', '.join(inner_msg)}" + msg = f"Layer {i + 1} ({layer.name}) is missing {noun}: {', '.join(missing_params)}" expected_err.append(msg) draft = create_draft_project(ratapi.Project()) @@ -301,6 +295,7 @@ def test_project_tab_validate_layers(input_params, absorption): ) project = ProjectWidget(parent) + project.renamed_parameters = {} project.draft_project = draft assert list(project.validate_layers()) == expected_err @@ -311,17 +306,17 @@ def test_project_tab_validate_layers(input_params, absorption): [ (Calculations.Normal, ([0, 1, 1, 2, 1], [0, 0, 1, 0, 1])), (Calculations.Normal, ([0, 0, 0, 1, 0], [0, 0, 1, 1, 0])), - (Calculations.Normal, ([0, 0, 0, 1, 0], [0, 0, 1, 3, 0])), - (Calculations.Normal, ([2, 2, 3, 2, 0], [0, 0, 2, 0, 1])), + (Calculations.Normal, ([0, 0, 0, 1, 0], [0, 0, 1, 2, 0])), + (Calculations.Normal, ([2, 2, 2, 2, 0], [0, 0, 2, 0, 1])), (Calculations.Domains, ([0, 1], [1, 1])), (Calculations.Domains, ([0, 2], [0, 1])), (Calculations.Domains, ([0, 1], [1, 2])), - (Calculations.Domains, ([2, 3], [1, 3])), + (Calculations.Domains, ([2, 2], [1, 2])), ], ) def test_project_tab_validate_contrast_models_standard(calculation, model_values, project_with_draft): """Test that contrast values are correctly validated for a standard layers calculation.""" - model_names = ["1", "2", "Invalid 1", "Invalid 2"] + model_names = ["1", "2", ""] models = [[model_names[i] for i in model_values[j]] for j in [0, 1]] contrasts = ratapi.ClassList( [ @@ -341,15 +336,17 @@ def test_project_tab_validate_contrast_models_standard(calculation, model_values expected_err = [] for i in [0, 1]: - invalid = [] - if 2 in model_values[i]: - invalid.append("Invalid 1") - if 3 in model_values[i]: - invalid.append("Invalid 2") - + invalid = ["mising"] * model_values[i].count(2) if invalid: - noun = "an invalid model value" if len(invalid) == 1 else "invalid model values" - msg = f"Contrast 'contrast {i}' (row {i + 1}) has {noun}: {{0}}".format(", ".join(invalid)) + if len(invalid) == 1: + noun = "an empty entry" + action = "empty entry with a" + suffix = "layer" if calculation == Calculations.Normal else "domain contrast" + else: + noun = "multiple empty entries" + action = "empty entries with" + suffix = "layers" if calculation == Calculations.Normal else "domain contrasts" + msg = f"Contrast {i + 1} (contrast {i}) has {noun} in the model. Please update the {action} valid {suffix}" expected_err.append(msg) draft = project_with_draft.draft_project @@ -376,7 +373,6 @@ def test_project_tab_validate_contrast_models_standard(calculation, model_values @pytest.mark.parametrize("calc_type", [LayerModels.CustomLayers, LayerModels.CustomXY]) def test_project_tab_validate_contrast_models_custom(contrast_models, calc_type, project_with_draft): """Test that contrast values are correctly validated for a custom layers/XY calculation.""" - custom_files = ["Custom File 1", "Invalid Custom File"] contrasts = ratapi.ClassList( [ ratapi.models.Contrast( @@ -387,7 +383,7 @@ def test_project_tab_validate_contrast_models_custom(contrast_models, calc_type, bulk_out="SLD D2O", scalefactor="Scalefactor 1", resolution="Resolution 1", - model=[custom_files[model_index]], + model=["Custom File 1"] if model_index == 0 else [], ) for i, model_index in enumerate(contrast_models) ] @@ -396,7 +392,9 @@ def test_project_tab_validate_contrast_models_custom(contrast_models, calc_type, expected_err = [] for i, model_index in enumerate(contrast_models): if model_index == 1: - expected_err.append(f"Contrast 'contrast {i}' (row {i + 1}) has invalid model: Invalid Custom File") + expected_err.append( + f"Contrast {i + 1} (contrast {i}) has no model. Please select a valid model for the contrast" + ) draft = project_with_draft.draft_project draft["model"] = calc_type