mirror of
https://github.com/django-components/django-components.git
synced 2025-07-07 17:34:59 +00:00
640 lines
20 KiB
Python
640 lines
20 KiB
Python
import re
|
|
from dataclasses import dataclass
|
|
from typing import NamedTuple, Optional
|
|
from typing_extensions import NotRequired, TypedDict
|
|
|
|
import pytest
|
|
from django.template import Context
|
|
|
|
from django_components import Component, Empty, Slot, SlotInput
|
|
|
|
from django_components.testing import djc_test
|
|
from .testutils import setup_test_config
|
|
|
|
setup_test_config({"autodiscover": False})
|
|
|
|
|
|
@djc_test
|
|
class TestComponentTyping:
|
|
def test_data_methods_input_typed(self):
|
|
template_called = False
|
|
js_called = False
|
|
css_called = False
|
|
|
|
class ButtonFooterSlotData(TypedDict):
|
|
value: int
|
|
|
|
class Button(Component):
|
|
class Args(NamedTuple):
|
|
arg1: str
|
|
arg2: str
|
|
|
|
@dataclass
|
|
class Kwargs:
|
|
name: str
|
|
age: int
|
|
maybe_var: Optional[int] = None
|
|
|
|
class Slots(TypedDict):
|
|
# Use `SlotInput` when you want to pass slot as string
|
|
header: SlotInput
|
|
# Use `Slot` for slot functions.
|
|
# The generic specifies the data available to the slot function
|
|
footer: NotRequired[Slot[ButtonFooterSlotData]]
|
|
|
|
def get_template_data(self, args: Args, kwargs: Kwargs, slots: Slots, context: Context):
|
|
nonlocal template_called
|
|
template_called = True
|
|
|
|
assert isinstance(args, Button.Args)
|
|
assert isinstance(kwargs, Button.Kwargs)
|
|
assert isinstance(slots, dict)
|
|
assert isinstance(context, Context)
|
|
|
|
def get_js_data(self, args: Args, kwargs: Kwargs, slots: Slots, context: Context):
|
|
nonlocal js_called
|
|
js_called = True
|
|
|
|
assert isinstance(args, Button.Args)
|
|
assert isinstance(kwargs, Button.Kwargs)
|
|
assert isinstance(slots, dict)
|
|
assert isinstance(context, Context)
|
|
|
|
def get_css_data(self, args: Args, kwargs: Kwargs, slots: Slots, context: Context):
|
|
nonlocal css_called
|
|
css_called = True
|
|
|
|
assert isinstance(args, Button.Args)
|
|
assert isinstance(kwargs, Button.Kwargs)
|
|
assert isinstance(slots, dict)
|
|
assert isinstance(context, Context)
|
|
|
|
template = "<button>Click me!</button>"
|
|
|
|
Button.render(
|
|
args=Button.Args(arg1="arg1", arg2="arg2"),
|
|
kwargs=Button.Kwargs(name="name", age=123),
|
|
slots=Button.Slots(
|
|
header="HEADER",
|
|
footer=Slot(lambda ctx: "FOOTER"),
|
|
),
|
|
)
|
|
|
|
assert template_called
|
|
assert js_called
|
|
assert css_called
|
|
|
|
def test_data_methods_input_not_typed_by_default(self):
|
|
template_called = False
|
|
js_called = False
|
|
css_called = False
|
|
|
|
class Button(Component):
|
|
def get_template_data(self, args, kwargs, slots, context):
|
|
nonlocal template_called
|
|
template_called = True
|
|
|
|
assert isinstance(args, list)
|
|
assert isinstance(kwargs, dict)
|
|
assert isinstance(slots, dict)
|
|
assert isinstance(context, Context)
|
|
|
|
def get_js_data(self, args, kwargs, slots, context):
|
|
nonlocal js_called
|
|
js_called = True
|
|
|
|
assert isinstance(args, list)
|
|
assert isinstance(kwargs, dict)
|
|
assert isinstance(slots, dict)
|
|
assert isinstance(context, Context)
|
|
|
|
def get_css_data(self, args, kwargs, slots, context):
|
|
nonlocal css_called
|
|
css_called = True
|
|
|
|
assert isinstance(args, list)
|
|
assert isinstance(kwargs, dict)
|
|
assert isinstance(slots, dict)
|
|
assert isinstance(context, Context)
|
|
|
|
template = "<button>Click me!</button>"
|
|
|
|
Button.render(
|
|
args=["arg1", "arg2"],
|
|
kwargs={"name": "name", "age": 123},
|
|
slots={
|
|
"header": "HEADER",
|
|
"footer": Slot(lambda ctx: "FOOTER"),
|
|
},
|
|
)
|
|
|
|
assert template_called
|
|
assert js_called
|
|
assert css_called
|
|
|
|
def test_data_methods_output_typed(self):
|
|
template_called = False
|
|
js_called = False
|
|
css_called = False
|
|
|
|
template_data_instance = None
|
|
js_data_instance = None
|
|
css_data_instance = None
|
|
|
|
class Button(Component):
|
|
# Data returned from `get_template_data`
|
|
@dataclass
|
|
class TemplateData:
|
|
data1: str
|
|
data2: int
|
|
|
|
def __post_init__(self):
|
|
nonlocal template_data_instance
|
|
template_data_instance = self
|
|
|
|
# Data returned from `get_js_data`
|
|
@dataclass
|
|
class JsData:
|
|
js_data1: str
|
|
js_data2: int
|
|
|
|
def __post_init__(self):
|
|
nonlocal js_data_instance
|
|
js_data_instance = self
|
|
|
|
# Data returned from `get_css_data`
|
|
@dataclass
|
|
class CssData:
|
|
css_data1: str
|
|
css_data2: int
|
|
|
|
def __post_init__(self):
|
|
nonlocal css_data_instance
|
|
css_data_instance = self
|
|
|
|
def get_template_data(self, args, kwargs, slots, context):
|
|
nonlocal template_called
|
|
template_called = True
|
|
|
|
return {
|
|
"data1": "data1",
|
|
"data2": 123,
|
|
}
|
|
|
|
def get_js_data(self, args, kwargs, slots, context):
|
|
nonlocal js_called
|
|
js_called = True
|
|
|
|
return {
|
|
"js_data1": "js_data1",
|
|
"js_data2": 123,
|
|
}
|
|
|
|
def get_css_data(self, args, kwargs, slots, context):
|
|
nonlocal css_called
|
|
css_called = True
|
|
|
|
return {
|
|
"css_data1": "css_data1",
|
|
"css_data2": 123,
|
|
}
|
|
|
|
template = "<button>Click me!</button>"
|
|
|
|
Button.render(
|
|
args=["arg1", "arg2"],
|
|
kwargs={"name": "name", "age": 123},
|
|
slots={
|
|
"header": "HEADER",
|
|
"footer": Slot(lambda ctx: "FOOTER"),
|
|
},
|
|
)
|
|
|
|
assert template_called
|
|
assert js_called
|
|
assert css_called
|
|
|
|
assert isinstance(template_data_instance, Button.TemplateData)
|
|
assert isinstance(js_data_instance, Button.JsData)
|
|
assert isinstance(css_data_instance, Button.CssData)
|
|
|
|
assert template_data_instance == Button.TemplateData(data1="data1", data2=123)
|
|
assert js_data_instance == Button.JsData(js_data1="js_data1", js_data2=123)
|
|
assert css_data_instance == Button.CssData(css_data1="css_data1", css_data2=123)
|
|
|
|
def test_data_methods_output_typed_reuses_instances(self):
|
|
template_called = False
|
|
js_called = False
|
|
css_called = False
|
|
|
|
template_data_instance = None
|
|
js_data_instance = None
|
|
css_data_instance = None
|
|
|
|
class Button(Component):
|
|
# Data returned from `get_template_data`
|
|
@dataclass
|
|
class TemplateData:
|
|
data1: str
|
|
data2: int
|
|
|
|
def __post_init__(self):
|
|
nonlocal template_data_instance
|
|
template_data_instance = self
|
|
|
|
# Data returned from `get_js_data`
|
|
@dataclass
|
|
class JsData:
|
|
js_data1: str
|
|
js_data2: int
|
|
|
|
def __post_init__(self):
|
|
nonlocal js_data_instance
|
|
js_data_instance = self
|
|
|
|
# Data returned from `get_css_data`
|
|
@dataclass
|
|
class CssData:
|
|
css_data1: str
|
|
css_data2: int
|
|
|
|
def __post_init__(self):
|
|
nonlocal css_data_instance
|
|
css_data_instance = self
|
|
|
|
def get_template_data(self, args, kwargs, slots, context):
|
|
nonlocal template_called
|
|
template_called = True
|
|
|
|
data = Button.TemplateData(
|
|
data1="data1",
|
|
data2=123,
|
|
)
|
|
|
|
# Reset the instance to None to check if the instance is reused
|
|
nonlocal template_data_instance
|
|
template_data_instance = None
|
|
|
|
return data
|
|
|
|
def get_js_data(self, args, kwargs, slots, context):
|
|
nonlocal js_called
|
|
js_called = True
|
|
|
|
data = Button.JsData(
|
|
js_data1="js_data1",
|
|
js_data2=123,
|
|
)
|
|
|
|
# Reset the instance to None to check if the instance is reused
|
|
nonlocal js_data_instance
|
|
js_data_instance = None
|
|
|
|
return data
|
|
|
|
def get_css_data(self, args, kwargs, slots, context):
|
|
nonlocal css_called
|
|
css_called = True
|
|
|
|
data = Button.CssData(
|
|
css_data1="css_data1",
|
|
css_data2=123,
|
|
)
|
|
|
|
# Reset the instance to None to check if the instance is reused
|
|
nonlocal css_data_instance
|
|
css_data_instance = None
|
|
|
|
return data
|
|
|
|
template = "<button>Click me!</button>"
|
|
|
|
Button.render(
|
|
args=["arg1", "arg2"],
|
|
kwargs={"name": "name", "age": 123},
|
|
slots={
|
|
"header": "HEADER",
|
|
"footer": Slot(lambda ctx: "FOOTER"),
|
|
},
|
|
)
|
|
|
|
assert template_called
|
|
assert js_called
|
|
assert css_called
|
|
|
|
assert template_data_instance is not None
|
|
assert js_data_instance is not None
|
|
assert css_data_instance is not None
|
|
|
|
def test_builtin_classes(self):
|
|
class ButtonFooterSlotData(TypedDict):
|
|
value: int
|
|
|
|
class Button(Component):
|
|
class Args(NamedTuple):
|
|
arg1: str
|
|
arg2: str
|
|
|
|
@dataclass
|
|
class Kwargs:
|
|
name: str
|
|
age: int
|
|
maybe_var: Optional[int] = None
|
|
|
|
class Slots(TypedDict):
|
|
# Use `SlotInput` when you want to pass slot as string
|
|
header: SlotInput
|
|
# Use `Slot` for slot functions.
|
|
# The generic specifies the data available to the slot function
|
|
footer: NotRequired[Slot[ButtonFooterSlotData]]
|
|
|
|
# Data returned from `get_template_data`
|
|
class TemplateData(NamedTuple):
|
|
data1: str
|
|
data2: int
|
|
data3: str
|
|
|
|
# Data returned from `get_js_data`
|
|
@dataclass
|
|
class JsData:
|
|
js_data1: str
|
|
js_data2: int
|
|
js_data3: str
|
|
|
|
# Data returned from `get_css_data`
|
|
@dataclass
|
|
class CssData:
|
|
css_data1: str
|
|
css_data2: int
|
|
css_data3: str
|
|
|
|
def get_template_data(self, args: Args, kwargs: Kwargs, slots: Slots, context: Context):
|
|
return self.TemplateData(
|
|
data1=kwargs.name,
|
|
data2=kwargs.age,
|
|
data3=args.arg1,
|
|
)
|
|
|
|
def get_js_data(self, args: Args, kwargs: Kwargs, slots: Slots, context: Context):
|
|
return self.JsData(
|
|
js_data1=kwargs.name,
|
|
js_data2=kwargs.age,
|
|
js_data3=args.arg1,
|
|
)
|
|
|
|
def get_css_data(self, args: Args, kwargs: Kwargs, slots: Slots, context: Context):
|
|
return self.CssData(
|
|
css_data1=kwargs.name,
|
|
css_data2=kwargs.age,
|
|
css_data3=args.arg1,
|
|
)
|
|
|
|
template = "<button>Click me!</button>"
|
|
|
|
# Success case
|
|
Button.render(
|
|
args=Button.Args(arg1="arg1", arg2="arg2"),
|
|
kwargs=Button.Kwargs(name="name", age=123),
|
|
slots=Button.Slots(
|
|
header="HEADER",
|
|
footer=Slot(lambda ctx: "FOOTER"),
|
|
),
|
|
)
|
|
|
|
# Failure case 1: NamedTuple raises error when a required argument is missing
|
|
with pytest.raises(
|
|
TypeError,
|
|
match=re.escape("missing 1 required positional argument: 'arg2'"),
|
|
):
|
|
Button.render(
|
|
# Missing arg2
|
|
args=Button.Args(arg1="arg1"), # type: ignore[call-arg]
|
|
kwargs=Button.Kwargs(name="name", age=123),
|
|
slots=Button.Slots(
|
|
header="HEADER",
|
|
footer=Slot(lambda ctx: "FOOTER"),
|
|
),
|
|
)
|
|
|
|
# Failure case 2: Dataclass raises error when a required argument is missing
|
|
with pytest.raises(
|
|
TypeError,
|
|
match=re.escape("missing 1 required positional argument: 'name'"),
|
|
):
|
|
Button.render(
|
|
args=Button.Args(arg1="arg1", arg2="arg2"),
|
|
# Name missing
|
|
kwargs=Button.Kwargs(age=123), # type: ignore[call-arg]
|
|
slots=Button.Slots(
|
|
header="HEADER",
|
|
footer=Slot(lambda ctx: "FOOTER"),
|
|
),
|
|
)
|
|
|
|
# Failure case 3
|
|
# NOTE: While we would expect this to raise, seems that TypedDict (`Slots`)
|
|
# does NOT raise an error when a required key is missing.
|
|
Button.render(
|
|
args=Button.Args(arg1="arg1", arg2="arg2"),
|
|
kwargs=Button.Kwargs(name="name", age=123),
|
|
slots=Button.Slots( # type: ignore[typeddict-item]
|
|
footer=Slot(lambda ctx: "FOOTER"), # Missing header
|
|
),
|
|
)
|
|
|
|
# Failure case 4: Data object is not of the expected type
|
|
class ButtonBad(Button):
|
|
class TemplateData(NamedTuple):
|
|
data1: str
|
|
data2: int # Removed data3
|
|
|
|
with pytest.raises(
|
|
TypeError,
|
|
match=re.escape("got an unexpected keyword argument 'data3'"),
|
|
):
|
|
ButtonBad.render(
|
|
args=ButtonBad.Args(arg1="arg1", arg2="arg2"),
|
|
kwargs=ButtonBad.Kwargs(name="name", age=123),
|
|
)
|
|
|
|
def test_empty_type(self):
|
|
template_called = False
|
|
|
|
class Button(Component):
|
|
template = "Hello"
|
|
|
|
Args = Empty
|
|
Kwargs = Empty
|
|
|
|
def get_template_data(self, args, kwargs, slots, context):
|
|
nonlocal template_called
|
|
template_called = True
|
|
|
|
assert isinstance(args, Empty)
|
|
assert isinstance(kwargs, Empty)
|
|
|
|
# Success case
|
|
Button.render()
|
|
assert template_called
|
|
|
|
# Failure cases
|
|
with pytest.raises(TypeError, match=re.escape("got an unexpected keyword argument 'arg1'")):
|
|
Button.render(
|
|
args=Button.Args(arg1="arg1", arg2="arg2"), # type: ignore[call-arg]
|
|
)
|
|
|
|
with pytest.raises(TypeError, match=re.escape("got an unexpected keyword argument 'arg1'")):
|
|
Button.render(
|
|
kwargs=Button.Kwargs(arg1="arg1", arg2="arg2"), # type: ignore[call-arg]
|
|
)
|
|
|
|
def test_custom_args_class_raises_on_invalid(self):
|
|
class Button(Component):
|
|
template = "Hello"
|
|
|
|
class Args:
|
|
arg1: str
|
|
arg2: str
|
|
|
|
with pytest.raises(TypeError, match=re.escape("Args() takes no arguments")):
|
|
Button.render(
|
|
args=Button.Args(arg1="arg1", arg2="arg2"), # type: ignore[call-arg]
|
|
)
|
|
|
|
class Button2(Component):
|
|
template = "Hello"
|
|
|
|
class Args:
|
|
arg1: str
|
|
arg2: str
|
|
|
|
def __init__(self, arg1: str, arg2: str):
|
|
self.arg1 = arg1
|
|
self.arg2 = arg2
|
|
|
|
with pytest.raises(TypeError, match=re.escape("'Args' object is not iterable")):
|
|
Button2.render(
|
|
args=Button2.Args(arg1="arg1", arg2="arg2"),
|
|
)
|
|
|
|
class Button3(Component):
|
|
template = "Hello"
|
|
|
|
class Args:
|
|
arg1: str
|
|
arg2: str
|
|
|
|
def __iter__(self):
|
|
return iter([self.arg1, self.arg2])
|
|
|
|
with pytest.raises(TypeError, match=re.escape("Args() takes no arguments")):
|
|
Button3.render(
|
|
args=Button3.Args(arg1="arg1", arg2="arg2"), # type: ignore[call-arg]
|
|
)
|
|
|
|
def test_custom_args_class_custom(self):
|
|
class Button(Component):
|
|
template = "Hello"
|
|
|
|
class Args:
|
|
arg1: str
|
|
arg2: str
|
|
|
|
def __init__(self, arg1: str, arg2: str):
|
|
self.arg1 = arg1
|
|
self.arg2 = arg2
|
|
|
|
def __iter__(self):
|
|
return iter([self.arg1, self.arg2])
|
|
|
|
Button.render(
|
|
args=Button.Args(arg1="arg1", arg2="arg2"),
|
|
)
|
|
|
|
def test_custom_kwargs_class_raises_on_invalid(self):
|
|
class Button(Component):
|
|
template = "Hello"
|
|
|
|
class Kwargs:
|
|
arg1: str
|
|
arg2: str
|
|
|
|
with pytest.raises(TypeError, match=re.escape("Kwargs() takes no arguments")):
|
|
Button.render(
|
|
kwargs=Button.Kwargs(arg1="arg1", arg2="arg2"), # type: ignore[call-arg]
|
|
)
|
|
|
|
class Button2(Component):
|
|
template = "Hello"
|
|
|
|
class Kwargs:
|
|
arg1: str
|
|
arg2: str
|
|
|
|
def __init__(self, arg1: str, arg2: str):
|
|
self.arg1 = arg1
|
|
self.arg2 = arg2
|
|
|
|
with pytest.raises(TypeError, match=re.escape("'Kwargs' object is not iterable")):
|
|
Button2.render(
|
|
kwargs=Button2.Kwargs(arg1="arg1", arg2="arg2"),
|
|
)
|
|
|
|
class Button3(Component):
|
|
template = "Hello"
|
|
|
|
class Kwargs:
|
|
arg1: str
|
|
arg2: str
|
|
|
|
def _asdict(self):
|
|
return {"arg1": self.arg1, "arg2": self.arg2}
|
|
|
|
with pytest.raises(TypeError, match=re.escape("Kwargs() takes no arguments")):
|
|
Button3.render(
|
|
kwargs=Button3.Kwargs(arg1="arg1", arg2="arg2"), # type: ignore[call-arg]
|
|
)
|
|
|
|
def test_custom_kwargs_class_custom(self):
|
|
class Button(Component):
|
|
template = "Hello"
|
|
|
|
class Kwargs:
|
|
arg1: str
|
|
arg2: str
|
|
|
|
def __init__(self, arg1: str, arg2: str):
|
|
self.arg1 = arg1
|
|
self.arg2 = arg2
|
|
|
|
def _asdict(self):
|
|
return {"arg1": self.arg1, "arg2": self.arg2}
|
|
|
|
Button.render(
|
|
kwargs=Button.Kwargs(arg1="arg1", arg2="arg2"),
|
|
)
|
|
|
|
def test_subclass_overrides_parent_type(self):
|
|
class Button(Component):
|
|
template = "Hello"
|
|
|
|
class Args(NamedTuple):
|
|
size: int
|
|
|
|
class Kwargs(NamedTuple):
|
|
color: str
|
|
|
|
class ButtonExtra(Button):
|
|
class Args(NamedTuple):
|
|
name: str
|
|
size: int
|
|
|
|
def get_template_data(self, args: Args, kwargs: "ButtonExtra.Kwargs", slots, context):
|
|
assert isinstance(args, ButtonExtra.Args)
|
|
assert isinstance(kwargs, ButtonExtra.Kwargs)
|
|
assert ButtonExtra.Kwargs is Button.Kwargs
|
|
|
|
ButtonExtra.render(
|
|
args=ButtonExtra.Args(name="John", size=30),
|
|
kwargs=ButtonExtra.Kwargs(color="red"),
|
|
)
|