diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index bdefa79d..59c136e7 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-20.04 strategy: matrix: - python-version: [3.8.6] + python-version: [3.10.13] steps: - name: Checkout this repository @@ -37,10 +37,9 @@ jobs: strategy: matrix: python-version: - - 3.8.x - - 3.9.x - 3.10.x - 3.11.x + - 3.12.x steps: - name: Checkout this repository diff --git a/.gitignore b/.gitignore index bc455f40..ac246a98 100644 --- a/.gitignore +++ b/.gitignore @@ -126,7 +126,7 @@ celerybeat.pid # Environments .env -.venv +.venv* env/ venv/ ENV/ @@ -176,3 +176,4 @@ node_modules/ *.DS_Store tsconfig.tsbuildinfo +.python-version diff --git a/pyproject.toml b/pyproject.toml index a3ca1048..96262283 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,9 +4,9 @@ dynamic = ["version"] description = "COre DEfinitions." readme = "README.md" requires-python = ">=3.8" -license = {file = "LICENSE.md"} +license = { file = "LICENSE.md" } authors = [ - {name = "Exabyte Inc.", email = "info@mat3ra.com"} + { name = "Exabyte Inc.", email = "info@mat3ra.com" } ] classifiers = [ "Programming Language :: Python", @@ -18,6 +18,8 @@ dependencies = [ # add requirements here "numpy", "jsonschema>=2.6.0", + "pydantic>=2.10.5", + "mat3ra-esse", "mat3ra-utils>=2024.5.15.post0", ] @@ -79,3 +81,11 @@ target-version = "py38" profile = "black" multi_line_output = 3 include_trailing_comma = true + +[tool.pytest.ini_options] +pythonpath = [ + "src/py", +] +testpaths = [ + "tests/py" +] diff --git a/src/py/mat3ra/__init__.py b/src/py/mat3ra/__init__.py index e69de29b..41e7db52 100644 --- a/src/py/mat3ra/__init__.py +++ b/src/py/mat3ra/__init__.py @@ -0,0 +1,2 @@ +__path__ = __import__("pkgutil").extend_path(__path__, __name__) +# otherwise, `mat3ra.utils` path leads to an empty __init__.py file in the code.py package diff --git a/src/py/mat3ra/code/array_with_ids.py b/src/py/mat3ra/code/array_with_ids.py new file mode 100644 index 00000000..087052c0 --- /dev/null +++ b/src/py/mat3ra/code/array_with_ids.py @@ -0,0 +1,119 @@ +import json +from typing import Any, Callable, Dict, List, Optional, Tuple, Union + +from mat3ra.utils.mixins import RoundNumericValuesMixin +from pydantic import BaseModel, model_serializer + +from .value_with_id import RoundedValueWithId, ValueWithId + + +class ArrayWithIds(BaseModel): + values: List[Any] + ids: List[int] + + @classmethod + def from_values(cls, values: List[Any]) -> "ArrayWithIds": + try: + ids = list(range(len(values))) + return cls(values=values, ids=ids) + except KeyError: + raise ValueError("Values must be a list") + + @classmethod + def get_values_and_ids_from_list_of_dicts(cls, list_of_dicts: List[Dict[str, Any]]) -> Tuple[List[Any], List[int]]: + try: + values = [item["value"] for item in list_of_dicts] + ids = [item["id"] for item in list_of_dicts] + return values, ids + except KeyError: + raise ValueError("List of dictionaries must contain 'id' and 'value' keys") + + @classmethod + def from_list_of_dicts(cls, list_of_dicts: List[Dict[str, Any]]) -> "ArrayWithIds": + try: + values, ids = cls.get_values_and_ids_from_list_of_dicts(list_of_dicts) + return cls(values=values, ids=ids) + except KeyError: + raise ValueError("List of dictionaries must contain 'id' and 'value' keys") + + @model_serializer + def to_dict(self) -> List[Dict[str, Any]]: + return list(map(lambda x: x.to_dict(), self.to_array_of_values_with_ids())) + + def to_json(self, skip_rounding=True) -> str: + return json.dumps(self.to_dict()) + + def to_array_of_values_with_ids(self) -> List[ValueWithId]: + return [ValueWithId(id=id, value=item) for id, item in zip(self.ids, self.values)] + + def get_element_value_by_index(self, index: int) -> Any: + return self.values[index] if index < len(self.values) else None + + def get_element_id_by_value(self, value: Any) -> Optional[int]: + try: + return self.ids[self.values.index(value)] + except ValueError: + return None + + def filter_by_values(self, values: Union[List[Any], Any]): + def make_hashable(value): + return tuple(value) if isinstance(value, list) else value + + values_to_keep = set(make_hashable(v) for v in values) if isinstance(values, list) else {make_hashable(values)} + filtered_items = [(v, i) for v, i in zip(self.values, self.ids) if make_hashable(v) in values_to_keep] + if filtered_items: + values_unpacked, ids_unpacked = zip(*filtered_items) + self.values = list(values_unpacked) + self.ids = list(ids_unpacked) + else: + self.values = [] + self.ids = [] + + def filter_by_indices(self, indices: Union[List[int], int]): + index_set = set(indices) if isinstance(indices, list) else {indices} + self.values = [self.values[i] for i in range(len(self.values)) if i in index_set] + self.ids = [self.ids[i] for i in range(len(self.ids)) if i in index_set] + + def filter_by_ids(self, ids: Union[List[int], int], invert: bool = False): + if isinstance(ids, int): + ids = [ids] + if not invert: + ids_set = set(ids) + else: + ids_set = set(self.ids) - set(ids) + keep_indices = [index for index, id_ in enumerate(self.ids) if id_ in ids_set] + self.values = [self.values[index] for index in keep_indices] + self.ids = [self.ids[index] for index in keep_indices] + + def __eq__(self, other: object) -> bool: + return isinstance(other, ArrayWithIds) and self.values == other.values and self.ids == other.ids + + def map_array_in_place(self, func: Callable): + self.values = list(map(func, self.values)) + + def add_item(self, element: Any, id: Optional[int] = None): + if id is None: + new_id = max(self.ids, default=-1) + 1 + else: + new_id = id + self.values.append(element) + self.ids.append(new_id) + + def remove_item(self, index: int, id: Optional[int] = None): + if id is not None: + try: + index = self.ids.index(id) + except ValueError: + raise ValueError("ID not found in the list") + if index < len(self.values): + del self.values[index] + del self.ids[index] + else: + raise IndexError("Index out of range") + + +class RoundedArrayWithIds(RoundNumericValuesMixin, ArrayWithIds): + def to_array_of_values_with_ids(self) -> List[ValueWithId]: + class_reference = RoundedValueWithId + class_reference.__round_precision__ = self.__round_precision__ + return [class_reference(id=id, value=item) for id, item in zip(self.ids, self.values)] diff --git a/src/py/mat3ra/code/constants.py b/src/py/mat3ra/code/constants.py index 0fd18284..5081fe6b 100644 --- a/src/py/mat3ra/code/constants.py +++ b/src/py/mat3ra/code/constants.py @@ -1,5 +1,9 @@ from math import pi +from mat3ra.esse.models.definitions.constants import FundamentalConstants + +CONSTANTS = FundamentalConstants() + class Coefficients: # Same as used in: JS/TS @@ -13,18 +17,19 @@ class Coefficients: # and originally taken from https://github.com/hplgit/physical-quantities/blob/master/PhysicalQuantities.py # Internal, for convenience purposes - _c = 299792458.0 # speed of light, m/s - _mu0 = 4.0e-7 * pi # permeability of vacuum - _eps0 = 1 / _mu0 / _c**2 # permittivity of vacuum - _Grav = 6.67259e-11 # gravitational constant - _hplanck = 6.6260755e-34 # Planck constant, J s - _hbar = _hplanck / (2 * pi) # Planck constant / 2pi, J s - _e = 1.60217733e-19 # elementary charge - _me = 9.1093897e-31 # electron mass + _c = CONSTANTS.c # speed of light, m/s + _Grav = CONSTANTS.G # gravitational constant + _hplanck = CONSTANTS.h # Planck constant, J s + _e = CONSTANTS.e # elementary charge + _me = CONSTANTS.me # electron mass + _mu0 = 4.0e-7 * pi # permeability of vacuum, atomic units + _mp = 1.6726231e-27 # proton mass _Nav = 6.0221367e23 # Avogadro number _k = 1.380658e-23 # Boltzmann constant, J/K _amu = 1.6605402e-27 # atomic mass unit, kg + _eps0 = 1 / _mu0 / _c**2 # permittivity of vacuum + _hbar = _hplanck / (2 * pi) # Planck constant / 2pi, J s # External BOHR = 4e10 * pi * _eps0 * _hbar**2 / _me / _e**2 # Bohr radius in angstrom diff --git a/src/py/mat3ra/code/entity.py b/src/py/mat3ra/code/entity.py index 20fd0601..f4ae9e91 100644 --- a/src/py/mat3ra/code/entity.py +++ b/src/py/mat3ra/code/entity.py @@ -1,16 +1,23 @@ -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Type, TypeVar import jsonschema from mat3ra.utils import object as object_utils +from pydantic import BaseModel +from typing_extensions import Self from . import BaseUnderscoreJsonPropsHandler from .mixins import DefaultableMixin, HasDescriptionMixin, HasMetadataMixin, NamedMixin +T = TypeVar("T", bound="InMemoryEntityPydantic") +B = TypeVar("B", bound="BaseModel") + +# TODO: remove in the next PR class ValidationErrorCode: IN_MEMORY_ENTITY_DATA_INVALID = "IN_MEMORY_ENTITY_DATA_INVALID" +# TODO: remove in the next PR class ErrorDetails: def __init__(self, error: Optional[Dict[str, Any]], json: Dict[str, Any], schema: Dict): self.error = error @@ -18,6 +25,7 @@ def __init__(self, error: Optional[Dict[str, Any]], json: Dict[str, Any], schema self.schema = schema +# TODO: remove in the next PR class EntityError(Exception): def __init__(self, code: ValidationErrorCode, details: Optional[ErrorDetails] = None): super().__init__(code) @@ -25,6 +33,58 @@ def __init__(self, code: ValidationErrorCode, details: Optional[ErrorDetails] = self.details = details +class InMemoryEntityPydantic(BaseModel): + model_config = {"arbitrary_types_allowed": True} + + @classmethod + def create(cls: Type[T], config: Dict[str, Any]) -> T: + return cls.validate(config) + + @classmethod + def validate(cls, value: Any) -> Self: + # this will clean and validate data + return cls.model_validate(value) + + @classmethod + def is_valid(cls, value: Any) -> bool: + try: + cls.validate(value) + return True + except Exception: + return False + + @classmethod + def from_json(cls: Type[T], json_str: str) -> T: + return cls.model_validate_json(json_str) + + @classmethod + def clean(cls: Type[T], config: Dict[str, Any]) -> Dict[str, Any]: + validated_model = cls.model_validate(config) + return validated_model.model_dump() + + def get_schema(self) -> Dict[str, Any]: + return self.model_json_schema() + + def get_data_model(self) -> Type[B]: + for base in self.__class__.__bases__: + if issubclass(base, BaseModel) and base is not self.__class__: + return base + raise ValueError(f"No schema base model found for {self.__class__.__name__}") + + def get_cls_name(self) -> str: + return self.__class__.__name__ + + def to_dict(self, exclude: Optional[List[str]] = None) -> Dict[str, Any]: + return self.model_dump(exclude=set(exclude) if exclude else None) + + def to_json(self, exclude: Optional[List[str]] = None) -> str: + return self.model_dump_json(exclude=set(exclude) if exclude else None) + + def clone(self: T, extra_context: Optional[Dict[str, Any]] = None, deep=True) -> T: + return self.model_copy(update=extra_context or {}, deep=deep) + + +# TODO: remove in the next PR class InMemoryEntity(BaseUnderscoreJsonPropsHandler): jsonSchema: Optional[Dict] = None @@ -97,7 +157,7 @@ def get_as_entity_reference(self, by_id_only: bool = False) -> Dict[str, str]: return {"_id": self.id, "slug": self.slug, "cls": self.get_cls_name()} -class HasDescriptionHasMetadataNamedDefaultableInMemoryEntity( - InMemoryEntity, DefaultableMixin, NamedMixin, HasMetadataMixin, HasDescriptionMixin +class HasDescriptionHasMetadataNamedDefaultableInMemoryEntityPydantic( + InMemoryEntityPydantic, DefaultableMixin, NamedMixin, HasMetadataMixin, HasDescriptionMixin ): pass diff --git a/src/py/mat3ra/code/mixins/__init__.py b/src/py/mat3ra/code/mixins/__init__.py index fb07844e..564362af 100644 --- a/src/py/mat3ra/code/mixins/__init__.py +++ b/src/py/mat3ra/code/mixins/__init__.py @@ -1,57 +1,26 @@ -from typing import Any, Dict +from typing import Any, ClassVar, Dict, Optional -from .. import BaseUnderscoreJsonPropsHandler +from mat3ra.esse.models.system.defaultable import DefaultableEntitySchema +from mat3ra.esse.models.system.description import DescriptionSchema +from mat3ra.esse.models.system.metadata import MetadataSchema +from mat3ra.esse.models.system.name import NameEntitySchema -class DefaultableMixin(BaseUnderscoreJsonPropsHandler): - __default_config__: Dict[str, Any] - - @property - def is_default(self) -> bool: - return self.get_prop("isDefault", False) - - @is_default.setter - def is_default(self, is_default: bool = False) -> None: - self.set_prop("isDefault", is_default) +class DefaultableMixin(DefaultableEntitySchema): + __default_config__: ClassVar[Dict[str, Any]] = {} @classmethod def create_default(cls) -> "DefaultableMixin": - return cls(cls.__default_config__) - - -class NamedMixin(BaseUnderscoreJsonPropsHandler): - @property - def name(self) -> str: - return self.get_prop("name", False) - - @name.setter - def name(self, name: str = "") -> None: - self.set_prop("name", name) - - -class HasMetadataMixin(BaseUnderscoreJsonPropsHandler): - @property - def metadata(self) -> Dict: - return self.get_prop("metadata", False) + return cls(**cls.__default_config__) - @metadata.setter - def metadata(self, metadata: Dict = {}) -> None: - self.set_prop("metadata", metadata) +class NamedMixin(NameEntitySchema): + pass -class HasDescriptionMixin(BaseUnderscoreJsonPropsHandler): - @property - def description(self) -> str: - return self.get_prop("description", "") - @description.setter - def description(self, description: str = "") -> None: - self.set_prop("description", description) +class HasMetadataMixin(MetadataSchema): + pass - @property - def description_object(self) -> str: - return self.get_prop("descriptionObject", "") - @description_object.setter - def description_object(self, description_object: str = "") -> None: - self.set_prop("descriptionObject", description_object) +class HasDescriptionMixin(DescriptionSchema): + pass diff --git a/src/py/mat3ra/code/value_with_id.py b/src/py/mat3ra/code/value_with_id.py new file mode 100644 index 00000000..7f3e0b0f --- /dev/null +++ b/src/py/mat3ra/code/value_with_id.py @@ -0,0 +1,34 @@ +import json +from typing import Any, Dict + +from mat3ra.utils.mixins import RoundNumericValuesMixin +from pydantic import BaseModel, model_serializer + + +class ValueWithId(BaseModel): + id: int = 0 + value: Any = None + + @model_serializer + def to_dict(self): + # If `to_dict` is present in `value`, call it + if hasattr(self.value, "to_dict"): + return {"id": self.id, "value": self.value.to_dict()} + else: + return {"id": self.id, "value": self.value} + + def to_json(self) -> str: + return json.dumps(self.to_dict()) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, ValueWithId): + return False + return self.value == other.value and self.id == other.id + + +class RoundedValueWithId(RoundNumericValuesMixin, ValueWithId): + @model_serializer + def to_dict(self, skip_rounding: bool = False) -> Dict[str, Any]: + rounded_value = self.round_array_or_number(self.value) if not skip_rounding else self.value + rounded_value_with_id = ValueWithId(id=self.id, value=rounded_value) + return rounded_value_with_id.to_dict() diff --git a/src/py/mat3ra/code/vector.py b/src/py/mat3ra/code/vector.py new file mode 100644 index 00000000..d3d824b4 --- /dev/null +++ b/src/py/mat3ra/code/vector.py @@ -0,0 +1,66 @@ +from typing import List + +import numpy as np +from mat3ra.esse.models.core.abstract.point import PointSchema as Vector3DSchema +from mat3ra.utils.mixins import RoundNumericValuesMixin +from pydantic import model_serializer + + +class Vector3D(Vector3DSchema): + __atol__ = 1e-8 + + def __init__(self, root: List[float]): + super().__init__(root=root) + + @property + def value(self): + return self.root + + @property + def x(self): + return self.root[0] + + @property + def y(self): + return self.root[1] + + @property + def z(self): + return self.root[2] + + def __eq__(self, other): + if isinstance(other, list): + other = Vector3D(other) + return np.allclose(self.root, other.root, atol=self.__atol__, rtol=0) + + +class RoundedVector3D(RoundNumericValuesMixin, Vector3D): + def __init__(self, root: List[float]): + super().__init__(root=root) + + @model_serializer + def to_dict(self, skip_rounding: bool = False) -> List[float]: + rounded_value = self.round_array_or_number(self.root) if not skip_rounding else self.root + return Vector3D(root=rounded_value).model_dump() + + @property + def value_rounded(self): + return self.to_dict() + + @property + def x_rounded(self): + return self.value_rounded[0] + + @property + def y_rounded(self): + return self.value_rounded[1] + + @property + def z_rounded(self): + return self.value_rounded[2] + + def __eq__(self, other): + if isinstance(other, list): + other = RoundedVector3D(other) + atol = self.__atol__ or 10 ** (-self.__round_precision__) + return np.allclose(self.value_rounded, other.value_rounded, atol=atol, rtol=0) diff --git a/tests/py/unit/__init__.py b/tests/py/unit/__init__.py index e69de29b..ce42873b 100644 --- a/tests/py/unit/__init__.py +++ b/tests/py/unit/__init__.py @@ -0,0 +1,44 @@ +import json + +from mat3ra.code.entity import InMemoryEntityPydantic +from pydantic import BaseModel + +REFERENCE_OBJECT_VALID = {"key1": "value1", "key2": 1} +REFERENCE_OBJECT_VALID_UPDATED = {"key1": "value1-updated", "key2": 2} +REFERENCE_OBJECT_INVALID = {"key1": "value1", "key2": "value2"} +REFERENCE_OBJECT_VALID_JSON = json.dumps(REFERENCE_OBJECT_VALID) +REFERENCE_OBJECT_NESTED_VALID = {"nested_key1": {**REFERENCE_OBJECT_VALID}} +REFERENCE_OBJECT_NESTED_VALID_UPDATED = {"nested_key1": {**REFERENCE_OBJECT_VALID_UPDATED}} + +REFERENCE_OBJECT_DOUBLE_NESTED_VALID = {"double_nested_key1": {**REFERENCE_OBJECT_NESTED_VALID}} + + +class ExampleSchema(BaseModel): + key1: str + key2: int + + +class ExampleNestedSchema(BaseModel): + nested_key1: ExampleSchema + + +class ExampleDoubleNestedSchema(BaseModel): + double_nested_key1: ExampleNestedSchema + + +class ExampleClass(ExampleSchema, InMemoryEntityPydantic): + pass + + +class ExampleNestedClass(ExampleNestedSchema, InMemoryEntityPydantic): + @property + def nested_key1_instance(self) -> ExampleClass: + return ExampleClass.create(self.nested_key1.model_dump()) + + +class ExampleNestedKeyAsClassInstanceClass(ExampleNestedSchema, InMemoryEntityPydantic): + nested_key1: ExampleClass + + +class ExampleDoubleNestedKeyAsClassInstancesClass(ExampleDoubleNestedSchema, InMemoryEntityPydantic): + double_nested_key1: ExampleNestedKeyAsClassInstanceClass diff --git a/tests/py/unit/test_array_with_ids.py b/tests/py/unit/test_array_with_ids.py new file mode 100644 index 00000000..676a24e7 --- /dev/null +++ b/tests/py/unit/test_array_with_ids.py @@ -0,0 +1,291 @@ +import json + +import numpy as np +from mat3ra.code.array_with_ids import ArrayWithIds, RoundedArrayWithIds + +ARRAY_WITH_IDS_INTEGER_VALUES_CONFIG = {"values": [1, 2, 3], "ids": [0, 1, 2]} + +ARRAY_WITH_IDS_INTEGER_VALUES_CONFIG_TO_DICT_OUTPUT = [ + {"id": 0, "value": 1}, + {"id": 1, "value": 2}, + {"id": 2, "value": 3}, +] + +ARRAY_WITH_IDS_INTEGER_VALUES_CONFIG_TO_JSON_OUTPUT = json.dumps(ARRAY_WITH_IDS_INTEGER_VALUES_CONFIG_TO_DICT_OUTPUT) + +ARRAY_WITH_IDS_ARRAY_VALUES_CONFIG = {"values": [[1, 2], [3, 4], [5, 6]], "ids": [0, 1, 2]} + +ARRAY_WITH_IDS_ARRAY_VALUES_CONFIG_TO_DICT_OUTPUT = [ + {"id": 0, "value": [1, 2]}, + {"id": 1, "value": [3, 4]}, + {"id": 2, "value": [5, 6]}, +] + +ARRAY_WITH_IDS_ARRAY_VALUES_CONFIG_TO_JSON_OUTPUT = json.dumps(ARRAY_WITH_IDS_ARRAY_VALUES_CONFIG_TO_DICT_OUTPUT) + +ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG = { + "values": [[1.23456789, 2.3456789], [3.456789, 4.56789], [-5.6789, 0.0000006789]], + "ids": [0, 1, 2], +} + +ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT = [ + {"id": 0, "value": [1.23456789, 2.3456789]}, + {"id": 1, "value": [3.456789, 4.56789]}, + {"id": 2, "value": [-5.6789, 0.0000006789]}, +] + +ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_JSON_OUTPUT = json.dumps( + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT +) + +ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_NON_CONSECUTIVE = { + "values": [[1.23456789, 2.3456789], [3.456789, 4.56789], [-5.6789, 0.0000006789]], + "ids": [2, 4, 6], +} + +ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_NON_CONSECUTIVE_TO_DICT_OUTPUT = [ + {"id": 2, "value": [1.23456789, 2.3456789]}, + {"id": 4, "value": [3.456789, 4.56789]}, + {"id": 6, "value": [-5.6789, 0.0000006789]}, +] + +ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_NON_CONSECUTIVE_TO_JSON_OUTPUT = json.dumps( + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_NON_CONSECUTIVE_TO_DICT_OUTPUT +) + +ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT_ROUNDED = [ + {"id": 0, "value": [1.235, 2.346]}, + {"id": 1, "value": [3.457, 4.568]}, + {"id": 2, "value": [-5.679, 0.0]}, +] + +ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT_ROUNDED_JSON = json.dumps( + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT_ROUNDED +) + +ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT_ROUNDED_WITH_ADDITION_OF_ONE = [ + {"id": 0, "value": [2.235, 3.346]}, + {"id": 1, "value": [4.457, 5.568]}, + {"id": 2, "value": [-4.679, 1.0]}, +] + +ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT_ROUNDED_WITH_ADDITION_OF_ONE_JSON = json.dumps( + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT_ROUNDED_WITH_ADDITION_OF_ONE +) + + +def test_create_integers(): + instance = ArrayWithIds(**ARRAY_WITH_IDS_INTEGER_VALUES_CONFIG) + assert instance.values == ARRAY_WITH_IDS_INTEGER_VALUES_CONFIG["values"] + assert instance.ids == ARRAY_WITH_IDS_INTEGER_VALUES_CONFIG["ids"] + assert instance.to_dict() == ARRAY_WITH_IDS_INTEGER_VALUES_CONFIG_TO_DICT_OUTPUT + assert instance.to_json() == ARRAY_WITH_IDS_INTEGER_VALUES_CONFIG_TO_JSON_OUTPUT + + +def test_create_array_values(): + instance = ArrayWithIds(**ARRAY_WITH_IDS_ARRAY_VALUES_CONFIG) + assert instance.values == ARRAY_WITH_IDS_ARRAY_VALUES_CONFIG["values"] + assert instance.ids == ARRAY_WITH_IDS_ARRAY_VALUES_CONFIG["ids"] + assert instance.to_dict() == ARRAY_WITH_IDS_ARRAY_VALUES_CONFIG_TO_DICT_OUTPUT + assert instance.to_json() == ARRAY_WITH_IDS_ARRAY_VALUES_CONFIG_TO_JSON_OUTPUT + + +def test_create_arrays_of_float_values(): + instance = ArrayWithIds(**ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG) + assert instance.values == ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["values"] + assert instance.ids == ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["ids"] + assert instance.to_dict() == ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT + assert instance.to_json() == ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_JSON_OUTPUT + + +def test_create_arrays_of_float_values_rounded(): + local_ = RoundedArrayWithIds + local_.__round_precision__ = 3 + instance = local_(**ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG) + assert instance.values == ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["values"] + assert instance.ids == ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["ids"] + assert instance.to_dict() == ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT_ROUNDED + assert instance.to_json() == ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT_ROUNDED_JSON + + +def test_from_values(): + instance = ArrayWithIds.from_values(ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["values"]) + assert instance.values == ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["values"] + assert instance.ids == ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["ids"] + assert instance.to_dict() == ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT + assert instance.to_json() == ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_JSON_OUTPUT + + +def test_from_list_of_dicts(): + instance = ArrayWithIds.from_list_of_dicts(ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT) + assert instance.values == ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["values"] + assert instance.ids == ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["ids"] + assert instance.to_dict() == ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT + assert instance.to_json() == ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_JSON_OUTPUT + + +def test_from_list_of_dicts_non_consecutive(): + instance = ArrayWithIds.from_list_of_dicts( + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_NON_CONSECUTIVE_TO_DICT_OUTPUT + ) + assert instance.values == ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_NON_CONSECUTIVE["values"] + assert instance.ids == ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_NON_CONSECUTIVE["ids"] + assert instance.to_dict() == ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_NON_CONSECUTIVE_TO_DICT_OUTPUT + assert instance.to_json() == ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_NON_CONSECUTIVE_TO_JSON_OUTPUT + + +def test_filter_by_values(): + instance = ArrayWithIds(**ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG) + instance.filter_by_values( + [ + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["values"][0], + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["values"][2], + ] + ) + assert instance.values == [ + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["values"][0], + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["values"][2], + ] + assert instance.ids == [ + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["ids"][0], + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["ids"][2], + ] + assert instance.to_dict() == [ + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT[0], + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT[2], + ] + assert instance.to_json() == json.dumps( + [ + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT[0], + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT[2], + ] + ) + + +def test_filter_by_indices(): + instance = ArrayWithIds(**ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG) + instance.filter_by_indices([0, 2]) + assert instance.values == [ + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["values"][0], + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["values"][2], + ] + assert instance.ids == [ + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["ids"][0], + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["ids"][2], + ] + assert instance.to_dict() == [ + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT[0], + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT[2], + ] + assert instance.to_json() == json.dumps( + [ + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT[0], + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT[2], + ] + ) + + +def test_filter_by_ids(): + instance = ArrayWithIds(**ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG) + instance.filter_by_ids([0, 2]) + assert instance.values == [ + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["values"][0], + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["values"][2], + ] + assert instance.ids == [ + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["ids"][0], + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["ids"][2], + ] + assert instance.to_dict() == [ + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT[0], + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT[2], + ] + assert instance.to_json() == json.dumps( + [ + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT[0], + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT[2], + ] + ) + + +def test_filter_by_ids_invert(): + instance = ArrayWithIds(**ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG) + instance.filter_by_ids([0, 2], invert=True) + assert instance.values == [ + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["values"][1], + ] + assert instance.ids == [ + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["ids"][1], + ] + assert instance.to_dict() == [ + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT[1], + ] + assert instance.to_json() == json.dumps( + [ + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT[1], + ] + ) + + +def test_map_array_in_place_rounded(): + def add_one_to_each_element(value): + return [float(np.round(v + 1.0, decimals=3)) for v in value] + + instance = RoundedArrayWithIds(**ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG) + instance.map_array_in_place(add_one_to_each_element) + + assert instance.values == [ + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT_ROUNDED_WITH_ADDITION_OF_ONE[0]["value"], + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT_ROUNDED_WITH_ADDITION_OF_ONE[1]["value"], + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT_ROUNDED_WITH_ADDITION_OF_ONE[2]["value"], + ] + assert instance.ids == [0, 1, 2] + assert ( + instance.to_dict() == ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT_ROUNDED_WITH_ADDITION_OF_ONE + ) + assert ( + instance.to_json() + == ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT_ROUNDED_WITH_ADDITION_OF_ONE_JSON + ) + + +def test_add_item(): + instance = ArrayWithIds(**ARRAY_WITH_IDS_INTEGER_VALUES_CONFIG) + instance.add_item(4) + assert instance.values == [1, 2, 3, 4] + assert instance.ids == [0, 1, 2, 3] + assert instance.to_dict() == ARRAY_WITH_IDS_INTEGER_VALUES_CONFIG_TO_DICT_OUTPUT + [{"id": 3, "value": 4}] + assert instance.to_json() == json.dumps( + ARRAY_WITH_IDS_INTEGER_VALUES_CONFIG_TO_DICT_OUTPUT + [{"id": 3, "value": 4}] + ) + + instance.add_item(5, 5) + assert instance.values == [1, 2, 3, 4, 5] + assert instance.ids == [0, 1, 2, 3, 5] + assert instance.to_dict() == ARRAY_WITH_IDS_INTEGER_VALUES_CONFIG_TO_DICT_OUTPUT + [ + {"id": 3, "value": 4}, + {"id": 5, "value": 5}, + ] + assert instance.to_json() == json.dumps( + ARRAY_WITH_IDS_INTEGER_VALUES_CONFIG_TO_DICT_OUTPUT + [{"id": 3, "value": 4}] + [{"id": 5, "value": 5}] + ) + + +def test_remove_item(): + instance = ArrayWithIds(**ARRAY_WITH_IDS_INTEGER_VALUES_CONFIG) + instance.remove_item(1) + assert instance.values == [1, 3] + assert instance.ids == [0, 2] + assert instance.to_dict() == [ + ARRAY_WITH_IDS_INTEGER_VALUES_CONFIG_TO_DICT_OUTPUT[0], + ARRAY_WITH_IDS_INTEGER_VALUES_CONFIG_TO_DICT_OUTPUT[2], + ] + assert instance.to_json() == json.dumps( + [ARRAY_WITH_IDS_INTEGER_VALUES_CONFIG_TO_DICT_OUTPUT[0], ARRAY_WITH_IDS_INTEGER_VALUES_CONFIG_TO_DICT_OUTPUT[2]] + ) + + instance.remove_item(0, 0) + assert instance.values == [3] + assert instance.ids == [2] + assert instance.to_dict() == [ARRAY_WITH_IDS_INTEGER_VALUES_CONFIG_TO_DICT_OUTPUT[2]] + assert instance.to_json() == json.dumps([ARRAY_WITH_IDS_INTEGER_VALUES_CONFIG_TO_DICT_OUTPUT[2]]) diff --git a/tests/py/unit/test_constants.py b/tests/py/unit/test_constants.py new file mode 100644 index 00000000..662ce2a6 --- /dev/null +++ b/tests/py/unit/test_constants.py @@ -0,0 +1,12 @@ +from mat3ra.code.constants import FundamentalConstants + + +def test_constants(): + CONSTANTS = FundamentalConstants() + assert CONSTANTS.c == 299792458.0 + assert CONSTANTS.h == 6.62607015e-34 + assert CONSTANTS.e == 1.602176634e-19 + assert CONSTANTS.me == 9.109383713928e-31 + assert CONSTANTS.G == 6.6743015e-11 + assert CONSTANTS.eps0 == 8.854187818814e-12 + assert CONSTANTS.mu0 == 1.256637061272e-6 diff --git a/tests/py/unit/test_entity.py b/tests/py/unit/test_entity.py index 30d19740..b8d669e7 100644 --- a/tests/py/unit/test_entity.py +++ b/tests/py/unit/test_entity.py @@ -1,24 +1,167 @@ -from mat3ra.code.entity import InMemoryEntity +import json -REFERENCE_OBJECT_1 = {"key1": "value1", "key2": "value2"} +from . import ( + REFERENCE_OBJECT_DOUBLE_NESTED_VALID, + REFERENCE_OBJECT_INVALID, + REFERENCE_OBJECT_NESTED_VALID, + REFERENCE_OBJECT_NESTED_VALID_UPDATED, + REFERENCE_OBJECT_VALID, + REFERENCE_OBJECT_VALID_JSON, + REFERENCE_OBJECT_VALID_UPDATED, + ExampleClass, + ExampleDoubleNestedKeyAsClassInstancesClass, + ExampleDoubleNestedSchema, + ExampleNestedClass, + ExampleNestedKeyAsClassInstanceClass, + ExampleNestedSchema, + ExampleSchema, +) def test_create(): - in_memory_entity = InMemoryEntity.create({}) - assert isinstance(in_memory_entity, InMemoryEntity) + entity = ExampleClass.create(REFERENCE_OBJECT_VALID) + assert isinstance(entity, ExampleClass) + assert entity.key1 == "value1" + assert entity.key2 == 1 -def test_get_prop(): - in_memory_entity = InMemoryEntity.create(REFERENCE_OBJECT_1) - assert in_memory_entity.get_prop("key1") == "value1" +def test_create_nested(): + # Test creating an instance with nested valid data + entity = ExampleNestedClass.create(REFERENCE_OBJECT_NESTED_VALID) + assert isinstance(entity, ExampleNestedClass) + assert isinstance(entity.nested_key1, ExampleSchema) + assert entity.nested_key1.key1 == "value1" + assert entity.nested_key1.key2 == 1 + assert isinstance(entity.nested_key1_instance, ExampleClass) -def test_set_prop(): - in_memory_entity = InMemoryEntity.create(REFERENCE_OBJECT_1) - in_memory_entity.set_prop("key3", "value3") - assert in_memory_entity.get_prop("key3") == "value3" +def test_create_nested_as_class_instance(): + entity = ExampleNestedKeyAsClassInstanceClass.create(REFERENCE_OBJECT_NESTED_VALID) + assert isinstance(entity, ExampleNestedKeyAsClassInstanceClass) + assert isinstance(entity.nested_key1, ExampleClass) + assert entity.nested_key1.key1 == "value1" + assert entity.nested_key1.key2 == 1 + assert entity.get_data_model() == ExampleNestedSchema + + +def test_create_double_nested_as_class_instances(): + entity = ExampleDoubleNestedKeyAsClassInstancesClass.create(REFERENCE_OBJECT_DOUBLE_NESTED_VALID) + assert isinstance(entity, ExampleDoubleNestedKeyAsClassInstancesClass) + assert isinstance(entity.double_nested_key1, ExampleNestedKeyAsClassInstanceClass) + assert isinstance(entity.double_nested_key1.nested_key1, ExampleClass) + assert entity.double_nested_key1.nested_key1.key1 == "value1" + assert entity.double_nested_key1.nested_key1.key2 == 1 + assert entity.get_data_model() == ExampleDoubleNestedSchema + + +def test_update_nested_as_class_instance(): + entity = ExampleNestedKeyAsClassInstanceClass.create(REFERENCE_OBJECT_NESTED_VALID) + entity.nested_key1 = ExampleClass(**REFERENCE_OBJECT_VALID_UPDATED) + assert entity.nested_key1.key1 == "value1-updated" + assert entity.nested_key1.key2 == 2 + entity_json = entity.to_json() + reference_json = json.dumps(REFERENCE_OBJECT_NESTED_VALID_UPDATED) + assert json.loads(entity_json) == json.loads(reference_json) + assert isinstance(entity.nested_key1, ExampleClass) + + +def test_validate(): + # Test valid case + entity = ExampleClass.create(REFERENCE_OBJECT_VALID) + assert isinstance(entity, ExampleClass) + # Test invalid case + try: + _ = ExampleClass.create(REFERENCE_OBJECT_INVALID) + assert False, "Invalid input did not raise an exception" + except Exception: + assert True # Expecting an exception for invalid input + + +def test_is_valid(): + assert ExampleClass.is_valid(REFERENCE_OBJECT_VALID) is True + assert ExampleClass.is_valid(REFERENCE_OBJECT_INVALID) is False + + +def test_from_json(): + # Test from_json method with valid JSON + entity = ExampleClass.from_json(REFERENCE_OBJECT_VALID_JSON) + assert isinstance(entity, ExampleClass) + assert entity.key1 == "value1" + assert entity.key2 == 1 + + # Test from_json with invalid JSON + try: + _ = ExampleClass.from_json(json.dumps(REFERENCE_OBJECT_INVALID)) + assert False, "Invalid JSON did not raise an exception" + except Exception: + assert True # Expecting an exception for invalid JSON + + +def test_clean(): + # Test clean method with valid input + cleaned_data = ExampleClass.clean(REFERENCE_OBJECT_VALID) + assert isinstance(cleaned_data, dict) + assert cleaned_data == REFERENCE_OBJECT_VALID + + # Test clean method with invalid input + try: + _ = ExampleClass.clean(REFERENCE_OBJECT_INVALID) + assert False, "Invalid input did not raise an exception" + except Exception: + assert True # Expecting an exception for invalid input + + +def test_get_cls_name(): + # Test get_cls_name method + entity = ExampleClass.create(REFERENCE_OBJECT_VALID) + cls_name = entity.get_cls_name() + assert cls_name == "ExampleClass", f"Expected 'ExampleClass', got '{cls_name}'" + # Ensure it works for the class itself + assert ExampleClass.__name__ == "ExampleClass" + + +def test_to_dict(): + entity = ExampleClass.create(REFERENCE_OBJECT_VALID) + # Test to_dict method + result = entity.to_dict() + assert isinstance(result, dict) + assert result == {"key1": "value1", "key2": 1} + # Test with exclude + result_exclude = entity.to_dict(exclude=["key2"]) + assert result_exclude == {"key1": "value1"} def test_to_json(): - in_memory_entity = InMemoryEntity.create({}) - assert in_memory_entity.to_json() == {} + entity = ExampleClass.create(REFERENCE_OBJECT_VALID) + result = entity.to_json() + assert isinstance(result, str) + assert json.loads(result) == json.loads(REFERENCE_OBJECT_VALID_JSON) + + +def test_clone(): + entity = ExampleClass.create(REFERENCE_OBJECT_VALID) + # Test clone method + cloned_entity = entity.clone(deep=False) + assert isinstance(cloned_entity, ExampleClass) + assert cloned_entity.key1 == entity.key1 + assert cloned_entity.key2 == entity.key2 + + # Test clone with extra context + extra_context = {"key2": 2} + cloned_entity_with_extra = entity.clone(extra_context=extra_context, deep=False) + assert isinstance(cloned_entity_with_extra, ExampleClass) + assert cloned_entity_with_extra.key1 == entity.key1 + assert cloned_entity_with_extra.key2 == 2 # Should override to 2 + + +def test_clone_deep(): + entity = ExampleClass.create(REFERENCE_OBJECT_VALID) + # Test clone with deep=True + cloned_entity_deep = entity.clone(deep=True) + assert isinstance(cloned_entity_deep, ExampleClass) + assert cloned_entity_deep.key1 == entity.key1 + assert cloned_entity_deep.key2 == entity.key2 + + cloned_entity_deep.key1 = "adjusted_value" + assert entity.key1 == "value1" + assert cloned_entity_deep.key1 == "adjusted_value" diff --git a/tests/py/unit/test_mixins.py b/tests/py/unit/test_mixins.py new file mode 100644 index 00000000..64daaf01 --- /dev/null +++ b/tests/py/unit/test_mixins.py @@ -0,0 +1,62 @@ +from typing import Optional + +from mat3ra.code.mixins import DefaultableMixin, NamedMixin + + +def test_defaultable_mixin(): + # Test the DefaultableMixin functionality + default_config = {"key": "value", "number": 42} + + class ExampleDefaultable(DefaultableMixin): + key: str + number: int + + __default_config__ = default_config + + # Create a default instance + instance = ExampleDefaultable.create_default() + + assert instance.key == "value" + assert instance.number == 42 + assert hasattr(instance, "isDefault") + + +def test_named_mixin(): + # Test the NamedMixin functionality + class ExampleNamed(NamedMixin): + pass + + # Create an instance with a name + instance = ExampleNamed(name="TestName") + + assert instance.name == "TestName" + + # Test with None + instance_none = ExampleNamed() + assert instance_none.name is None + + +def test_complex_mixin(): + # Test a complex mixin + default_config = {"key": "value", "number": 42} + + class ExampleComplex(DefaultableMixin, NamedMixin): + key: Optional[str] + number: Optional[int] + + __default_config__ = default_config + + # Create a default instance + instance = ExampleComplex.create_default() + + assert instance.key == "value" + assert instance.number == 42 + assert hasattr(instance, "isDefault") + + # Create an instance with a name + instance = ExampleComplex(name="TestName", key=None, number=None) + + assert instance.name == "TestName" + assert instance.key is None + assert instance.number is None + assert hasattr(instance, "isDefault") diff --git a/tests/py/unit/test_value_with_id.py b/tests/py/unit/test_value_with_id.py new file mode 100644 index 00000000..7751a33f --- /dev/null +++ b/tests/py/unit/test_value_with_id.py @@ -0,0 +1,39 @@ +from mat3ra.code.value_with_id import RoundedValueWithId, ValueWithId + +EXAMPLE_VALUE_WITH_ID_DICT = {"id": 1, "value": "value"} + +EXAMPLE_VALUE_WITH_ID_JSON = '{"id": 1, "value": "value"}' + +EXAMPLE_VALUE_WITH_ID_DICT_FLOAT = {"id": 1, "value": 1.23456789} + +EXAMPLE_VALUE_WITH_ID_DICT_FLOAT_JSON = '{"id": 1, "value": 1.23456789}' + +EXAMPLE_VALUE_WITH_ID_DICT_FLOAT_ROUNDED_JSON = '{"id": 1, "value": 1.235}' + + +def test_create(): + instance = ValueWithId(**EXAMPLE_VALUE_WITH_ID_DICT) + assert instance.id == EXAMPLE_VALUE_WITH_ID_DICT["id"] + assert instance.value == EXAMPLE_VALUE_WITH_ID_DICT["value"] + assert instance.to_dict() == EXAMPLE_VALUE_WITH_ID_DICT + + +def test_to_json(): + instance = ValueWithId(**EXAMPLE_VALUE_WITH_ID_DICT) + assert instance.to_json() == EXAMPLE_VALUE_WITH_ID_JSON + + +def test_create_float(): + instance = ValueWithId(**EXAMPLE_VALUE_WITH_ID_DICT_FLOAT) + assert instance.id == EXAMPLE_VALUE_WITH_ID_DICT_FLOAT["id"] + assert instance.value == EXAMPLE_VALUE_WITH_ID_DICT_FLOAT["value"] + assert instance.to_dict() == EXAMPLE_VALUE_WITH_ID_DICT_FLOAT + + +def test_create_float_with_precision(): + local_ = RoundedValueWithId + local_.__round_precision__ = 3 + instance = local_(**EXAMPLE_VALUE_WITH_ID_DICT_FLOAT) + assert instance.id == EXAMPLE_VALUE_WITH_ID_DICT_FLOAT["id"] + assert instance.value == EXAMPLE_VALUE_WITH_ID_DICT_FLOAT["value"] + assert instance.to_json() == EXAMPLE_VALUE_WITH_ID_DICT_FLOAT_ROUNDED_JSON diff --git a/tests/py/unit/test_vector.py b/tests/py/unit/test_vector.py new file mode 100644 index 00000000..b10ec225 --- /dev/null +++ b/tests/py/unit/test_vector.py @@ -0,0 +1,72 @@ +from mat3ra.code.vector import RoundedVector3D, Vector3D + +VECTOR_FLOAT = [1.234567890, 2.345678901, 3.456789012] +VECTOR_FLOAT_DIFFERENT_WITHIN_TOL = [1.23456789999, 2.345678901, 3.456789012] +VECTOR_FLOAT_DIFFERENT_OUTSIDE_TOL = [1.2345699999, 2.345678901, 3.456789012] +VECTOR_FLOAT_ROUNDED_4 = [1.2346, 2.3457, 3.4568] +VECTOR_FLOAT_ROUNDED_3 = [1.235, 2.346, 3.457] + + +def test_vector_init(): + vector = Vector3D(VECTOR_FLOAT) + assert vector.model_dump() == VECTOR_FLOAT + assert vector.value == VECTOR_FLOAT + assert vector.x == 1.234567890 + assert vector.y == 2.345678901 + assert vector.z == 3.456789012 + + +def test_vector_init_wrong_type(): + try: + _ = Vector3D(root=[1, 2, "3"]) + except Exception as e: + assert str(e) == "3 is not of type float" + + +def test_vector_init_wrong_size(): + try: + _ = Vector3D([1, 2]) + assert False + except Exception: + assert True + + +def test_vector_equality(): + vector = Vector3D(VECTOR_FLOAT) + assert vector == VECTOR_FLOAT + assert vector == Vector3D(VECTOR_FLOAT_DIFFERENT_WITHIN_TOL) + assert vector != Vector3D(VECTOR_FLOAT_DIFFERENT_OUTSIDE_TOL) + + +def test_rounded_vector_init(): + vector = RoundedVector3D(VECTOR_FLOAT) + assert vector.model_dump() == VECTOR_FLOAT + assert vector.value == VECTOR_FLOAT + + +def test_rounded_vector_serialization(): + class_reference = RoundedVector3D + class_reference.__round_precision__ = 4 + vector = class_reference(VECTOR_FLOAT) + assert vector.model_dump() == VECTOR_FLOAT_ROUNDED_4 + assert vector.value_rounded == VECTOR_FLOAT_ROUNDED_4 + assert vector.x == VECTOR_FLOAT[0] + assert vector.y == VECTOR_FLOAT[1] + assert vector.z == VECTOR_FLOAT[2] + assert vector.x_rounded == VECTOR_FLOAT_ROUNDED_4[0] + assert vector.y_rounded == VECTOR_FLOAT_ROUNDED_4[1] + assert vector.z_rounded == VECTOR_FLOAT_ROUNDED_4[2] + assert vector.value == VECTOR_FLOAT + + class_reference = RoundedVector3D + class_reference.__round_precision__ = 3 + vector = class_reference(VECTOR_FLOAT) + assert vector.model_dump() == VECTOR_FLOAT_ROUNDED_3 + assert vector.value_rounded == VECTOR_FLOAT_ROUNDED_3 + assert vector.value == VECTOR_FLOAT + assert vector.x == VECTOR_FLOAT[0] + assert vector.y == VECTOR_FLOAT[1] + assert vector.z == VECTOR_FLOAT[2] + assert vector.x_rounded == VECTOR_FLOAT_ROUNDED_3[0] + assert vector.y_rounded == VECTOR_FLOAT_ROUNDED_3[1] + assert vector.z_rounded == VECTOR_FLOAT_ROUNDED_3[2]