From 079035e377d3d748effddafedb5048f39409adc6 Mon Sep 17 00:00:00 2001 From: Antti Soininen Date: Mon, 29 Jul 2024 15:37:24 +0300 Subject: [PATCH] Implement parameter type validation in DB editor Parameter (default) values are now validated in a parallel process in Database editor. Re #2791 --- docs/source/spine_db_editor/adding_data.rst | 12 +- spinetoolbox/mvcmodels/shared.py | 5 + spinetoolbox/parameter_type_validation.py | 144 ++++++++++ .../mvcmodels/compound_models.py | 66 +++-- .../spine_db_editor/mvcmodels/empty_models.py | 8 +- .../mvcmodels/single_models.py | 108 ++++++-- .../widgets/custom_delegates.py | 41 ++- .../spine_db_editor/widgets/custom_editors.py | 31 ++- spinetoolbox/spine_db_manager.py | 84 +++++- .../mvcmodels/test_emptyParameterModels.py | 34 ++- .../mvcmodels/test_single_parameter_models.py | 29 +- tests/spine_db_editor/test_graphics_items.py | 1 - .../widgets/spine_db_editor_test_base.py | 252 +++++++++--------- .../widgets/test_SpineDBEditor.py | 2 + .../widgets/test_SpineDBEditorAdd.py | 26 +- .../widgets/test_SpineDBEditorFilter.py | 4 + .../widgets/test_SpineDBEditorUpdate.py | 47 ++-- .../widgets/test_custom_editors.py | 45 +++- .../widgets/test_custom_qtableview.py | 6 +- tests/test_SpineDBManager.py | 6 +- tests/test_parameter_type_validation.py | 111 ++++++++ 21 files changed, 808 insertions(+), 254 deletions(-) create mode 100644 spinetoolbox/parameter_type_validation.py create mode 100644 tests/test_parameter_type_validation.py diff --git a/docs/source/spine_db_editor/adding_data.rst b/docs/source/spine_db_editor/adding_data.rst index 4a31e45c2..6a53816b9 100644 --- a/docs/source/spine_db_editor/adding_data.rst +++ b/docs/source/spine_db_editor/adding_data.rst @@ -171,7 +171,13 @@ Only two of the fields are required when creating a new parameter definition: *e *parameter_name*. Enter the name of the class under *entity_class_name*. To display a list of available entity classes, start typing in the empty cell or double click it. For the name of the parameter choose something that isn't already defined for the specified entity class. Optionally, you can also -specify a parameter value list, a default value and a description. +specify valid value types, a parameter value list, a default value and a description. + +The *valid types* column defines value types that are valid for the parameter. +An empty field means that all types are valid. +All values are validated against this column and non-valid types are marked invalid +in the *default_value* and *value* (in Parameter value table) columns. +Valid types are not enforced, however, so it is still possible to commit values of invalid type to the database. In the column *value_list_name* a name for a parameter value list can be selected. Leaving this field empty means that later on when creating parameter values with this definition, the values are arbitrary. Meaning that @@ -182,7 +188,9 @@ see :ref:`parameter_value_list`. In the *default_value* field, the default value can be set. The default value can be used in cases where the value is not specified. The usage of *default_value* is really tool dependent, meaning that the Spine Database Editor doesn't use the information of the default value anywhere, but it is instead left to the tool creators on how to -utilize the default value. A short description for the parameter can be written in the *description* column. +utilize the default value. + +A short description for the parameter can be written in the *description* column. The parameter is added when the background of the cells under *entity_class_name* and *database* become gray. diff --git a/spinetoolbox/mvcmodels/shared.py b/spinetoolbox/mvcmodels/shared.py index 487754ab3..5daed1541 100644 --- a/spinetoolbox/mvcmodels/shared.py +++ b/spinetoolbox/mvcmodels/shared.py @@ -15,3 +15,8 @@ PARSED_ROLE = Qt.ItemDataRole.UserRole DB_MAP_ROLE = Qt.ItemDataRole.UserRole + 1 +PARAMETER_TYPE_VALIDATION_ROLE = Qt.ItemDataRole.UserRole + 2 + +INVALID_TYPE = 0 +TYPE_NOT_VALIDATED = 1 +VALID_TYPE = 2 diff --git a/spinetoolbox/parameter_type_validation.py b/spinetoolbox/parameter_type_validation.py new file mode 100644 index 000000000..f09bbc57d --- /dev/null +++ b/spinetoolbox/parameter_type_validation.py @@ -0,0 +1,144 @@ +###################################################################################################################### +# Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors +# This file is part of Spine Toolbox. +# Spine Toolbox is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General +# Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) +# any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General +# Public License for more details. You should have received a copy of the GNU Lesser General Public License along with +# this program. If not, see . +###################################################################################################################### +"""Contains utilities for validating parameter types.""" +from dataclasses import dataclass +from multiprocessing import Pipe, Process +from typing import Any, Iterable, Optional, Tuple +from PySide6.QtCore import QObject, QTimer, Signal, Slot +from spinedb_api.db_mapping_helpers import is_parameter_type_valid, type_check_args + +CHUNK_SIZE = 20 + + +@dataclass(frozen=True) +class ValidationKey: + item_type: str + db_map_id: int + item_private_id: int + + +@dataclass(frozen=True) +class ValidatableValue: + key: ValidationKey + args: Tuple[Iterable[str], Optional[bytes], Optional[Any], Optional[str]] + + +class ParameterTypeValidator(QObject): + """Handles parameter type validation in a concurrent process.""" + + validated = Signal(ValidationKey, bool) + + def __init__(self, parent=None): + """ + Args: + parent (QObject, optional): parent object + """ + super().__init__(parent) + self._connection, scheduler_connection = Pipe() + self._process = Process(target=schedule, name="Type validation worker", args=(scheduler_connection,)) + self._timer = QTimer(self) + self._timer.setInterval(100) + self._timer.timeout.connect(self._communicate) + self._task_queue = [] + self._sent_task_count = 0 + + def set_interval(self, interval): + """Sets the interval between communication attempts with the validation process. + + Args: + interval (int): interval in milliseconds + """ + self._timer.setInterval(interval) + + def start_validating(self, db_mngr, db_map, value_item_ids): + """Initiates validation of given parameter definition/value items. + + Args: + db_mngr (SpineDBManager): database manager + db_map (DatabaseMapping): database mapping + value_item_ids (Iterable of TempId): item ids to validate + """ + if not self._process.is_alive(): + self._process.start() + for item_id in value_item_ids: + item = db_mngr.get_item(db_map, item_id.item_type, item_id) + args = type_check_args(item) + self._task_queue.append( + ValidatableValue(ValidationKey(item_id.item_type, id(db_map), item_id.private_id), args) + ) + self._sent_task_count += 1 + if not self._timer.isActive(): + chunk = self._task_queue[:CHUNK_SIZE] + self._task_queue = self._task_queue[CHUNK_SIZE:] + self._connection.send(chunk) + self._timer.start() + + @Slot() + def _communicate(self): + """Communicates with the validation process.""" + self._timer.stop() + if self._connection.poll(): + results = self._connection.recv() + for key, result in results.items(): + self.validated.emit(key, result) + self._sent_task_count -= len(results) + if self._task_queue and self._sent_task_count < 3 * CHUNK_SIZE: + chunk = self._task_queue[:CHUNK_SIZE] + self._task_queue = self._task_queue[CHUNK_SIZE:] + self._connection.send(chunk) + if not self._task_queue and self._sent_task_count == 0: + return + self._timer.start() + + def tear_down(self): + """Cleans up the validation process.""" + self._timer.stop() + if self._process.is_alive(): + self._connection.send("quit") + self._process.join() + + +def validate_chunk(validatable_values): + """Validates given parameter definitions/values. + + Args: + validatable_values (Iterable of ValidatableValue): values to validate + + Returns: + dict: mapping from ValidationKey to boolean + """ + results = {} + for validatable_value in validatable_values: + results[validatable_value.key] = is_parameter_type_valid(*validatable_value.args) + return results + + +def schedule(connection): + """Loops over incoming messages and sends responses back. + + Args: + connection (Connection): A duplex Pipe end + """ + validatable_values = [] + while True: + if connection.poll() or not validatable_values: + while True: + task = connection.recv() + if task == "quit": + return + validatable_values += task + if not connection.poll(): + break + chunk = validatable_values[:CHUNK_SIZE] + validatable_values = validatable_values[CHUNK_SIZE:] + results = validate_chunk(chunk) + connection.send(results) diff --git a/spinetoolbox/spine_db_editor/mvcmodels/compound_models.py b/spinetoolbox/spine_db_editor/mvcmodels/compound_models.py index fe2546a60..735944a1a 100644 --- a/spinetoolbox/spine_db_editor/mvcmodels/compound_models.py +++ b/spinetoolbox/spine_db_editor/mvcmodels/compound_models.py @@ -11,6 +11,7 @@ ###################################################################################################################### """ Compound models. These models concatenate several 'single' models and one 'empty' model. """ +from typing import ClassVar from PySide6.QtCore import QModelIndex, Qt, QTimer, Slot from PySide6.QtGui import QFont from spinedb_api.parameter_value import join_value_and_type @@ -25,6 +26,8 @@ class CompoundModelBase(CompoundWithEmptyTableModel): """A base model for all models that show data in stacked format.""" + item_type: ClassVar[str] = NotImplemented + def __init__(self, parent, db_mngr, *db_maps): """ Args: @@ -65,15 +68,6 @@ def column_filters(self): def field_map(self): return {} - @property - def item_type(self): - """Returns the DB item type, e.g., 'parameter_value'. - - Returns: - str - """ - raise NotImplementedError() - @property def _single_model_type(self): """ @@ -318,7 +312,7 @@ def _items_per_class(items): def handle_items_added(self, db_map_data): """Runs when either parameter definitions or values are added to the dbs. Adds necessary sub-models and initializes them with data. - Also notifies the empty model so it can remove rows that are already in. + Also notifies the empty model, so it can remove rows that are already in. Args: db_map_data (dict): list of added dict-items keyed by DatabaseMapping @@ -493,6 +487,19 @@ def _create_single_model(self, db_map, entity_class_id, committed): class EditParameterValueMixin: """Provides the interface to edit values via ParameterValueEditor.""" + def handle_items_updated(self, db_map_data): + super().handle_items_updated(db_map_data) + for db_map, items in db_map_data.items(): + if db_map not in self.db_maps: + continue + items_by_class = self._items_per_class(items) + for entity_class_id, class_items in items_by_class.items(): + single_model = next( + (m for m in self.single_models if (m.db_map, m.entity_class_id) == (db_map, entity_class_id)), None + ) + if single_model is not None: + single_model.revalidate_item_types(class_items) + def index_name(self, index): """Generates a name for data at given index. @@ -544,9 +551,7 @@ def get_set_data_delayed(self, index): class CompoundParameterDefinitionModel(EditParameterValueMixin, CompoundModelBase): """A model that concatenates several single parameter_definition models and one empty parameter_definition model.""" - @property - def item_type(self): - return "parameter_definition" + item_type = "parameter_definition" def _make_header(self): return [ @@ -579,9 +584,16 @@ def _empty_model_type(self): class CompoundParameterValueModel(FilterEntityAlternativeMixin, EditParameterValueMixin, CompoundModelBase): """A model that concatenates several single parameter_value models and one empty parameter_value model.""" - @property - def item_type(self): - return "parameter_value" + item_type = "parameter_value" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._definition_fetch_parent = FlexibleFetchParent( + "parameter_definition", + shows_item=lambda item, db_map: True, + handle_items_updated=self._handle_parameter_definitions_updated, + owner=self, + ) def _make_header(self): return [ @@ -605,11 +617,27 @@ def _single_model_type(self): def _empty_model_type(self): return EmptyParameterValueModel + def reset_db_map(self, db_maps): + super().reset_db_maps(db_maps) + self._definition_fetch_parent.set_obsolete(False) + self._definition_fetch_parent.reset() + + def _handle_parameter_definitions_updated(self, db_map_data): + for db_map, items in db_map_data.items(): + if db_map not in self.db_maps: + continue + items_by_class = self._items_per_class(items) + for entity_class_id, class_items in items_by_class.items(): + single_model = next( + (m for m in self.single_models if (m.db_map, m.entity_class_id) == (db_map, entity_class_id)), None + ) + if single_model is not None: + single_model.revalidate_item_typs(class_items) + class CompoundEntityAlternativeModel(FilterEntityAlternativeMixin, CompoundModelBase): - @property - def item_type(self): - return "entity_alternative" + + item_type = "entity_alternative" def _make_header(self): return [ diff --git a/spinetoolbox/spine_db_editor/mvcmodels/empty_models.py b/spinetoolbox/spine_db_editor/mvcmodels/empty_models.py index 02504dbc3..e4663f4fd 100644 --- a/spinetoolbox/spine_db_editor/mvcmodels/empty_models.py +++ b/spinetoolbox/spine_db_editor/mvcmodels/empty_models.py @@ -45,7 +45,7 @@ def add_items_to_db(self, db_map_data): """Add items to db. Args: - db_map_data (dict): mapping DiffDatabaseMapping instance to list of items + db_map_data (dict): mapping DatabaseMapping instance to list of items """ db_map_items = {} db_map_error_log = {} @@ -161,7 +161,7 @@ def _make_db_map_data(self, rows): rows (set): group data from these rows Returns: - dict: mapping DiffDatabaseMapping instance to list of items + dict: mapping DatabaseMapping instance to list of items """ items = [self._make_item(row) for row in rows] db_map_data = {} @@ -187,12 +187,12 @@ def value_field(self): return {"parameter_value": "value", "parameter_definition": "default_value"}[self.item_type] def data(self, index, role=Qt.ItemDataRole.DisplayRole): - if self.header[index.column()] == self.value_field and role in ( + if self.header[index.column()] == self.value_field and role in { Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.ToolTipRole, Qt.ItemDataRole.TextAlignmentRole, PARSED_ROLE, - ): + }: data = super().data(index, role=Qt.ItemDataRole.EditRole) return self.db_mngr.get_value_from_data(data, role) return super().data(index, role) diff --git a/spinetoolbox/spine_db_editor/mvcmodels/single_models.py b/spinetoolbox/spine_db_editor/mvcmodels/single_models.py index d92a2a4b5..885fb1871 100644 --- a/spinetoolbox/spine_db_editor/mvcmodels/single_models.py +++ b/spinetoolbox/spine_db_editor/mvcmodels/single_models.py @@ -11,11 +11,12 @@ ###################################################################################################################### """Single models for parameter definitions and values (as 'for a single entity').""" -from typing import Iterable -from PySide6.QtCore import Qt +from typing import ClassVar, Iterable +from PySide6.QtCore import Qt, Slot +from PySide6.QtGui import QColor from spinetoolbox.helpers import DB_ITEM_SEPARATOR, order_key, plain_to_rich from ...mvcmodels.minimal_table_model import MinimalTableModel -from ...mvcmodels.shared import DB_MAP_ROLE, PARSED_ROLE +from ...mvcmodels.shared import DB_MAP_ROLE, PARAMETER_TYPE_VALIDATION_ROLE, PARSED_ROLE from ..mvcmodels.single_and_empty_model_mixins import MakeEntityOnTheFlyMixin, SplitValueAndTypeMixin from .colors import FIXED_FIELD_COLOR @@ -45,7 +46,8 @@ def _sort_key(self, element): class SingleModelBase(HalfSortedTableModel): """Base class for all single models that go in a CompoundModelBase subclass.""" - group_fields: Iterable[str] = () + item_type: ClassVar[str] = NotImplemented + group_fields: ClassVar[Iterable[str]] = () def __init__(self, parent, db_map, entity_class_id, committed, lazy=False): """ @@ -54,6 +56,7 @@ def __init__(self, parent, db_map, entity_class_id, committed, lazy=False): db_map (DatabaseMapping) entity_class_id (int) committed (bool) + lazy (bool) """ super().__init__(parent=parent, header=parent.header, lazy=lazy) self.db_mngr = parent.db_mngr @@ -75,11 +78,6 @@ def __lt__(self, other): ) return keys["left"] < keys["right"] - @property - def item_type(self): - """The DB item type, required by the data method.""" - raise NotImplementedError() - @property def field_map(self): return self._parent.field_map @@ -298,13 +296,13 @@ def _alternative_filter_accepts_item(self, item): class ParameterMixin: """Provides the data method for parameter values and definitions.""" - @property - def value_field(self): - return {"parameter_definition": "default_value", "parameter_value": "value"}[self.item_type] + value_field: ClassVar[str] = NotImplemented + parameter_definition_id_key: ClassVar[str] = NotImplemented - @property - def parameter_definition_id_key(self): - return {"parameter_definition": "id", "parameter_value": "parameter_id"}[self.item_type] + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._ids_pending_type_validation = set() + self.destroyed.connect(self._stop_waiting_validation) @property def _references(self): @@ -320,6 +318,15 @@ def _references(self): "alternative_name": ("alternative_id", "alternative"), } + def reset_model(self, main_data=None): + """Resets the model.""" + super().reset_model(main_data) + if self._ids_pending_type_validation: + self.db_mngr.parameter_type_validator.validated.disconnect(self._parameter_type_validated) + self._ids_pending_type_validation.clear() + if main_data: + self._start_validating_types(main_data) + def data(self, index, role=Qt.ItemDataRole.DisplayRole): """Gets the id and database for the row, and reads data from the db manager using the item_type property. @@ -327,18 +334,66 @@ def data(self, index, role=Qt.ItemDataRole.DisplayRole): Also paint background of fixed indexes gray and apply custom format to JSON fields.""" field = self.header[index.column()] # Display, edit, tool tip, alignment role of 'value fields' - if field == self.value_field and role in ( + if field == self.value_field and role in { Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole, Qt.ItemDataRole.ToolTipRole, - Qt.TextAlignmentRole, + Qt.ItemDataRole.TextAlignmentRole, PARSED_ROLE, - ): + PARAMETER_TYPE_VALIDATION_ROLE, + }: id_ = self._main_data[index.row()] item = self.db_mngr.get_item(self.db_map, self.item_type, id_) return self.db_mngr.get_value(self.db_map, item, role) return super().data(index, role) + def add_rows(self, ids): + super().add_rows(ids) + self._start_validating_types(ids) + + def revalidate_item_types(self, items): + ids = tuple(item["id"] for item in items) + self._start_validating_types(ids) + + def _start_validating_types(self, ids): + """""" + private_ids = set(temp_id.private_id for temp_id in ids) + new_ids = private_ids - self._ids_pending_type_validation + if not new_ids: + return + self._ids_pending_type_validation |= new_ids + self.db_mngr.parameter_type_validator.validated.connect( + self._parameter_type_validated, Qt.ConnectionType.UniqueConnection + ) + self.db_mngr.parameter_type_validator.start_validating( + self.db_mngr, self.db_map, (id_ for id_ in ids if id_.private_id in new_ids) + ) + + def _parameter_type_validated(self, key, is_valid): + """Notifies the model that values have been validated. + + Args: + key (ValidationKey): validation key + is_valid (bool): True if value type is valid, False otherwise + """ + if key.item_type != self.item_type or key.db_map_id != id(self.db_map): + return + self._ids_pending_type_validation.discard(key.item_private_id) + if not self._ids_pending_type_validation: + self.db_mngr.parameter_type_validator.validated.disconnect(self._parameter_type_validated) + value_column = self.header.index(self.value_field) + for row, id_ in enumerate(self._main_data): + if id_.private_id == key.item_private_id: + self.dataChanged.emit(self.index(row, value_column), [PARAMETER_TYPE_VALIDATION_ROLE]) + break + + @Slot(object) + def _stop_waiting_validation(self): + """Stops the model from waiting for type validation notifications.""" + if self._ids_pending_type_validation: + self.db_mngr.parameter_type_validator.validated.disconnect(self._parameter_type_validated) + self._ids_pending_type_validation.clear() + class EntityMixin: group_fields = ("entity_byname",) @@ -368,12 +423,11 @@ def _do_update_items_in_db(self, db_map_data): class SingleParameterDefinitionModel(SplitValueAndTypeMixin, ParameterMixin, SingleModelBase): """A parameter_definition model for a single entity_class.""" + item_type = "parameter_definition" + value_field = "default_value" + parameter_definition_id_key = "id" group_fields = ("valid types",) - @property - def item_type(self): - return "parameter_definition" - def _sort_key(self, element): item = self.db_item_from_id(element) return order_key(item.get("name", "")) @@ -392,9 +446,9 @@ class SingleParameterValueModel( ): """A parameter_value model for a single entity_class.""" - @property - def item_type(self): - return "parameter_value" + item_type = "parameter_value" + value_field = "value" + parameter_definition_id_key = "parameter_id" def _sort_key(self, element): item = self.db_item_from_id(element) @@ -410,9 +464,7 @@ def _do_update_items_in_db(self, db_map_data): class SingleEntityAlternativeModel(MakeEntityOnTheFlyMixin, EntityMixin, FilterEntityAlternativeMixin, SingleModelBase): """An entity_alternative model for a single entity_class.""" - @property - def item_type(self): - return "entity_alternative" + item_type = "entity_alternative" def _sort_key(self, element): item = self.db_item_from_id(element) diff --git a/spinetoolbox/spine_db_editor/widgets/custom_delegates.py b/spinetoolbox/spine_db_editor/widgets/custom_delegates.py index afb427e29..98ec8ffb7 100644 --- a/spinetoolbox/spine_db_editor/widgets/custom_delegates.py +++ b/spinetoolbox/spine_db_editor/widgets/custom_delegates.py @@ -13,7 +13,7 @@ """Custom item delegates.""" from numbers import Number from PySide6.QtCore import QEvent, QModelIndex, QRect, QSize, Qt, Signal -from PySide6.QtGui import QFontMetrics, QIcon +from PySide6.QtGui import QColor, QFont, QFontMetrics, QIcon from PySide6.QtWidgets import QStyledItemDelegate from spinedb_api import to_database from spinedb_api.parameter_value import join_value_and_type @@ -29,7 +29,7 @@ SearchBarEditorWithCreation, ) from ...helpers import object_icon -from ...mvcmodels.shared import DB_MAP_ROLE, PARSED_ROLE +from ...mvcmodels.shared import DB_MAP_ROLE, INVALID_TYPE, PARAMETER_TYPE_VALIDATION_ROLE, PARSED_ROLE from ...widgets.custom_delegates import CheckBoxDelegate, RankDelegate from ..mvcmodels.metadata_table_model_base import Column as MetadataColumn @@ -280,20 +280,51 @@ def createEditor(self, parent, option, index): return editor +def _make_exclamation_font(): + """Creates font for invalid parameter type notification. + + Returns: + QFont: font + """ + font = QFont("Font Awesome 5 Free Solid") + font.setPixelSize(12) + return font + + class ParameterValueOrDefaultValueDelegate(TableDelegate): """A delegate for either the value or the default value.""" parameter_value_editor_requested = Signal(QModelIndex) + EXCLAMATION_FONT = _make_exclamation_font() + EXCLAMATION_COLOR = QColor("red") + INDICATOR_WIDTH = 18 def __init__(self, parent, db_mngr): """ Args: parent (QWidget): parent widget - db_mngr (SpineDatabaseManager): database manager + db_mngr (SpineDBManager): database manager """ super().__init__(parent, db_mngr) self._db_value_list_lookup = {} + def paint(self, painter, option, index): + validation_state = index.data(PARAMETER_TYPE_VALIDATION_ROLE) + if validation_state == INVALID_TYPE: + left = option.rect.x() + width = option.rect.width() + height = option.rect.height() + indicator_left = left + width - self.INDICATOR_WIDTH + indicator_rect = QRect(indicator_left, option.rect.y(), self.INDICATOR_WIDTH, height) + option.rect.setRight(indicator_left) + text_position = indicator_rect.center() + text_position.setY(text_position.y() + 5) + text_position.setX(text_position.x() - 5) + painter.setFont(self.EXCLAMATION_FONT) + painter.setPen(self.EXCLAMATION_COLOR) + painter.drawText(text_position, "\uf06a") + super().paint(painter, option, index) + def setModelData(self, editor, model, index): """Send signal.""" display_value = editor.data() @@ -325,7 +356,7 @@ def _get_value_list_id(self, index, db_map): Args: index (QModelIndex): value list's index - db_map (DiffDatabaseMapping): database mapping + db_map (DatabaseMapping): database mapping Returns: int: value list id @@ -981,6 +1012,8 @@ def sizeHint(self, option, index): class ParameterTypeListDelegate(QStyledItemDelegate): + """Delegate for the 'valid types' column in Parameter definition table.""" + data_committed = Signal(QModelIndex, object) def __init__(self, db_editor, db_mngr): diff --git a/spinetoolbox/spine_db_editor/widgets/custom_editors.py b/spinetoolbox/spine_db_editor/widgets/custom_editors.py index b78f31776..39300221c 100644 --- a/spinetoolbox/spine_db_editor/widgets/custom_editors.py +++ b/spinetoolbox/spine_db_editor/widgets/custom_editors.py @@ -580,6 +580,8 @@ def data(self): class ParameterTypeEditor(QWidget): + """Editor to select valid parameter types.""" + def __init__(self, parent): """ Args: @@ -594,6 +596,7 @@ def __init__(self, parent): self._ui.select_all_button.clicked.connect(self._select_all) self._ui.clear_all_button.clicked.connect(self._clear_all) self._ui.map_rank_line_edit.textEdited.connect(self._ensure_map_selected) + self._ui.map_check_box.clicked.connect(self._edit_rank) def data(self): """Returns editor's data. @@ -630,7 +633,7 @@ def set_data(self, type_list): type_list (str): parameter type list separated by DB_ITEM_SEPARATOR """ if not type_list: - self._select_all() + self._clear_all() else: self._clear_all() map_ranks = [] @@ -643,12 +646,18 @@ def set_data(self, type_list): self._ui.map_rank_line_edit.setText(", ".join(map_ranks)) def _check_box_iter(self): + """Yields type check boxes. + + Yields: + QCheckBox: type check box + """ for attribute in dir(self._ui): if attribute.endswith("_check_box"): yield getattr(self._ui, attribute) @Slot(bool) def _select_all(self, _=True): + """Selects all check boxes.""" for check_box in self._check_box_iter(): check_box.setChecked(True) if not self._ui.map_rank_line_edit.text().strip(): @@ -656,13 +665,33 @@ def _select_all(self, _=True): @Slot(bool) def _clear_all(self, _=True): + """Clears all check boxes.""" for check_box in self._check_box_iter(): check_box.setChecked(False) @Slot(str) def _ensure_map_selected(self, rank_text): + """Makes sure the map check box is checked. + + Args: + rank_text (str): text in the rank line edit + """ if rank_text: if not self._ui.map_check_box.isChecked(): self._ui.map_check_box.setChecked(True) elif self._ui.map_check_box.isChecked(): self._ui.map_check_box.setChecked(False) + + @Slot(bool) + def _edit_rank(self, map_checked): + """Focuses on the rank line edit and select all its contents if map has been checked. + + Args: + map_checked (bool): map checkbox state + """ + if not map_checked: + return + if not self._ui.map_rank_line_edit.text(): + self._ui.map_rank_line_edit.setText("1") + self._ui.map_rank_line_edit.selectAll() + self._ui.map_rank_line_edit.setFocus(Qt.FocusReason.OtherFocusReason) diff --git a/spinetoolbox/spine_db_manager.py b/spinetoolbox/spine_db_manager.py index 89b4599d3..07f209269 100644 --- a/spinetoolbox/spine_db_manager.py +++ b/spinetoolbox/spine_db_manager.py @@ -11,6 +11,7 @@ ###################################################################################################################### """The SpineDBManager class.""" +from contextlib import suppress import json import os from PySide6.QtCore import QObject, Qt, Signal, Slot @@ -48,7 +49,8 @@ ) from spinedb_api.spine_io.exporters.excel import export_spine_database_to_xlsx from .helpers import busy_effect, plain_to_tool_tip -from .mvcmodels.shared import PARSED_ROLE +from .mvcmodels.shared import INVALID_TYPE, PARAMETER_TYPE_VALIDATION_ROLE, PARSED_ROLE, TYPE_NOT_VALIDATED, VALID_TYPE +from .parameter_type_validation import ParameterTypeValidator, ValidationKey from .spine_db_commands import ( AddItemsCommand, AddUpdateItemsCommand, @@ -115,11 +117,18 @@ def __init__(self, settings, parent, synchronous=False): self._cmd_id = 0 self._synchronous = synchronous self.data_stores = {} + self._validated_values = {"parameter_definition": {}, "parameter_value": {}} + self._parameter_type_validator = ParameterTypeValidator(self) + self._parameter_type_validator.validated.connect(self._parameter_value_validated) def _connect_signals(self): self.error_msg.connect(self.receive_error_msg) qApp.aboutToQuit.connect(self.clean_up) # pylint: disable=undefined-variable + @property + def parameter_type_validator(self) -> ParameterTypeValidator: + return self._parameter_type_validator + @Slot(object) def receive_error_msg(self, db_map_error_log): for db_map, error_log in db_map_error_log.items(): @@ -314,6 +323,8 @@ def close_session(self, url): if worker is not None: worker.close_db_map() # NOTE: This calls ThreadPoolExecutor.shutdown() which waits for Futures to finish worker.clean_up() + del self._validated_values["parameter_definition"][id(db_map)] + del self._validated_values["parameter_value"][id(db_map)] del self.undo_stack[db_map] del self.undo_action[db_map] del self.redo_action[db_map] @@ -389,6 +400,8 @@ def _do_get_db_map(self, url, **kwargs): raise error self._workers[db_map] = worker self._db_maps[url] = db_map + self._validated_values["parameter_definition"][id(db_map)] = {} + self._validated_values["parameter_value"][id(db_map)] = {} stack = self.undo_stack[db_map] = AgedUndoStack(self) self.undo_action[db_map] = stack.createUndoAction(self) self.redo_action[db_map] = stack.createRedoAction(self) @@ -563,6 +576,7 @@ def clean_up(self): while self._workers: _, worker = self._workers.popitem() worker.clean_up() + self._parameter_type_validator.tear_down() self.deleteLater() def refresh_session(self, *db_maps): @@ -677,10 +691,13 @@ def _do_rollback_session(self, db_map): """ try: db_map.rollback_session() - self.undo_stack[db_map].clear() - self.receive_session_rolled_back({db_map}) except SpineDBAPIError as err: self.error_msg.emit({db_map: [err.msg]}) + return + self._validated_values["parameter_definition"][id(db_map)].clear() + self._validated_values["parameter_value"][id(db_map)].clear() + self.undo_stack[db_map].clear() + self.receive_session_rolled_back({db_map}) def entity_class_renderer(self, db_map, entity_class_id, for_group=False, color=None): """Returns an icon renderer for a given entity class. @@ -816,6 +833,24 @@ def tool_tip_data_from_parsed(parsed_data): tool_tip_data = None return plain_to_tool_tip(tool_tip_data) + def _tool_tip_for_invalid_parameter_type(self, item): + """Returns tool tip for parameter (default) values that have an invalid type. + + Args: + item (PublicItem): + + Returns: + str: tool tip + """ + if item.item_type == "parameter_value": + definition = self.get_item(item.db_map, "parameter_definition", item["parameter_definition_id"]) + else: + definition = item + type_list = definition["parameter_type_list"] + if len(type_list) == 1: + return plain_to_tool_tip(f"Expected value's type to be {type_list[0]}.") + return plain_to_tool_tip(f"Expected value's type to be one of {', '.join(type_list)}.") + def _format_list_value(self, db_map, item_type, value, list_value_id): list_value = self.get_item(db_map, "list_value", list_value_id) if not list_value: @@ -838,18 +873,32 @@ def get_value(self, db_map, item, role=Qt.ItemDataRole.DisplayRole): role (Qt.ItemDataRole): data role Returns: - any + Any: """ if not item: return None + if role == PARAMETER_TYPE_VALIDATION_ROLE: + try: + is_valid = self._validated_values[item.item_type][id(db_map)][item["id"].private_id] + except KeyError: + return TYPE_NOT_VALIDATED + return VALID_TYPE if is_valid else INVALID_TYPE + if role == Qt.ItemDataRole.ToolTipRole: + try: + is_valid = self._validated_values[item.item_type][id(db_map)][item["id"].private_id] + except KeyError: + pass + else: + if not is_valid: + return self._tool_tip_for_invalid_parameter_type(item) value_field, type_field = { "parameter_value": ("value", "type"), "list_value": ("value", "type"), "parameter_definition": ("default_value", "default_type"), }[item.item_type] - list_value_id = item["id"] if item.item_type == "list_value" else item["list_value_id"] complex_types = {"array": "Array", "time_series": "Time series", "time_pattern": "Time pattern", "map": "Map"} if role == Qt.ItemDataRole.DisplayRole and item[type_field] in complex_types: + list_value_id = item["id"] if item.item_type == "list_value" else item["list_value_id"] return self._format_list_value(db_map, item.item_type, complex_types[item[type_field]], list_value_id) if role == Qt.ItemDataRole.EditRole: return join_value_and_type(item[value_field], item[type_field]) @@ -1362,6 +1411,8 @@ def update_items(self, item_type, db_map_data, identifier=None, **kwargs): """Pushes commands to update items to undo stack.""" if identifier is None: identifier = self.get_command_identifier() + if item_type in ("parameter_definition", "parameter_value"): + self._clear_validated_value_ids(item_type, db_map_data) for db_map, data in db_map_data.items(): self.undo_stack[db_map].push( UpdateItemsCommand(self, db_map, item_type, data, identifier=identifier, **kwargs) @@ -1371,6 +1422,8 @@ def add_update_items(self, item_type, db_map_data, identifier=None, **kwargs): """Pushes commands to add_update items to undo stack.""" if identifier is None: identifier = self.get_command_identifier() + if item_type in ("parameter_definition", "parameter_value"): + self._clear_validated_value_ids(item_type, db_map_data) for db_map, data in db_map_data.items(): self.undo_stack[db_map].push( AddUpdateItemsCommand(self, db_map, item_type, data, identifier=identifier, **kwargs) @@ -1382,6 +1435,14 @@ def remove_items(self, db_map_typed_ids, identifier=None, **kwargs): identifier = self.get_command_identifier() for db_map, ids_per_type in db_map_typed_ids.items(): for item_type, ids in ids_per_type.items(): + if item_type in ("parameter_definition", "parameter_value"): + if Asterisk in ids: + self._validated_values[item_type][id(db_map)].clear() + else: + validated_values = self._validated_values[item_type][id(db_map)] + for id_ in ids: + with suppress(KeyError): + del validated_values[id_.private_id] self.undo_stack[db_map].push( RemoveItemsCommand(self, db_map, item_type, ids, identifier=identifier, **kwargs) ) @@ -1728,3 +1789,16 @@ def open_db_editor(self, db_url_codenames, reuse_existing_editor): if multi_db_editor.isMinimized(): multi_db_editor.showNormal() multi_db_editor.activateWindow() + + @Slot(ValidationKey, bool) + def _parameter_value_validated(self, key, is_valid): + with suppress(KeyError): + self._validated_values[key.item_type][key.db_map_id][key.item_private_id] = is_valid + + def _clear_validated_value_ids(self, item_type, db_map_data): + db_map_validated_values = self._validated_values[item_type] + for db_map, data in db_map_data.items(): + validated_values = db_map_validated_values[id(db_map)] + for item in data: + with suppress(KeyError): + del validated_values[item["id"].private_id] diff --git a/tests/spine_db_editor/mvcmodels/test_emptyParameterModels.py b/tests/spine_db_editor/mvcmodels/test_emptyParameterModels.py index db4c6479c..26c461796 100644 --- a/tests/spine_db_editor/mvcmodels/test_emptyParameterModels.py +++ b/tests/spine_db_editor/mvcmodels/test_emptyParameterModels.py @@ -22,7 +22,7 @@ import_relationship_parameters, import_relationships, ) -from spinedb_api.parameter_value import join_value_and_type +from spinedb_api.parameter_value import join_value_and_type, to_database from spinetoolbox.helpers import DB_ITEM_SEPARATOR from spinetoolbox.spine_db_editor.mvcmodels.compound_models import ( CompoundParameterDefinitionModel, @@ -75,10 +75,11 @@ def test_add_object_parameter_values_to_db(self): """Test that object parameter values are added to the db when editing the table.""" model = TestEmptyParameterValueModel(self._db_mngr) fetch_model(model) + value, value_type = to_database("bloodhound") self.assertTrue( model.batch_set_data( _empty_indexes(model), - ["dog", "pluto", "breed", "Base", join_value_and_type(b'"bloodhound"', None), "mock_db"], + ["dog", "pluto", "breed", "Base", join_value_and_type(value, value_type), "mock_db"], ) ) values = self._db_mngr.get_items(self._db_map, "parameter_value") @@ -86,7 +87,7 @@ def test_add_object_parameter_values_to_db(self): self.assertEqual(values[0]["entity_class_name"], "dog") self.assertEqual(values[0]["entity_name"], "pluto") self.assertEqual(values[0]["parameter_name"], "breed") - self.assertEqual(values[0]["value"], b'"bloodhound"') + self.assertEqual(values[0]["value"], value) def test_do_not_add_invalid_object_parameter_values(self): """Test that object parameter values aren't added to the db if data is incomplete.""" @@ -103,9 +104,10 @@ def test_infer_class_from_object_and_parameter(self): model = TestEmptyParameterValueModel(self._db_mngr) fetch_model(model) indexes = _empty_indexes(model) + value, value_type = to_database("bloodhound") self.assertTrue( model.batch_set_data( - indexes, ["cat", "pluto", "breed", "Base", join_value_and_type(b'"bloodhound"', None), "mock_db"] + indexes, ["cat", "pluto", "breed", "Base", join_value_and_type(value, value_type), "mock_db"] ) ) self.assertEqual(indexes[0].data(), "dog") @@ -114,12 +116,13 @@ def test_infer_class_from_object_and_parameter(self): self.assertEqual(values[0]["entity_class_name"], "dog") self.assertEqual(values[0]["entity_name"], "pluto") self.assertEqual(values[0]["parameter_name"], "breed") - self.assertEqual(values[0]["value"], b'"bloodhound"') + self.assertEqual(values[0]["value"], value) def test_add_relationship_parameter_values_to_db(self): """Test that relationship parameter values are added to the db when editing the table.""" model = TestEmptyParameterValueModel(self._db_mngr) fetch_model(model) + value, value_type = to_database(-1) self.assertTrue( model.batch_set_data( _empty_indexes(model), @@ -128,7 +131,7 @@ def test_add_relationship_parameter_values_to_db(self): DB_ITEM_SEPARATOR.join(["pluto", "nemo"]), "relative_speed", "Base", - join_value_and_type(b"-1", None), + join_value_and_type(value, value_type), "mock_db", ], ) @@ -138,7 +141,7 @@ def test_add_relationship_parameter_values_to_db(self): self.assertEqual(values[0]["entity_class_name"], "dog__fish") self.assertEqual(values[0]["element_name_list"], ("pluto", "nemo")) self.assertEqual(values[0]["parameter_name"], "relative_speed") - self.assertEqual(values[0]["value"], b"-1") + self.assertEqual(values[0]["value"], value) def test_do_not_add_invalid_relationship_parameter_values(self): """Test that relationship parameter values aren't added to the db if data is incomplete.""" @@ -222,19 +225,24 @@ def test_add_entity_parameter_values_adds_entity(self): """Test that adding parameter a value for a nonexistent entity creates the entity.""" model = TestEmptyParameterValueModel(self._db_mngr) fetch_model(model) - self.assertTrue( - model.batch_set_data( - _empty_indexes(model), - ["dog", "plato", "breed", "Base", join_value_and_type(b'"dog-human"', None), "mock_db"], + value, value_type = to_database("dog-human") + with mock.patch("spinetoolbox.spine_db_editor.mvcmodels.empty_models.AddedEntitiesPopup") as add_entities_popup: + show_method = mock.MagicMock() + add_entities_popup.return_value = show_method + self.assertTrue( + model.batch_set_data( + _empty_indexes(model), + ["dog", "plato", "breed", "Base", join_value_and_type(value, value_type), "mock_db"], + ) ) - ) + show_method.show.assert_called_once() parameter_values = self._db_mngr.get_items(self._db_map, "parameter_value") entities = self._db_mngr.get_items(self._db_map, "entity") self.assertEqual(len(parameter_values), 1) self.assertEqual(parameter_values[0]["entity_class_name"], "dog") self.assertEqual(parameter_values[0]["entity_name"], "plato") self.assertEqual(parameter_values[0]["parameter_name"], "breed") - self.assertEqual(parameter_values[0]["value"], b'"dog-human"') + self.assertEqual(parameter_values[0]["value"], value) self.assertEqual(len(entities), 4) self.assertEqual(entities[0]["name"], "pluto") self.assertEqual(entities[1]["name"], "nemo") diff --git a/tests/spine_db_editor/mvcmodels/test_single_parameter_models.py b/tests/spine_db_editor/mvcmodels/test_single_parameter_models.py index dce94f45f..ff1c7f3c5 100644 --- a/tests/spine_db_editor/mvcmodels/test_single_parameter_models.py +++ b/tests/spine_db_editor/mvcmodels/test_single_parameter_models.py @@ -97,30 +97,39 @@ def tearDown(self): self._db_mngr.deleteLater() def test_data_db_map_role(self): - self._db_mngr.add_entity_classes({self._db_map: [{"name": "my_class", "id": 1}]}) + self._db_mngr.add_entity_classes({self._db_map: [{"name": "my_class"}]}) + entity_class = self._db_map.get_entity_class_item(name="my_class") self._db_mngr.add_parameter_definitions( - {self._db_map: [{"entity_class_id": 1, "name": "my_parameter", "id": 1}]} + {self._db_map: [{"entity_class_id": entity_class["id"], "name": "my_parameter"}]} ) - self._db_mngr.add_entities({self._db_map: [{"class_id": 1, "name": "my_object", "id": 1}]}) + definition = self._db_map.get_parameter_definition_item(entity_class_id=entity_class["id"], name="my_parameter") + self._db_mngr.add_entities({self._db_map: [{"class_id": entity_class["id"], "name": "my_object"}]}) + entity = self._db_map.get_entity_item(class_id=entity_class["id"], name="my_object") + alternative = self._db_map.get_alternative_item(name="Base") value, type_ = to_database(2.3) self._db_mngr.add_parameter_values( { self._db_map: [ { - "entity_class_id": 1, - "entity_id": 1, - "parameter_definition_id": 1, + "entity_class_id": entity_class["id"], + "entity_id": entity["id"], + "parameter_definition_id": definition["id"], "value": value, "type": type_, - "alternative_id": 1, - "id": 1, + "alternative_id": alternative["id"], } ] } ) - with q_object(TestSingleParameterValueModel(self._db_mngr, self._db_map, 1, True)) as model: + parameter_value = self._db_map.get_parameter_value_item( + entity_class_id=entity_class["id"], + entity_id=entity["id"], + parameter_definition_id=definition["id"], + alternative_id=alternative["id"], + ) + with q_object(TestSingleParameterValueModel(self._db_mngr, self._db_map, parameter_value["id"], True)) as model: fetch_model(model) - model.add_rows([1]) + model.add_rows([parameter_value["id"]]) self.assertEqual(model.index(0, 0).data(DB_MAP_ROLE), self._db_map) diff --git a/tests/spine_db_editor/test_graphics_items.py b/tests/spine_db_editor/test_graphics_items.py index 35ddf1779..ac8b1c087 100644 --- a/tests/spine_db_editor/test_graphics_items.py +++ b/tests/spine_db_editor/test_graphics_items.py @@ -25,7 +25,6 @@ class TestEntityItem(unittest.TestCase): @classmethod def setUpClass(cls): - # SpineDBEditor takes long to construct hence we make only one of them for the entire suite. if not QApplication.instance(): QApplication() diff --git a/tests/spine_db_editor/widgets/spine_db_editor_test_base.py b/tests/spine_db_editor/widgets/spine_db_editor_test_base.py index b7cb1cd6f..4ade6c89e 100644 --- a/tests/spine_db_editor/widgets/spine_db_editor_test_base.py +++ b/tests/spine_db_editor/widgets/spine_db_editor_test_base.py @@ -14,116 +14,16 @@ import unittest from unittest import mock from PySide6.QtWidgets import QApplication +from spinedb_api import to_database from spinetoolbox.spine_db_editor.widgets.spine_db_editor import SpineDBEditor from tests.mock_helpers import TestSpineDBManager class DBEditorTestBase(unittest.TestCase): - @staticmethod - def _entity_class(*args): - return dict(zip(["id", "name", "dimension_id_list"], args)) - - @staticmethod - def _entity(*args): - return dict(zip(["id", "class_id", "name", "element_id_list"], args)) - - @staticmethod - def _parameter_definition(*args): - d = dict(zip(["id", "entity_class_id", "name"], args)) - d.update({"default_value": None, "default_type": None}) - return d - - @staticmethod - def _parameter_value(*args): - return dict( - zip( - ["id", "entity_class_id", "entity_id", "parameter_definition_id", "alternative_id", "value", "type"], - args, - ) - ) - @classmethod def setUpClass(cls): if not QApplication.instance(): QApplication() - cls.create_mock_dataset() - - @classmethod - def create_mock_dataset(cls): - cls.fish_class = cls._entity_class(1, "fish") - cls.dog_class = cls._entity_class(2, "dog") - cls.fish_dog_class = cls._entity_class(3, "fish__dog", [cls.fish_class["id"], cls.dog_class["id"]]) - cls.dog_fish_class = cls._entity_class(4, "dog__fish", [cls.dog_class["id"], cls.fish_class["id"]]) - cls.nemo_object = cls._entity(1, cls.fish_class["id"], "nemo") - cls.pluto_object = cls._entity(2, cls.dog_class["id"], "pluto") - cls.scooby_object = cls._entity(3, cls.dog_class["id"], "scooby") - cls.pluto_nemo_rel = cls._entity( - 4, cls.dog_fish_class["id"], "dog__fish_pluto__nemo", [cls.pluto_object["id"], cls.nemo_object["id"]] - ) - cls.nemo_pluto_rel = cls._entity( - 5, cls.fish_dog_class["id"], "fish__dog_nemo__pluto", [cls.nemo_object["id"], cls.pluto_object["id"]] - ) - cls.nemo_scooby_rel = cls._entity( - 6, cls.fish_dog_class["id"], "fish__dog_nemo__scooby", [cls.nemo_object["id"], cls.scooby_object["id"]] - ) - cls.water_parameter = cls._parameter_definition(1, cls.fish_class["id"], "water") - cls.breed_parameter = cls._parameter_definition(2, cls.dog_class["id"], "breed") - cls.relative_speed_parameter = cls._parameter_definition(3, cls.fish_dog_class["id"], "relative_speed") - cls.combined_mojo_parameter = cls._parameter_definition(4, cls.dog_fish_class["id"], "combined_mojo") - cls.nemo_water = cls._parameter_value( - 1, - cls.water_parameter["entity_class_id"], - cls.nemo_object["id"], - cls.water_parameter["id"], - 1, - b'"salt"', - None, - ) - cls.pluto_breed = cls._parameter_value( - 2, - cls.breed_parameter["entity_class_id"], - cls.pluto_object["id"], - cls.breed_parameter["id"], - 1, - b'"bloodhound"', - None, - ) - cls.scooby_breed = cls._parameter_value( - 3, - cls.breed_parameter["entity_class_id"], - cls.scooby_object["id"], - cls.breed_parameter["id"], - 1, - b'"great dane"', - None, - ) - cls.nemo_pluto_relative_speed = cls._parameter_value( - 4, - cls.relative_speed_parameter["entity_class_id"], - cls.nemo_pluto_rel["id"], - cls.relative_speed_parameter["id"], - 1, - b"-1", - None, - ) - cls.nemo_scooby_relative_speed = cls._parameter_value( - 5, - cls.relative_speed_parameter["entity_class_id"], - cls.nemo_scooby_rel["id"], - cls.relative_speed_parameter["id"], - 1, - b"5", - None, - ) - cls.pluto_nemo_combined_mojo = cls._parameter_value( - 6, - cls.combined_mojo_parameter["entity_class_id"], - cls.pluto_nemo_rel["id"], - cls.combined_mojo_parameter["id"], - 1, - b"100", - None, - ) db_codename = "database" @@ -154,53 +54,155 @@ def tearDown(self): self.spine_db_editor.deleteLater() self.spine_db_editor = None + def _assert_success(self, result): + item, error = result + self.assertIsNone(error) + return item + def put_mock_object_classes_in_db_mngr(self): """Puts fish and dog object classes in the db mngr.""" - object_classes = [self.fish_class, self.dog_class] - self.db_mngr.add_entity_classes({self.mock_db_map: object_classes}) - self.fetch_entity_tree_model() + self.fish_class = self._assert_success(self.mock_db_map.add_entity_class_item(name="fish")) + self.dog_class = self._assert_success(self.mock_db_map.add_entity_class_item(name="dog")) def put_mock_objects_in_db_mngr(self): """Puts nemo, pluto and scooby objects in the db mngr.""" - objects = [self.nemo_object, self.pluto_object, self.scooby_object] - self.db_mngr.add_entities({self.mock_db_map: objects}) - self.fetch_entity_tree_model() + self.nemo_object = self._assert_success( + self.mock_db_map.add_entity_item(entity_class_name=self.fish_class["name"], name="nemo") + ) + self.pluto_object = self._assert_success( + self.mock_db_map.add_entity_item(entity_class_name=self.dog_class["name"], name="pluto") + ) + self.scooby_object = self._assert_success( + self.mock_db_map.add_entity_item(entity_class_name=self.dog_class["name"], name="scooby") + ) def put_mock_relationship_classes_in_db_mngr(self): """Puts dog__fish and fish__dog relationship classes in the db mngr.""" - relationship_classes = [self.fish_dog_class, self.dog_fish_class] - self.db_mngr.add_entity_classes({self.mock_db_map: relationship_classes}) - self.fetch_entity_tree_model() + self.fish_dog_class = self._assert_success( + self.mock_db_map.add_entity_class_item( + dimension_name_list=(self.fish_class["name"], self.dog_class["name"]) + ) + ) + self.dog_fish_class = self._assert_success( + self.mock_db_map.add_entity_class_item( + dimension_name_list=(self.dog_class["name"], self.fish_class["name"]) + ) + ) def put_mock_relationships_in_db_mngr(self): """Puts pluto_nemo, nemo_pluto and nemo_scooby relationships in the db mngr.""" - relationships = [self.pluto_nemo_rel, self.nemo_pluto_rel, self.nemo_scooby_rel] - self.db_mngr.add_entities({self.mock_db_map: relationships}) - self.fetch_entity_tree_model() + self.pluto_nemo_rel = self._assert_success( + self.mock_db_map.add_entity_item( + entity_class_name=self.dog_fish_class["name"], + entity_byname=(self.pluto_object["name"], self.nemo_object["name"]), + ) + ) + self.nemo_pluto_rel = self._assert_success( + self.mock_db_map.add_entity_item( + entity_class_name=self.fish_dog_class["name"], + entity_byname=(self.nemo_object["name"], self.pluto_object["name"]), + ) + ) + self.nemo_scooby_rel = self._assert_success( + self.mock_db_map.add_entity_item( + entity_class_name=self.fish_dog_class["name"], + entity_byname=(self.nemo_object["name"], self.scooby_object["name"]), + ) + ) def put_mock_object_parameter_definitions_in_db_mngr(self): """Puts water and breed object parameter definitions in the db mngr.""" - parameter_definitions = [self.water_parameter, self.breed_parameter] - self.db_mngr.add_parameter_definitions({self.mock_db_map: parameter_definitions}) + self.water_parameter = self._assert_success( + self.mock_db_map.add_parameter_definition_item(entity_class_name=self.fish_class["name"], name="water") + ) + self.breed_parameter = self._assert_success( + self.mock_db_map.add_parameter_definition_item(entity_class_name=self.dog_class["name"], name="breed") + ) def put_mock_relationship_parameter_definitions_in_db_mngr(self): """Puts relative speed and combined mojo relationship parameter definitions in the db mngr.""" - parameter_definitions = [self.relative_speed_parameter, self.combined_mojo_parameter] - self.db_mngr.add_parameter_definitions({self.mock_db_map: parameter_definitions}) + self.relative_speed_parameter = self._assert_success( + self.mock_db_map.add_parameter_definition_item( + entity_class_name=self.fish_dog_class["name"], name="relative_speed" + ) + ) + self.combined_mojo_parameter = self._assert_success( + self.mock_db_map.add_parameter_definition_item( + entity_class_name=self.dog_fish_class["name"], name="combined_mojo" + ) + ) def put_mock_object_parameter_values_in_db_mngr(self): """Puts some object parameter values in the db mngr.""" - parameter_values = [self.nemo_water, self.pluto_breed, self.scooby_breed] - self.db_mngr.add_parameter_values({self.mock_db_map: parameter_values}) + value, type_ = to_database("salt") + self.nemo_water = self._assert_success( + self.mock_db_map.add_parameter_value_item( + entity_class_name=self.fish_class["name"], + entity_byname=(self.nemo_object["name"],), + parameter_definition_name=self.water_parameter["name"], + alternative_name="Base", + value=value, + type=type_, + ) + ) + value, type_ = to_database("bloodhound") + self.pluto_breed = self._assert_success( + self.mock_db_map.add_parameter_value_item( + entity_class_name=self.dog_class["name"], + entity_byname=(self.pluto_object["name"],), + parameter_definition_name=self.breed_parameter["name"], + alternative_name="Base", + value=value, + type=type_, + ) + ) + value, type_ = to_database("great dane") + self.scooby_breed = self._assert_success( + self.mock_db_map.add_parameter_value_item( + entity_class_name=self.dog_class["name"], + entity_byname=(self.scooby_object["name"],), + parameter_definition_name=self.breed_parameter["name"], + alternative_name="Base", + value=value, + type=type_, + ) + ) def put_mock_relationship_parameter_values_in_db_mngr(self): """Puts some relationship parameter values in the db mngr.""" - parameter_values = [ - self.nemo_pluto_relative_speed, - self.nemo_scooby_relative_speed, - self.pluto_nemo_combined_mojo, - ] - self.db_mngr.add_parameter_values({self.mock_db_map: parameter_values}) + value, type_ = to_database(-1) + self.nemo_pluto_relative_speed = self._assert_success( + self.mock_db_map.add_parameter_value_item( + entity_class_name=self.fish_dog_class["name"], + entity_byname=(self.nemo_object["name"], self.pluto_object["name"]), + parameter_definition_name=self.relative_speed_parameter["name"], + alternative_name="Base", + value=value, + type=type_, + ) + ) + value, type_ = to_database(5) + self.nemo_scooby_relative_speed = self._assert_success( + self.mock_db_map.add_parameter_value_item( + entity_class_name=self.fish_dog_class["name"], + entity_byname=(self.nemo_object["name"], self.scooby_object["name"]), + parameter_definition_name=self.relative_speed_parameter["name"], + alternative_name="Base", + value=value, + type=type_, + ) + ) + value, type_ = to_database(100) + self.pluto_nemo_combined_mojo = self._assert_success( + self.mock_db_map.add_parameter_value_item( + entity_class_name=self.dog_fish_class["name"], + entity_byname=(self.pluto_object["name"], self.nemo_object["name"]), + parameter_definition_name=self.combined_mojo_parameter["name"], + alternative_name="Base", + value=value, + type=type_, + ) + ) def put_mock_dataset_in_db_mngr(self): """Puts mock dataset in the db mngr.""" diff --git a/tests/spine_db_editor/widgets/test_SpineDBEditor.py b/tests/spine_db_editor/widgets/test_SpineDBEditor.py index 8b30d0d26..eebdc8be8 100644 --- a/tests/spine_db_editor/widgets/test_SpineDBEditor.py +++ b/tests/spine_db_editor/widgets/test_SpineDBEditor.py @@ -70,6 +70,8 @@ def test_open_element_name_list_editor(self): QApplication.processEvents() model = self.spine_db_editor.parameter_value_model index = model.index(0, 1) + self.assertEqual(index.data(), "nemo ǀ pluto") + self.assertEqual(model.index(1, 1).data(), "nemo ǀ scooby") with mock.patch( "spinetoolbox.spine_db_editor.widgets.stacked_view_mixin.ElementNameListEditor" ) as editor_constructor: diff --git a/tests/spine_db_editor/widgets/test_SpineDBEditorAdd.py b/tests/spine_db_editor/widgets/test_SpineDBEditorAdd.py index 8258122cd..d642af351 100644 --- a/tests/spine_db_editor/widgets/test_SpineDBEditorAdd.py +++ b/tests/spine_db_editor/widgets/test_SpineDBEditorAdd.py @@ -54,10 +54,10 @@ def test_add_entities_to_object_tree_model(self): def test_add_relationship_classes_to_object_tree_model(self): """Test that entity classes are added to the object tree model.""" self.spine_db_editor.init_models() - self.fetch_entity_tree_model() self.put_mock_object_classes_in_db_mngr() self.put_mock_objects_in_db_mngr() self.put_mock_relationship_classes_in_db_mngr() + self.fetch_entity_tree_model() root_item = self.spine_db_editor.entity_tree_model.root_item dog_fish_item = next(x for x in root_item.children if x.display_data == "dog__fish") fish_dog_item = next(x for x in root_item.children if x.display_data == "fish__dog") @@ -111,14 +111,15 @@ def test_add_object_parameter_definitions_to_model(self): with mock.patch.object(SingleParameterDefinitionModel, "__lt__") as lt_mocked: lt_mocked.return_value = False self.put_mock_object_parameter_definitions_in_db_mngr() + self.fetch_entity_tree_model() h = model.header.index parameters = [] for row in range(model.rowCount()): parameters.append( (model.index(row, h("entity_class_name")).data(), model.index(row, h("parameter_name")).data()) ) - self.assertTrue(("fish", "water") in parameters) - self.assertTrue(("dog", "breed") in parameters) + self.assertIn(("fish", "water"), parameters) + self.assertIn(("dog", "breed"), parameters) def test_add_relationship_parameter_definitions_to_model(self): """Test that entity parameter definitions are added to the model.""" @@ -130,14 +131,15 @@ def test_add_relationship_parameter_definitions_to_model(self): with mock.patch.object(SingleParameterDefinitionModel, "__lt__") as lt_mocked: lt_mocked.return_value = False self.put_mock_relationship_parameter_definitions_in_db_mngr() + self.fetch_entity_tree_model() h = model.header.index parameters = [] for row in range(model.rowCount()): parameters.append( (model.index(row, h("entity_class_name")).data(), model.index(row, h("parameter_name")).data()) ) - self.assertTrue(("fish__dog", "relative_speed") in parameters) - self.assertTrue(("dog__fish", "combined_mojo") in parameters) + self.assertIn(("fish__dog", "relative_speed"), parameters) + self.assertIn(("dog__fish", "combined_mojo"), parameters) def test_add_object_parameter_values_to_model(self): """Test that object parameter values are added to the model.""" @@ -150,6 +152,7 @@ def test_add_object_parameter_values_to_model(self): with mock.patch.object(SingleParameterDefinitionModel, "__lt__") as lt_mocked: lt_mocked.return_value = False self.put_mock_object_parameter_values_in_db_mngr() + self.fetch_entity_tree_model() h = model.header.index parameters = [] for row in range(model.rowCount()): @@ -160,9 +163,9 @@ def test_add_object_parameter_values_to_model(self): model.index(row, h("value")).data(), ) ) - self.assertTrue(("nemo", "water", "salt") in parameters) - self.assertTrue(("pluto", "breed", "bloodhound") in parameters) - self.assertTrue(("scooby", "breed", "great dane") in parameters) + self.assertIn(("nemo", "water", "salt"), parameters) + self.assertIn(("pluto", "breed", "bloodhound"), parameters) + self.assertIn(("scooby", "breed", "great dane"), parameters) def test_add_relationship_parameter_values_to_model(self): """Test that object parameter values are added to the model.""" @@ -178,6 +181,7 @@ def test_add_relationship_parameter_values_to_model(self): with mock.patch.object(SingleParameterDefinitionModel, "__lt__") as lt_mocked: lt_mocked.return_value = False self.put_mock_relationship_parameter_values_in_db_mngr() + self.fetch_entity_tree_model() h = model.header.index parameters = [] for row in range(model.rowCount()): @@ -188,6 +192,6 @@ def test_add_relationship_parameter_values_to_model(self): model.index(row, h("value")).data(), ) ) - self.assertTrue((("nemo", "pluto"), "relative_speed", "-1.0") in parameters) - self.assertTrue((("nemo", "scooby"), "relative_speed", "5.0") in parameters) - self.assertTrue((("pluto", "nemo"), "combined_mojo", "100.0") in parameters) + self.assertIn((("nemo", "pluto"), "relative_speed", "-1.0"), parameters) + self.assertIn((("nemo", "scooby"), "relative_speed", "5.0"), parameters) + self.assertIn((("pluto", "nemo"), "combined_mojo", "100.0"), parameters) diff --git a/tests/spine_db_editor/widgets/test_SpineDBEditorFilter.py b/tests/spine_db_editor/widgets/test_SpineDBEditorFilter.py index 27226cf64..680cae6de 100644 --- a/tests/spine_db_editor/widgets/test_SpineDBEditorFilter.py +++ b/tests/spine_db_editor/widgets/test_SpineDBEditorFilter.py @@ -61,6 +61,7 @@ def test_filter_parameter_tables_per_zero_dimensional_entity_class(self): if model.canFetchMore(None): model.fetchMore(None) self.put_mock_dataset_in_db_mngr() + self.fetch_entity_tree_model() root_item = self.spine_db_editor.entity_tree_model.root_item fish_item = next(x for x in root_item.children if x.display_data == "fish") fish_index = self.spine_db_editor.entity_tree_model.index_from_item(fish_item) @@ -79,6 +80,7 @@ def test_filter_parameter_tables_per_nonzero_dimensional_entity_class(self): if model.canFetchMore(None): model.fetchMore(None) self.put_mock_dataset_in_db_mngr() + self.fetch_entity_tree_model() root_item = self.spine_db_editor.entity_tree_model.root_item fish_dog_item = next(x for x in root_item.children if x.display_data == "fish__dog") fish_dog_index = self.spine_db_editor.entity_tree_model.index_from_item(fish_dog_item) @@ -101,6 +103,7 @@ def test_filter_parameter_tables_per_entity_class_and_entity_cross_selection(sel if model.canFetchMore(None): model.fetchMore(None) self.put_mock_dataset_in_db_mngr() + self.fetch_entity_tree_model() root_item = self.spine_db_editor.entity_tree_model.root_item fish_item = next(x for x in root_item.children if x.display_data == "fish") fish_index = self.spine_db_editor.entity_tree_model.index_from_item(fish_item) @@ -128,6 +131,7 @@ def test_filter_parameter_tables_per_entity(self): if model.canFetchMore(None): model.fetchMore(None) self.put_mock_dataset_in_db_mngr() + self.fetch_entity_tree_model() root_item = self.spine_db_editor.entity_tree_model.root_item dog_item = next(x for x in root_item.children if x.display_data == "dog") pluto_item = next(x for x in dog_item.children if x.display_data == "pluto") diff --git a/tests/spine_db_editor/widgets/test_SpineDBEditorUpdate.py b/tests/spine_db_editor/widgets/test_SpineDBEditorUpdate.py index bca80d872..5a0a7f735 100644 --- a/tests/spine_db_editor/widgets/test_SpineDBEditorUpdate.py +++ b/tests/spine_db_editor/widgets/test_SpineDBEditorUpdate.py @@ -11,6 +11,8 @@ ###################################################################################################################### """Unit tests for database item update functionality in Database editor.""" +from PySide6.QtWidgets import QApplication +from spinedb_api import to_database from spinetoolbox.helpers import DB_ITEM_SEPARATOR from .spine_db_editor_test_base import DBEditorTestBase @@ -21,8 +23,8 @@ def test_update_object_classes_in_object_tree_model(self): self.spine_db_editor.init_models() self.put_mock_object_classes_in_db_mngr() self.fetch_entity_tree_model() - self.fish_class = self._entity_class(1, "octopus") - self.db_mngr.update_entity_classes({self.mock_db_map: [self.fish_class]}) + fish_update = {"id": self.fish_class["id"], "name": "octopus"} + self.db_mngr.update_entity_classes({self.mock_db_map: [fish_update]}) root_item = self.spine_db_editor.entity_tree_model.root_item fish_item = root_item.child(1) self.assertEqual(fish_item.item_type, "entity_class") @@ -34,8 +36,8 @@ def test_update_objects_in_object_tree_model(self): self.put_mock_object_classes_in_db_mngr() self.put_mock_objects_in_db_mngr() self.fetch_entity_tree_model() - self.nemo_object = self._entity(1, self.fish_class["id"], "dory") - self.db_mngr.update_entities({self.mock_db_map: [self.nemo_object]}) + nemo_update = {"id": self.nemo_object["id"], "name": "dory"} + self.db_mngr.update_entities({self.mock_db_map: [nemo_update]}) root_item = self.spine_db_editor.entity_tree_model.root_item fish_item = root_item.child(1) nemo_item = fish_item.child(0) @@ -49,8 +51,8 @@ def test_update_relationship_classes_in_object_tree_model(self): self.put_mock_objects_in_db_mngr() self.put_mock_relationship_classes_in_db_mngr() self.fetch_entity_tree_model() - self.fish_dog_class = {"id": 3, "name": "octopus__dog"} - self.db_mngr.update_entity_classes({self.mock_db_map: [self.fish_dog_class]}) + fish_dog_update = {"id": self.fish_dog_class["id"], "name": "octopus__dog"} + self.db_mngr.update_entity_classes({self.mock_db_map: [fish_dog_update]}) root_item = self.spine_db_editor.entity_tree_model.root_item fish_dog_item = root_item.child(3) self.assertEqual(fish_dog_item.item_type, "entity_class") @@ -65,8 +67,8 @@ def test_update_object_parameter_definitions_in_model(self): self.put_mock_object_classes_in_db_mngr() self.put_mock_object_parameter_definitions_in_db_mngr() self.fetch_entity_tree_model() - self.water_parameter = self._parameter_definition(1, self.fish_class["id"], "fire") - self.db_mngr.update_parameter_definitions({self.mock_db_map: [self.water_parameter]}) + water_update = {"id": self.water_parameter["id"], "name": "fire"} + self.db_mngr.update_parameter_definitions({self.mock_db_map: [water_update]}) h = model.header.index parameters = [] for row in range(model.rowCount()): @@ -86,8 +88,8 @@ def test_update_relationship_parameter_definitions_in_model(self): self.put_mock_object_parameter_definitions_in_db_mngr() self.put_mock_relationship_parameter_definitions_in_db_mngr() self.fetch_entity_tree_model() - self.relative_speed_parameter = self._parameter_definition(3, self.fish_dog_class["id"], "each_others_opinion") - self.db_mngr.update_parameter_definitions({self.mock_db_map: [self.relative_speed_parameter]}) + relative_speed_update = {"id": self.relative_speed_parameter["id"], "name": "each_others_opinion"} + self.db_mngr.update_parameter_definitions({self.mock_db_map: [relative_speed_update]}) h = model.header.index parameters = [] for row in range(model.rowCount()): @@ -107,10 +109,9 @@ def test_update_object_parameter_values_in_model(self): self.put_mock_object_parameter_definitions_in_db_mngr() self.put_mock_object_parameter_values_in_db_mngr() self.fetch_entity_tree_model() - self.nemo_water = self._parameter_value( - 1, self.fish_class["id"], self.nemo_object["id"], self.water_parameter["id"], 1, b'"pepper"', None - ) - self.db_mngr.update_parameter_values({self.mock_db_map: [self.nemo_water]}) + value, type_ = to_database("pepper") + nemo_water_update = {"id": self.nemo_water["id"], "value": value, "type": type_} + self.db_mngr.update_parameter_values({self.mock_db_map: [nemo_water_update]}) h = model.header.index parameters = [] for row in range(model.rowCount()): @@ -130,16 +131,14 @@ def test_update_relationship_parameter_values_in_model(self): if model.canFetchMore(None): model.fetchMore(None) self.put_mock_dataset_in_db_mngr() - self.nemo_pluto_relative_speed = self._parameter_value( - 4, - self.fish_dog_class["id"], - self.nemo_pluto_rel["id"], - self.relative_speed_parameter["id"], - 1, - b"100", - None, - ) - self.db_mngr.update_parameter_values({self.mock_db_map: [self.nemo_pluto_relative_speed]}) + self.fetch_entity_tree_model() + value, type_ = to_database(100) + nemo_pluto_relative_speed_update = { + "id": self.nemo_pluto_relative_speed["id"], + "value": value, + "type": type_, + } + self.db_mngr.update_parameter_values({self.mock_db_map: [nemo_pluto_relative_speed_update]}) h = model.header.index parameters = [] for row in range(model.rowCount()): diff --git a/tests/spine_db_editor/widgets/test_custom_editors.py b/tests/spine_db_editor/widgets/test_custom_editors.py index 621087dd0..ef6c44b9f 100644 --- a/tests/spine_db_editor/widgets/test_custom_editors.py +++ b/tests/spine_db_editor/widgets/test_custom_editors.py @@ -15,7 +15,7 @@ from PySide6.QtCore import QEvent, QPoint, Qt from PySide6.QtGui import QFocusEvent, QKeyEvent, QStandardItem, QStandardItemModel from PySide6.QtWidgets import QApplication, QStyleOptionViewItem, QWidget -from spinetoolbox.helpers import make_icon_id +from spinetoolbox.helpers import DB_ITEM_SEPARATOR, make_icon_id from spinetoolbox.resources_icons_rc import qInitResources from spinetoolbox.spine_db_editor.widgets.custom_editors import ( BooleanSearchBarEditor, @@ -23,6 +23,7 @@ CustomComboBoxEditor, CustomLineEditor, IconColorEditor, + ParameterTypeEditor, ParameterValueLineEditor, PivotHeaderTableLineEditor, SearchBarEditor, @@ -140,5 +141,47 @@ def test_boolean_searchbar_editor(self): self.assertEqual(True, retval) +class TestParameterTypeEditor(unittest.TestCase): + @classmethod + def setUpClass(cls): + qInitResources() + if not QApplication.instance(): + QApplication() + + def setUp(self): + self._editor = ParameterTypeEditor(None) + + def tearDown(self): + self._editor.deleteLater() + + def test_select_all(self): + self._editor.set_data("") + self._editor._ui.select_all_button.click() + for check_box in self._editor._check_box_iter(): + with self.subTest(check_box_text=check_box.text()): + self.assertTrue(check_box.isChecked()) + self.assertEqual(self._editor._ui.map_rank_line_edit.text(), "1") + self.assertEqual(self._editor.data(), "") + + def test_select_single_type(self): + expected_data = { + "a&rray": "array", + "&bool": "bool", + "&date_time": "date_time", + "d&uration": "duration", + "&float": "float", + "&map": DB_ITEM_SEPARATOR.join(("2d_map", "3d_map")), + "&str": "str", + "time_&pattern": "time_pattern", + "&time_series": "time_series", + } + for check_box in self._editor._check_box_iter(): + self._editor._clear_all() + check_box.setChecked(True) + self._editor._ui.map_rank_line_edit.setText("2,3") + with self.subTest(check_box_text=check_box.text()): + self.assertEqual(self._editor.data(), expected_data[check_box.text()]) + + if __name__ == "__main__": unittest.main() diff --git a/tests/spine_db_editor/widgets/test_custom_qtableview.py b/tests/spine_db_editor/widgets/test_custom_qtableview.py index d00ae08fe..c4fc90503 100644 --- a/tests/spine_db_editor/widgets/test_custom_qtableview.py +++ b/tests/spine_db_editor/widgets/test_custom_qtableview.py @@ -382,12 +382,12 @@ def test_purging_value_data_leaves_empty_rows_intact(self): for row, column in itertools.product(range(model.rowCount()), range(model.columnCount())): self.assertEqual(model.index(row, column).data(), expected[row][column]) - def test_removing_fetched_rows_allows_still_fetching_more(self): + def test_remove_fetched_rows(self): table_view = self._db_editor.ui.tableView_parameter_value model = table_view.model() self.assertEqual(model.rowCount(), self._CHUNK_SIZE + 1) - n_values = self._whole_model_rowcount() - 1 - self._db_mngr.remove_items({self._db_map: {"parameter_value": set(range(1, n_values, 2))}}) + ids = [model.item_at_row(row) for row in range(0, model.rowCount() - 1, 2)] + self._db_mngr.remove_items({self._db_map: {"parameter_value": set(ids)}}) self.assertEqual(model.rowCount(), self._CHUNK_SIZE / 2 + 1) def test_undoing_purge(self): diff --git a/tests/test_SpineDBManager.py b/tests/test_SpineDBManager.py index 1935922fd..dcbafce5b 100644 --- a/tests/test_SpineDBManager.py +++ b/tests/test_SpineDBManager.py @@ -199,7 +199,7 @@ def test_broken_value_in_display_role(self): parameter_definition_name="x", alternative_name="Base", value=value, - type=None, + type="float", ) self.assertIsNone(error) formatted = self.db_mngr.get_value(self._db_map, item, Qt.ItemDataRole.DisplayRole) @@ -213,7 +213,7 @@ def test_broken_value_in_edit_role(self): parameter_definition_name="x", alternative_name="Base", value=value, - type=None, + type="str", ) self.assertIsNone(error) formatted = self.db_mngr.get_value(self._db_map, item, Qt.ItemDataRole.EditRole) @@ -227,7 +227,7 @@ def test_broken_value_in_tool_tip_role(self): parameter_definition_name="x", alternative_name="Base", value=value, - type=None, + type="duration", ) self.assertIsNone(error) formatted = self.db_mngr.get_value(self._db_map, item, Qt.ItemDataRole.ToolTipRole) diff --git a/tests/test_parameter_type_validation.py b/tests/test_parameter_type_validation.py new file mode 100644 index 000000000..104cb20b3 --- /dev/null +++ b/tests/test_parameter_type_validation.py @@ -0,0 +1,111 @@ +###################################################################################################################### +# Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Database API contributors +# This file is part of Spine Database API. +# Spine Database API is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser +# General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General +# Public License for more details. You should have received a copy of the GNU Lesser General Public License along with +# this program. If not, see . +###################################################################################################################### +import unittest +from unittest import mock +from PySide6.QtWidgets import QApplication +from spinedb_api import to_database +from spinetoolbox.helpers import signal_waiter +from spinetoolbox.parameter_type_validation import ValidationKey +from tests.mock_helpers import TestSpineDBManager + + +class TestTypeValidator(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.db_codename = cls.__name__ + "_db" + if not QApplication.instance(): + QApplication() + + def setUp(self): + mock_settings = mock.MagicMock() + mock_settings.value.side_effect = lambda *args, **kwargs: 0 + self._db_mngr = TestSpineDBManager(mock_settings, None) + logger = mock.MagicMock() + self._db_map = self._db_mngr.get_db_map("sqlite://", logger, codename=self.db_codename, create=True) + self._db_mngr.parameter_type_validator.set_interval(0) + + def tearDown(self): + self._db_mngr.close_all_sessions() + while not self._db_map.closed: + QApplication.processEvents() + self._db_mngr.clean_up() + + def _assert_success(self, result): + item, error = result + self.assertIsNone(error) + return item + + def test_valid_parameter_default_value(self): + self._assert_success(self._db_map.add_entity_class_item(name="Recipe")) + value, value_type = to_database(23.0) + price = self._assert_success( + self._db_map.add_parameter_definition_item( + name="price", entity_class_name="Recipe", default_value=value, default_type=value_type + ) + ) + self._db_map.commit_session("Add test data.") + with signal_waiter(self._db_mngr.parameter_type_validator.validated, timeout=2) as waiter: + self._db_mngr.parameter_type_validator.start_validating(self._db_mngr, self._db_map, [price["id"]]) + waiter.wait() + self.assertEqual( + waiter.args, + (ValidationKey("parameter_definition", id(self._db_map), price["id"].private_id), True), + ) + + def test_invalid_parameter_default_value(self): + self._assert_success(self._db_map.add_entity_class_item(name="Recipe")) + value, value_type = to_database(23.0) + price = self._assert_success( + self._db_map.add_parameter_definition_item( + name="price", + entity_class_name="Recipe", + parameter_type_list=("str",), + default_value=value, + default_type=value_type, + ) + ) + with signal_waiter(self._db_mngr.parameter_type_validator.validated, timeout=5.0) as waiter: + self._db_mngr.parameter_type_validator.start_validating(self._db_mngr, self._db_map, [price["id"]]) + waiter.wait() + self.assertEqual( + waiter.args, + (ValidationKey("parameter_definition", id(self._db_map), price["id"].private_id), False), + ) + + def test_valid_parameter_value(self): + self._assert_success(self._db_map.add_entity_class_item(name="Recipe")) + self._assert_success(self._db_map.add_entity_item(name="fish_n_chips", entity_class_name="Recipe")) + self._assert_success(self._db_map.add_parameter_definition_item(name="price", entity_class_name="Recipe")) + value, value_type = to_database(23.0) + fish_n_chips_price = self._assert_success( + self._db_map.add_parameter_value_item( + entity_class_name="Recipe", + parameter_definition_name="price", + entity_byname=("fish_n_chips",), + alternative_name="Base", + value=value, + type=value_type, + ) + ) + with signal_waiter(self._db_mngr.parameter_type_validator.validated, timeout=2) as waiter: + self._db_mngr.parameter_type_validator.start_validating( + self._db_mngr, self._db_map, [fish_n_chips_price["id"]] + ) + waiter.wait() + self.assertEqual( + waiter.args, + (ValidationKey("parameter_value", id(self._db_map), fish_n_chips_price["id"].private_id), True), + ) + + +if __name__ == "__main__": + unittest.main()