From e76de8c2571f533c6206a5ea713c68f99864cb90 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 --- 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 ++++++++ 20 files changed, 798 insertions(+), 252 deletions(-) create mode 100644 spinetoolbox/parameter_type_validation.py create mode 100644 tests/test_parameter_type_validation.py 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..c876fa8f4 --- /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=1.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()