mirror of
https://github.com/divkit/divkit.git
synced 2026-05-07 20:02:32 +00:00
2987d93ba7
commit_hash:cf5070a543788fa57136adb1a4a7ea42f4490329
616 lines
20 KiB
Python
616 lines
20 KiB
Python
import enum
|
|
from typing import Any, Mapping, Optional, Sequence, Union
|
|
|
|
import pytest
|
|
|
|
from pydivkit.core import BaseDiv, BaseEntity, Expr, Field, Ref
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# BaseEntity: construction and field assignment
|
|
# ---------------------------------------------------------------------------
|
|
class TestBaseEntityInit:
|
|
def test_create_simple_entity(self):
|
|
class SimpleEntity(BaseEntity):
|
|
name: str = Field()
|
|
|
|
entity = SimpleEntity(name="hello")
|
|
assert entity.name == "hello"
|
|
|
|
def test_field_with_default(self):
|
|
class DefaultEntity(BaseEntity):
|
|
color: str = Field(default="red")
|
|
|
|
entity = DefaultEntity()
|
|
assert entity.color == "red"
|
|
|
|
def test_optional_field_default_none(self):
|
|
"""Optional field defaults to None when not provided."""
|
|
|
|
class OptEntity(BaseEntity):
|
|
label: Optional[str] = Field()
|
|
|
|
entity = OptEntity()
|
|
assert entity.label is None
|
|
|
|
def test_sequence_field(self):
|
|
class SeqEntity(BaseEntity):
|
|
items: Sequence[int] = Field()
|
|
|
|
entity = SeqEntity(items=[1, 2, 3])
|
|
assert entity.items == [1, 2, 3]
|
|
|
|
def test_sequence_field_wrong_type_raises(self):
|
|
class SeqEntity2(BaseEntity):
|
|
items: Sequence[int] = Field()
|
|
|
|
with pytest.raises(ValueError, match="has wrong type"):
|
|
SeqEntity2(items=42)
|
|
|
|
def test_mapping_field(self):
|
|
class MapEntity(BaseEntity):
|
|
data: Mapping[str, int] = Field()
|
|
|
|
entity = MapEntity(data={"a": 1, "b": 2})
|
|
assert entity.data == {"a": 1, "b": 2}
|
|
|
|
def test_mapping_field_wrong_type_raises(self):
|
|
class MapEntity2(BaseEntity):
|
|
data: Mapping[str, int] = Field()
|
|
|
|
with pytest.raises(ValueError, match="has wrong type"):
|
|
MapEntity2(data=42)
|
|
|
|
def test_any_field_passthrough(self):
|
|
class AnyEntity(BaseEntity):
|
|
data: Any = Field()
|
|
|
|
value = {"key": [1, 2, 3]}
|
|
entity = AnyEntity(data=value)
|
|
assert entity.data is value
|
|
|
|
def test_union_field_exact_match(self):
|
|
class UnionEntity(BaseEntity):
|
|
val: Union[int, str] = Field()
|
|
|
|
entity = UnionEntity(val=42)
|
|
assert entity.val == 42
|
|
|
|
def test_union_field_no_match_raises(self):
|
|
class UnionEntity2(BaseEntity):
|
|
val: Union[int, float] = Field()
|
|
|
|
with pytest.raises(ValueError, match="has wrong type"):
|
|
UnionEntity2(val=[])
|
|
|
|
def test_type_coercion(self):
|
|
"""Values are coerced to field type (e.g. int -> float)."""
|
|
|
|
class CoerceEntity(BaseEntity):
|
|
val: float = Field()
|
|
|
|
entity = CoerceEntity(val=42)
|
|
assert isinstance(entity.val, float)
|
|
assert entity.val == 42.0
|
|
|
|
def test_unknown_attribute_raises(self):
|
|
class StrictEntity(BaseEntity):
|
|
val: int = Field(default=0)
|
|
|
|
entity = StrictEntity()
|
|
with pytest.raises(KeyError, match="has no attribute 'unknown'"):
|
|
entity.unknown = 42
|
|
|
|
def test_wrong_type_raises(self):
|
|
"""Passing an incompatible value type raises ValueError."""
|
|
|
|
class StrictTypeEntity(BaseEntity):
|
|
val: int = Field()
|
|
|
|
with pytest.raises(ValueError, match="has wrong type"):
|
|
StrictTypeEntity(val=[])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# BaseEntity: type field validation
|
|
# ---------------------------------------------------------------------------
|
|
class TestBaseEntityTypeField:
|
|
def test_type_field_wrong_value_raises(self):
|
|
class TypedEntity(BaseEntity):
|
|
type: str = Field(name="type", default="expected")
|
|
|
|
with pytest.raises(ValueError, match="has wrong value"):
|
|
TypedEntity(type="wrong")
|
|
|
|
def test_type_field_correct_value(self):
|
|
class TypedEntity2(BaseEntity):
|
|
type: str = Field(name="type", default="correct")
|
|
|
|
entity = TypedEntity2(type="correct")
|
|
assert entity.type == "correct"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# BaseEntity: dict() serialization
|
|
# ---------------------------------------------------------------------------
|
|
class TestBaseEntityDict:
|
|
def test_simple_dict(self):
|
|
class DictEntity(BaseEntity):
|
|
name: str = Field(default="default")
|
|
count: Optional[int] = Field()
|
|
|
|
entity = DictEntity(name="test", count=5)
|
|
assert entity.dict() == {"name": "test", "count": 5}
|
|
|
|
def test_dict_skips_none(self):
|
|
class NoneEntity(BaseEntity):
|
|
name: str = Field(default="x")
|
|
opt: Optional[int] = Field()
|
|
|
|
entity = NoneEntity(name="hello")
|
|
assert entity.dict() == {"name": "hello"}
|
|
|
|
def test_dict_with_nested_entity(self):
|
|
class Inner(BaseEntity):
|
|
x: int = Field(default=1)
|
|
|
|
class Outer(BaseEntity):
|
|
inner: Inner = Field()
|
|
|
|
entity = Outer(inner=Inner(x=10))
|
|
assert entity.dict() == {"inner": {"x": 10}}
|
|
|
|
def test_dict_with_sequence(self):
|
|
class SeqDictEntity(BaseEntity):
|
|
items: Sequence[int] = Field()
|
|
|
|
entity = SeqDictEntity(items=[1, 2, 3])
|
|
assert entity.dict() == {"items": [1, 2, 3]}
|
|
|
|
def test_dict_with_mapping(self):
|
|
class MapDictEntity(BaseEntity):
|
|
data: Mapping[str, Any] = Field()
|
|
|
|
entity = MapDictEntity(data={"key": "value"})
|
|
assert entity.dict() == {"data": {"key": "value"}}
|
|
|
|
def test_dict_with_expr(self):
|
|
class ExprEntity(BaseEntity):
|
|
val: Union[str, Expr] = Field()
|
|
|
|
entity = ExprEntity(val=Expr("@{some_var}"))
|
|
assert entity.dict() == {"val": "@{some_var}"}
|
|
|
|
def test_dict_with_enum(self):
|
|
class Color(enum.Enum):
|
|
RED = "red"
|
|
GREEN = "green"
|
|
|
|
class EnumEntity(BaseEntity):
|
|
color: Color = Field(default=Color.RED)
|
|
|
|
entity = EnumEntity(color=Color.GREEN)
|
|
assert entity.dict() == {"color": "green"}
|
|
|
|
def test_dict_with_nested_entity_in_sequence(self):
|
|
class Item(BaseEntity):
|
|
x: int = Field(default=1)
|
|
|
|
class ListEntity(BaseEntity):
|
|
items: Sequence[Item] = Field()
|
|
|
|
entity = ListEntity(items=[Item(x=10)])
|
|
assert entity.dict() == {"items": [{"x": 10}]}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# BaseEntity: template_name
|
|
# ---------------------------------------------------------------------------
|
|
class TestBaseEntityTemplateName:
|
|
def test_default_template_name(self):
|
|
class NameEntity(BaseEntity):
|
|
val: int = Field(default=0)
|
|
|
|
expected = f"{NameEntity.__module__}.{NameEntity.__name__}"
|
|
assert NameEntity.template_name == expected
|
|
|
|
def test_custom_template_name(self):
|
|
class CustomNameEntity(BaseEntity):
|
|
__template_name__ = "custom.template"
|
|
val: int = Field(default=0)
|
|
|
|
assert CustomNameEntity.template_name == "custom.template"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# BaseEntity: related_templates
|
|
# ---------------------------------------------------------------------------
|
|
class TestBaseEntityRelatedTemplates:
|
|
def test_empty_for_plain_entity(self):
|
|
class PlainEntity(BaseEntity):
|
|
val: int = Field(default=0)
|
|
|
|
entity = PlainEntity()
|
|
assert entity.related_templates() == set()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# BaseEntity: union with injected types (dict -> entity cast)
|
|
# ---------------------------------------------------------------------------
|
|
class TestBaseEntityUnionInjectedTypes:
|
|
def test_union_field_dict_cast_to_entity(self):
|
|
"""Dict with 'type' field is cast to correct entity in a Union."""
|
|
|
|
class TypeAlpha(BaseEntity):
|
|
type: str = Field(name="type", default="alpha")
|
|
val: int = Field(default=0)
|
|
|
|
class TypeBeta(BaseEntity):
|
|
type: str = Field(name="type", default="beta")
|
|
name: str = Field(default="x")
|
|
|
|
class Container(BaseEntity):
|
|
child: Union[TypeAlpha, TypeBeta] = Field()
|
|
|
|
entity = Container(child={"type": "alpha", "val": 42})
|
|
assert isinstance(entity.child, TypeAlpha)
|
|
assert entity.child.val == 42
|
|
|
|
def test_union_field_dict_without_type_raises(self):
|
|
class TypeGamma(BaseEntity):
|
|
type: str = Field(name="type", default="gamma")
|
|
val: int = Field(default=0)
|
|
|
|
class TypeDelta(BaseEntity):
|
|
type: str = Field(name="type", default="delta")
|
|
val: int = Field(default=0)
|
|
|
|
class Container2(BaseEntity):
|
|
child: Union[TypeGamma, TypeDelta] = Field()
|
|
|
|
with pytest.raises(ValueError, match="does not have field type"):
|
|
Container2(child={"val": 42})
|
|
|
|
def test_union_field_dict_unknown_type_raises(self):
|
|
class TypeEpsilon(BaseEntity):
|
|
type: str = Field(name="type", default="epsilon")
|
|
val: int = Field(default=0)
|
|
|
|
class TypeZeta(BaseEntity):
|
|
type: str = Field(name="type", default="zeta")
|
|
val: int = Field(default=0)
|
|
|
|
class Container3(BaseEntity):
|
|
child: Union[TypeEpsilon, TypeZeta] = Field()
|
|
|
|
with pytest.raises(ValueError, match="does not contain type"):
|
|
Container3(child={"type": "unknown", "val": 1})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# BaseDiv: template building
|
|
# ---------------------------------------------------------------------------
|
|
class TestBaseDivTemplate:
|
|
def test_direct_subclass_is_not_template(self):
|
|
class RawDiv(BaseDiv):
|
|
name: str = Field(default="x")
|
|
|
|
with pytest.raises(TypeError, match="is not a template"):
|
|
RawDiv.template()
|
|
|
|
def test_template_with_tpl_values(self):
|
|
class CompBase(BaseDiv):
|
|
type: str = Field(name="type", default="component")
|
|
text: str = Field()
|
|
|
|
class MyComponent(CompBase):
|
|
text = "default_text"
|
|
|
|
tpl = MyComponent.template()
|
|
assert tpl["type"] == "component"
|
|
assert tpl["text"] == "default_text"
|
|
|
|
def test_template_with_ref_field(self):
|
|
class RefBase(BaseDiv):
|
|
type: str = Field(name="type", default="ref_comp")
|
|
label: str = Field()
|
|
|
|
class RefTemplate(RefBase):
|
|
custom_label: str = Field()
|
|
label = Ref(custom_label)
|
|
|
|
tpl = RefTemplate.template()
|
|
assert tpl["type"] == "ref_comp"
|
|
assert "$label" in tpl
|
|
assert tpl["$label"] == "custom_label"
|
|
|
|
def test_template_caching(self):
|
|
class CacheBase(BaseDiv):
|
|
type: str = Field(name="type", default="cache_test")
|
|
val: int = Field()
|
|
|
|
class CachedComponent(CacheBase):
|
|
val = 42
|
|
|
|
tpl1 = CachedComponent.template()
|
|
tpl2 = CachedComponent.template()
|
|
assert tpl1 is tpl2
|
|
|
|
def test_template_none_tpl_value_not_overridden(self):
|
|
"""Instantiating with None doesn't override template values."""
|
|
|
|
class TplBase(BaseDiv):
|
|
type: str = Field(name="type", default="tpl_base")
|
|
color: str = Field()
|
|
|
|
class TplChild(TplBase):
|
|
color = "red"
|
|
|
|
instance = TplChild()
|
|
assert instance.dict().get("type") is not None
|
|
|
|
def test_multiple_bases_raises(self):
|
|
with pytest.raises(TypeError, match="cannot be uniquely identified"):
|
|
|
|
class MultiBaseDiv(BaseDiv, BaseEntity):
|
|
pass
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# BaseDiv: schema()
|
|
# ---------------------------------------------------------------------------
|
|
class TestBaseDivSchema:
|
|
def test_schema_returns_object_with_definitions(self):
|
|
class SchBase(BaseDiv):
|
|
type: str = Field(name="type", default="schema_test")
|
|
val: int = Field()
|
|
|
|
class SchChild(SchBase):
|
|
pass
|
|
|
|
schema = SchChild.schema()
|
|
assert schema["type"] == "object"
|
|
assert "definitions" in schema
|
|
assert isinstance(schema["definitions"], dict)
|
|
|
|
def test_schema_tpl_values_excluded(self):
|
|
class SchBase2(BaseDiv):
|
|
type: str = Field(name="type", default="sch_tpl")
|
|
color: str = Field()
|
|
size: int = Field()
|
|
|
|
class SchChild2(SchBase2):
|
|
color = "red"
|
|
|
|
schema = SchChild2.schema()
|
|
props = schema.get("properties", {})
|
|
assert "color" not in props
|
|
assert "size" in props
|
|
|
|
def test_schema_with_exclude_fields(self):
|
|
class SchBase3(BaseDiv):
|
|
type: str = Field(name="type", default="sch_excl")
|
|
a: str = Field()
|
|
b: str = Field()
|
|
|
|
class SchChild3(SchBase3):
|
|
pass
|
|
|
|
schema = SchChild3.schema(exclude_fields=["a"])
|
|
props = schema.get("properties", {})
|
|
assert "a" not in props
|
|
|
|
def test_schema_with_nested_exclude(self):
|
|
class SchBase4(BaseDiv):
|
|
type: str = Field(name="type", default="sch_nest")
|
|
a: str = Field()
|
|
b: str = Field()
|
|
|
|
class SchChild4(SchBase4):
|
|
pass
|
|
|
|
schema = SchChild4.schema(exclude_fields=["a.nested"])
|
|
assert "definitions" in schema
|
|
|
|
def test_schema_optional_not_required(self):
|
|
class SchBase5(BaseDiv):
|
|
type: str = Field(name="type", default="sch_opt")
|
|
required_field: int = Field()
|
|
optional_field: Optional[str] = Field()
|
|
|
|
class SchChild5(SchBase5):
|
|
pass
|
|
|
|
schema = SchChild5.schema()
|
|
required = schema.get("required", [])
|
|
assert "required_field" in required
|
|
assert "optional_field" not in required
|
|
|
|
def test_schema_int_field(self):
|
|
class IntBase(BaseDiv):
|
|
type: str = Field(name="type", default="int_sch")
|
|
count: int = Field()
|
|
|
|
class IntChild(IntBase):
|
|
pass
|
|
|
|
schema = IntChild.schema()
|
|
props = schema.get("properties", {})
|
|
assert props["count"]["type"] == "integer"
|
|
|
|
def test_schema_str_field(self):
|
|
class StrBase(BaseDiv):
|
|
type: str = Field(name="type", default="str_sch")
|
|
name: str = Field()
|
|
|
|
class StrChild(StrBase):
|
|
pass
|
|
|
|
schema = StrChild.schema()
|
|
props = schema.get("properties", {})
|
|
assert props["name"]["type"] == "string"
|
|
|
|
def test_schema_float_field(self):
|
|
class FloatBase(BaseDiv):
|
|
type: str = Field(name="type", default="float_sch")
|
|
rate: float = Field()
|
|
|
|
class FloatChild(FloatBase):
|
|
pass
|
|
|
|
schema = FloatChild.schema()
|
|
props = schema.get("properties", {})
|
|
assert props["rate"]["type"] == "number"
|
|
|
|
def test_schema_bool_field(self):
|
|
class BoolBase(BaseDiv):
|
|
type: str = Field(name="type", default="bool_sch")
|
|
flag: bool = Field()
|
|
|
|
class BoolChild(BoolBase):
|
|
pass
|
|
|
|
schema = BoolChild.schema()
|
|
props = schema.get("properties", {})
|
|
assert props["flag"]["type"] == "integer"
|
|
assert props["flag"]["format"] == "boolean"
|
|
|
|
def test_schema_sequence_field(self):
|
|
class SeqBase(BaseDiv):
|
|
type: str = Field(name="type", default="seq_sch")
|
|
items: Sequence[int] = Field()
|
|
|
|
class SeqChild(SeqBase):
|
|
pass
|
|
|
|
schema = SeqChild.schema()
|
|
props = schema.get("properties", {})
|
|
assert props["items"]["type"] == "array"
|
|
assert props["items"]["items"]["type"] == "integer"
|
|
|
|
def test_schema_mapping_field(self):
|
|
class MapBase(BaseDiv):
|
|
type: str = Field(name="type", default="map_sch")
|
|
data: Mapping[str, Any] = Field()
|
|
|
|
class MapChild(MapBase):
|
|
pass
|
|
|
|
schema = MapChild.schema()
|
|
props = schema.get("properties", {})
|
|
assert props["data"]["type"] == "object"
|
|
assert props["data"]["additionalProperties"] is True
|
|
|
|
def test_schema_union_field(self):
|
|
class UnionBase(BaseDiv):
|
|
type: str = Field(name="type", default="union_sch")
|
|
val: Union[int, str] = Field()
|
|
|
|
class UnionChild(UnionBase):
|
|
pass
|
|
|
|
schema = UnionChild.schema()
|
|
props = schema.get("properties", {})
|
|
assert "anyOf" in props["val"]
|
|
type_values = [a["type"] for a in props["val"]["anyOf"]]
|
|
assert "integer" in type_values
|
|
assert "string" in type_values
|
|
|
|
def test_schema_enum_field(self):
|
|
class Direction(enum.Enum):
|
|
LEFT = "left"
|
|
RIGHT = "right"
|
|
|
|
class EnumBase(BaseDiv):
|
|
type: str = Field(name="type", default="enum_sch")
|
|
dir: Direction = Field()
|
|
|
|
class EnumChild(EnumBase):
|
|
pass
|
|
|
|
schema = EnumChild.schema()
|
|
props = schema.get("properties", {})
|
|
assert props["dir"]["type"] == "string"
|
|
assert set(props["dir"]["enum"]) == {"left", "right"}
|
|
|
|
def test_schema_nested_entity_field(self):
|
|
class Nested(BaseEntity):
|
|
x: int = Field(default=0)
|
|
|
|
class NestBase(BaseDiv):
|
|
type: str = Field(name="type", default="nest_sch")
|
|
child: Nested = Field()
|
|
|
|
class NestChild(NestBase):
|
|
pass
|
|
|
|
schema = NestChild.schema()
|
|
props = schema.get("properties", {})
|
|
assert "$ref" in props["child"]
|
|
assert "Nested" in props["child"]["$ref"]
|
|
assert "Nested" in schema["definitions"]
|
|
|
|
def test_schema_field_with_description_and_constraints(self):
|
|
class DescBase(BaseDiv):
|
|
type: str = Field(name="type", default="desc_sch")
|
|
count: int = Field(
|
|
description="Item count",
|
|
ge=0,
|
|
le=100,
|
|
)
|
|
|
|
class DescChild(DescBase):
|
|
pass
|
|
|
|
schema = DescChild.schema()
|
|
props = schema.get("properties", {})
|
|
count_schema = props["count"]
|
|
assert count_schema.get("description") == "Item count"
|
|
assert count_schema.get("minimum") == 0
|
|
assert count_schema.get("maximum") == 100
|
|
|
|
def test_schema_type_field(self):
|
|
"""The 'type' field with a default generates enum schema."""
|
|
|
|
class TypeBase(BaseDiv):
|
|
type: str = Field(name="type", default="type_sch")
|
|
val: int = Field()
|
|
|
|
class TypeChild(TypeBase):
|
|
pass
|
|
|
|
schema = TypeChild.schema()
|
|
props = schema.get("properties", {})
|
|
type_prop = props["type"]
|
|
assert type_prop["type"] == "string"
|
|
assert "enum" in type_prop
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Integration with generated DivKit classes
|
|
# ---------------------------------------------------------------------------
|
|
class TestIntegration:
|
|
def test_make_div_with_template(self):
|
|
from pydivkit import DivContainer, DivText, make_div
|
|
|
|
class MyTemplate(DivContainer):
|
|
label: str = Field()
|
|
items = [DivText(text=Ref(label))]
|
|
|
|
result = make_div(MyTemplate(items=[], label="hello"))
|
|
assert "templates" in result
|
|
assert "card" in result
|
|
tpl_name = MyTemplate.template_name
|
|
assert tpl_name in result["templates"]
|
|
|
|
def test_schema_with_generated_classes(self):
|
|
from pydivkit import DivContainer, DivText
|
|
|
|
class TextTemplate(DivContainer):
|
|
label: str = Field()
|
|
items = [DivText(text=Ref(label))]
|
|
|
|
schema = TextTemplate.schema()
|
|
assert schema["type"] == "object"
|
|
assert "definitions" in schema
|