django-components/tests/test_component.py
Juro Oravec 7dfcb447c4
feat: add decorator for writing component tests (#1008)
* feat: add decorator for writing component tests

* refactor: udpate changelog + update deps pins

* refactor: fix deps

* refactor: make cached_ref into generic and fix linter errors

* refactor: fix coverage testing

* refactor: use global var instead of env var for is_testing state
2025-03-02 19:46:12 +01:00

1499 lines
50 KiB
Python

"""
Tests focusing on the Component class.
For tests focusing on the `component` tag, see `test_templatetags_component.py`
"""
import re
import sys
from typing import Any, Dict, List, Tuple, Union, no_type_check
# See https://peps.python.org/pep-0655/#usage-in-python-3-11
if sys.version_info >= (3, 11):
from typing import NotRequired, TypedDict
else:
from typing_extensions import NotRequired, TypedDict # for Python <3.11 with (Not)Required
from unittest import skipIf
import pytest
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.http import HttpRequest, HttpResponse
from django.template import Context, RequestContext, Template, TemplateSyntaxError
from django.template.base import TextNode
from django.test import Client
from django.urls import path
from django.utils.safestring import SafeString
from pytest_django.asserts import assertHTMLEqual, assertInHTML
from django_components import Component, ComponentView, Slot, SlotFunc, register, types
from django_components.slots import SlotRef
from django_components.urls import urlpatterns as dc_urlpatterns
from django_components.testing import djc_test
from .testutils import PARAMETRIZE_CONTEXT_BEHAVIOR, setup_test_config
setup_test_config({"autodiscover": False})
# Client for testing endpoints via requests
class CustomClient(Client):
def __init__(self, urlpatterns=None, *args, **kwargs):
import types
if urlpatterns:
urls_module = types.ModuleType("urls")
urls_module.urlpatterns = urlpatterns + dc_urlpatterns # type: ignore
settings.ROOT_URLCONF = urls_module
else:
settings.ROOT_URLCONF = __name__
settings.SECRET_KEY = "secret" # noqa
super().__init__(*args, **kwargs)
# Component typings
CompArgs = Tuple[int, str]
class CompData(TypedDict):
variable: str
class CompSlots(TypedDict):
my_slot: Union[str, int, Slot]
my_slot2: SlotFunc
if sys.version_info >= (3, 11):
class CompKwargs(TypedDict):
variable: str
another: int
optional: NotRequired[int]
else:
class CompKwargs(TypedDict, total=False):
variable: str
another: int
optional: NotRequired[int]
# TODO_REMOVE_IN_V1 - Superseded by `self.get_template` in v1
@djc_test
class TestComponentOldTemplateApi:
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_get_template_string(self, components_settings):
class SimpleComponent(Component):
def get_template_string(self, context):
content: types.django_html = """
Variable: <strong>{{ variable }}</strong>
"""
return content
def get_context_data(self, variable=None):
return {
"variable": variable,
}
class Media:
css = "style.css"
js = "script.js"
rendered = SimpleComponent.render(kwargs={"variable": "test"})
assertHTMLEqual(
rendered,
"""
Variable: <strong data-djc-id-a1bc3e>test</strong>
""",
)
@djc_test
class TestComponent:
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_empty_component(self, components_settings):
class EmptyComponent(Component):
pass
with pytest.raises(ImproperlyConfigured):
EmptyComponent("empty_component")._get_template(Context({}), "123")
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_template_string_static_inlined(self, components_settings):
class SimpleComponent(Component):
template: types.django_html = """
Variable: <strong>{{ variable }}</strong>
"""
def get_context_data(self, variable=None):
return {
"variable": variable,
}
class Media:
css = "style.css"
js = "script.js"
rendered = SimpleComponent.render(kwargs={"variable": "test"})
assertHTMLEqual(
rendered,
"""
Variable: <strong data-djc-id-a1bc3e>test</strong>
""",
)
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_template_string_dynamic(self, components_settings):
class SimpleComponent(Component):
def get_template(self, context):
content: types.django_html = """
Variable: <strong>{{ variable }}</strong>
"""
return content
def get_context_data(self, variable=None):
return {
"variable": variable,
}
class Media:
css = "style.css"
js = "script.js"
rendered = SimpleComponent.render(kwargs={"variable": "test"})
assertHTMLEqual(
rendered,
"""
Variable: <strong data-djc-id-a1bc3e>test</strong>
""",
)
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_template_file_static(self, components_settings):
class SimpleComponent(Component):
template_file = "simple_template.html"
def get_context_data(self, variable=None):
return {
"variable": variable,
}
class Media:
css = "style.css"
js = "script.js"
rendered = SimpleComponent.render(kwargs={"variable": "test"})
assertHTMLEqual(
rendered,
"""
Variable: <strong data-djc-id-a1bc3e>test</strong>
""",
)
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_template_file_static__compat(self, components_settings):
class SimpleComponent(Component):
template_name = "simple_template.html"
def get_context_data(self, variable=None):
return {
"variable": variable,
}
class Media:
css = "style.css"
js = "script.js"
assert SimpleComponent.template_name == "simple_template.html"
assert SimpleComponent.template_file == "simple_template.html"
SimpleComponent.template_name = "other_template.html"
assert SimpleComponent.template_name == "other_template.html"
assert SimpleComponent.template_file == "other_template.html"
SimpleComponent.template_name = "simple_template.html"
rendered = SimpleComponent.render(kwargs={"variable": "test"})
assertHTMLEqual(
rendered,
"""
Variable: <strong data-djc-id-a1bc3e>test</strong>
""",
)
comp = SimpleComponent()
assert comp.template_name == "simple_template.html"
assert comp.template_file == "simple_template.html"
# NOTE: Setting `template_file` on INSTANCE is not supported, as users should work
# with classes and not instances. This is tested for completeness.
comp.template_name = "other_template_2.html"
assert comp.template_name == "other_template_2.html"
assert comp.template_file == "other_template_2.html"
assert SimpleComponent.template_name == "other_template_2.html"
assert SimpleComponent.template_file == "other_template_2.html"
SimpleComponent.template_name = "simple_template.html"
rendered = comp.render(kwargs={"variable": "test"})
assertHTMLEqual(
rendered,
"""
Variable: <strong data-djc-id-a1bc3f>test</strong>
""",
)
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_template_file_dynamic(self, components_settings):
class SvgComponent(Component):
def get_context_data(self, name, css_class="", title="", **attrs):
return {
"name": name,
"css_class": css_class,
"title": title,
**attrs,
}
def get_template_name(self, context):
return f"dynamic_{context['name']}.svg"
assertHTMLEqual(
SvgComponent.render(kwargs={"name": "svg1"}),
"""
<svg data-djc-id-a1bc3e>Dynamic1</svg>
""",
)
assertHTMLEqual(
SvgComponent.render(kwargs={"name": "svg2"}),
"""
<svg data-djc-id-a1bc3f>Dynamic2</svg>
""",
)
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_allows_to_return_template(self, components_settings):
class TestComponent(Component):
def get_context_data(self, variable, **attrs):
return {
"variable": variable,
}
def get_template(self, context):
template_str = "Variable: <strong>{{ variable }}</strong>"
return Template(template_str)
rendered = TestComponent.render(kwargs={"variable": "test"})
assertHTMLEqual(
rendered,
"""
Variable: <strong data-djc-id-a1bc3e>test</strong>
""",
)
def test_input(self):
class TestComponent(Component):
@no_type_check
def get_context_data(self, var1, var2, variable, another, **attrs):
assert self.input.args == (123, "str")
assert self.input.kwargs == {"variable": "test", "another": 1}
assert isinstance(self.input.context, Context)
assert list(self.input.slots.keys()) == ["my_slot"]
assert self.input.slots["my_slot"](Context(), None, None) == "MY_SLOT"
return {
"variable": variable,
}
@no_type_check
def get_template(self, context):
assert self.input.args == (123, "str")
assert self.input.kwargs == {"variable": "test", "another": 1}
assert isinstance(self.input.context, Context)
assert list(self.input.slots.keys()) == ["my_slot"]
assert self.input.slots["my_slot"](Context(), None, None) == "MY_SLOT"
template_str: types.django_html = """
{% load component_tags %}
Variable: <strong>{{ variable }}</strong>
{% slot 'my_slot' / %}
"""
return Template(template_str)
rendered = TestComponent.render(
kwargs={"variable": "test", "another": 1},
args=(123, "str"),
slots={"my_slot": "MY_SLOT"},
)
assertHTMLEqual(
rendered,
"""
Variable: <strong data-djc-id-a1bc3e>test</strong> MY_SLOT
""",
)
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_prepends_exceptions_with_component_path(self, components_settings):
@register("broken")
class Broken(Component):
template: types.django_html = """
{% load component_tags %}
<div> injected: {{ data|safe }} </div>
<main>
{% slot "content" default / %}
</main>
"""
def get_context_data(self):
data = self.inject("my_provide")
data["data1"] # This should raise TypeError
return {"data": data}
@register("provider")
class Provider(Component):
def get_context_data(self, data: Any) -> Any:
return {"data": data}
template: types.django_html = """
{% load component_tags %}
{% provide "my_provide" key="hi" data=data %}
{% slot "content" default / %}
{% endprovide %}
"""
@register("parent")
class Parent(Component):
def get_context_data(self, data: Any) -> Any:
return {"data": data}
template: types.django_html = """
{% load component_tags %}
{% component "provider" data=data %}
{% component "broken" %}
{% slot "content" default / %}
{% endcomponent %}
{% endcomponent %}
"""
@register("root")
class Root(Component):
template: types.django_html = """
{% load component_tags %}
{% component "parent" data=123 %}
{% fill "content" %}
456
{% endfill %}
{% endcomponent %}
"""
with pytest.raises(
TypeError,
match=re.escape(
"An error occured while rendering components Root > parent > provider > provider(slot:content) > broken:\n" # noqa: E501
"tuple indices must be integers or slices, not str"
),
):
Root.render()
@djc_test
class TestComponentValidation:
def test_validate_input_passes(self):
class TestComponent(Component[CompArgs, CompKwargs, CompSlots, CompData, Any, Any]):
def get_context_data(self, var1, var2, variable, another, **attrs):
return {
"variable": variable,
}
template: types.django_html = """
{% load component_tags %}
Variable: <strong>{{ variable }}</strong>
Slot 1: {% slot "my_slot" / %}
Slot 2: {% slot "my_slot2" / %}
"""
rendered = TestComponent.render(
kwargs={"variable": "test", "another": 1},
args=(123, "str"),
slots={
"my_slot": SafeString("MY_SLOT"),
"my_slot2": lambda ctx, data, ref: "abc",
},
)
assertHTMLEqual(
rendered,
"""
Variable: <strong data-djc-id-a1bc3e>test</strong>
Slot 1: MY_SLOT
Slot 2: abc
""",
)
@skipIf(sys.version_info < (3, 11), "Requires >= 3.11")
def test_validate_input_fails(self):
class TestComponent(Component[CompArgs, CompKwargs, CompSlots, CompData, Any, Any]):
def get_context_data(self, var1, var2, variable, another, **attrs):
return {
"variable": variable,
}
template: types.django_html = """
{% load component_tags %}
Variable: <strong>{{ variable }}</strong>
Slot 1: {% slot "my_slot" / %}
Slot 2: {% slot "my_slot2" / %}
"""
with pytest.raises(
TypeError,
match=re.escape("Component 'TestComponent' expected 2 positional arguments, got 1"),
):
TestComponent.render(
kwargs={"variable": 1, "another": "test"}, # type: ignore
args=(123,), # type: ignore
slots={
"my_slot": "MY_SLOT",
"my_slot2": lambda ctx, data, ref: "abc",
},
)
with pytest.raises(
TypeError,
match=re.escape("Component 'TestComponent' expected 2 positional arguments, got 0"),
):
TestComponent.render(
kwargs={"variable": 1, "another": "test"}, # type: ignore
slots={
"my_slot": "MY_SLOT",
"my_slot2": lambda ctx, data, ref: "abc",
},
)
with pytest.raises(
TypeError,
match=re.escape(
"Component 'TestComponent' expected keyword argument 'variable' to be <class 'str'>, got 1 of type <class 'int'>" # noqa: E501
),
):
TestComponent.render(
kwargs={"variable": 1, "another": "test"}, # type: ignore
args=(123, "abc", 456), # type: ignore
slots={
"my_slot": "MY_SLOT",
"my_slot2": lambda ctx, data, ref: "abc",
},
)
with pytest.raises(
TypeError,
match=re.escape("Component 'TestComponent' expected 2 positional arguments, got 0"),
):
TestComponent.render()
with pytest.raises(
TypeError,
match=re.escape(
"Component 'TestComponent' expected keyword argument 'variable' to be <class 'str'>, got 1 of type <class 'int'>" # noqa: E501
),
):
TestComponent.render(
kwargs={"variable": 1, "another": "test"}, # type: ignore
args=(123, "str"),
slots={
"my_slot": "MY_SLOT",
"my_slot2": lambda ctx, data, ref: "abc",
},
)
with pytest.raises(
TypeError,
match=re.escape("Component 'TestComponent' is missing a required keyword argument 'another'"),
):
TestComponent.render(
kwargs={"variable": "abc"}, # type: ignore
args=(123, "str"),
slots={
"my_slot": "MY_SLOT",
"my_slot2": lambda ctx, data, ref: "abc",
},
)
with pytest.raises(
TypeError,
match=re.escape(
"Component 'TestComponent' expected slot 'my_slot' to be typing.Union[str, int, django_components.slots.Slot], got 123.5 of type <class 'float'>" # noqa: E501
),
):
TestComponent.render(
kwargs={"variable": "abc", "another": 1},
args=(123, "str"),
slots={
"my_slot": 123.5, # type: ignore
"my_slot2": lambda ctx, data, ref: "abc",
},
)
with pytest.raises(
TypeError,
match=re.escape("Component 'TestComponent' is missing a required slot 'my_slot2'"),
):
TestComponent.render(
kwargs={"variable": "abc", "another": 1},
args=(123, "str"),
slots={
"my_slot": "MY_SLOT",
}, # type: ignore
)
def test_validate_input_skipped(self):
class TestComponent(Component[Any, CompKwargs, Any, CompData, Any, Any]):
def get_context_data(self, var1, var2, variable, another, **attrs):
return {
"variable": variable,
}
template: types.django_html = """
{% load component_tags %}
Variable: <strong>{{ variable }}</strong>
Slot 1: {% slot "my_slot" / %}
Slot 2: {% slot "my_slot2" / %}
"""
rendered = TestComponent.render(
kwargs={"variable": "test", "another": 1},
args=("123", "str"), # NOTE: Normally should raise
slots={
"my_slot": 123.5, # NOTE: Normally should raise
"my_slot2": lambda ctx, data, ref: "abc",
},
)
assertHTMLEqual(
rendered,
"""
Variable: <strong data-djc-id-a1bc3e>test</strong>
Slot 1: 123.5
Slot 2: abc
""",
)
def test_validate_output_passes(self):
class TestComponent(Component[CompArgs, CompKwargs, CompSlots, CompData, Any, Any]):
def get_context_data(self, var1, var2, variable, another, **attrs):
return {
"variable": variable,
}
template: types.django_html = """
{% load component_tags %}
Variable: <strong>{{ variable }}</strong>
Slot 1: {% slot "my_slot" / %}
Slot 2: {% slot "my_slot2" / %}
"""
rendered = TestComponent.render(
kwargs={"variable": "test", "another": 1},
args=(123, "str"),
slots={
"my_slot": SafeString("MY_SLOT"),
"my_slot2": lambda ctx, data, ref: "abc",
},
)
assertHTMLEqual(
rendered,
"""
Variable: <strong data-djc-id-a1bc3e>test</strong>
Slot 1: MY_SLOT
Slot 2: abc
""",
)
def test_validate_output_fails(self):
class TestComponent(Component[CompArgs, CompKwargs, CompSlots, CompData, Any, Any]):
def get_context_data(self, var1, var2, variable, another, **attrs):
return {
"variable": variable,
"invalid_key": var1,
}
template: types.django_html = """
{% load component_tags %}
Variable: <strong>{{ variable }}</strong>
Slot 1: {% slot "my_slot" / %}
Slot 2: {% slot "my_slot2" / %}
"""
with pytest.raises(
TypeError,
match=re.escape("Component 'TestComponent' got unexpected data keys 'invalid_key'"),
):
TestComponent.render(
kwargs={"variable": "test", "another": 1},
args=(123, "str"),
slots={
"my_slot": SafeString("MY_SLOT"),
"my_slot2": lambda ctx, data, ref: "abc",
},
)
def test_handles_components_in_typing(self):
class InnerKwargs(TypedDict):
one: str
class InnerData(TypedDict):
one: Union[str, int]
self: "InnerComp" # type: ignore[misc]
InnerComp = Component[Any, InnerKwargs, Any, InnerData, Any, Any] # type: ignore[misc]
class Inner(InnerComp):
def get_context_data(self, one):
return {
"self": self,
"one": one,
}
template = ""
TodoArgs = Tuple[Inner] # type: ignore[misc]
class TodoKwargs(TypedDict):
inner: Inner
class TodoData(TypedDict):
one: Union[str, int]
self: "TodoComp" # type: ignore[misc]
inner: str
TodoComp = Component[TodoArgs, TodoKwargs, Any, TodoData, Any, Any] # type: ignore[misc]
# NOTE: Since we're using ForwardRef for "TodoComp" and "InnerComp", we need
# to ensure that the actual types are set as globals, so the ForwardRef class
# can resolve them.
globals()["TodoComp"] = TodoComp
globals()["InnerComp"] = InnerComp
class TestComponent(TodoComp):
def get_context_data(self, var1, inner):
return {
"self": self,
"one": "2123",
# NOTE: All of this is typed
"inner": self.input.kwargs["inner"].render(kwargs={"one": "abc"}),
}
template: types.django_html = """
{% load component_tags %}
Name: <strong>{{ self.name }}</strong>
"""
rendered = TestComponent.render(args=(Inner(),), kwargs={"inner": Inner()})
assertHTMLEqual(
rendered,
"""
Name: <strong data-djc-id-a1bc3e>TestComponent</strong>
""",
)
def test_handles_typing_module(self):
TodoArgs = Tuple[
Union[str, int],
Dict[str, int],
List[str],
Tuple[int, Union[str, int]],
]
class TodoKwargs(TypedDict):
one: Union[str, int]
two: Dict[str, int]
three: List[str]
four: Tuple[int, Union[str, int]]
class TodoData(TypedDict):
one: Union[str, int]
two: Dict[str, int]
three: List[str]
four: Tuple[int, Union[str, int]]
TodoComp = Component[TodoArgs, TodoKwargs, Any, TodoData, Any, Any]
# NOTE: Since we're using ForwardRef for "TodoComp", we need
# to ensure that the actual types are set as globals, so the ForwardRef class
# can resolve them.
globals()["TodoComp"] = TodoComp
class TestComponent(TodoComp):
def get_context_data(self, *args, **kwargs):
return {
**kwargs,
}
template = ""
TestComponent.render(
args=("str", {"str": 123}, ["a", "b", "c"], (123, "123")),
kwargs={
"one": "str",
"two": {"str": 123},
"three": ["a", "b", "c"],
"four": (123, "123"),
},
)
@djc_test
class TestComponentRender:
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_render_minimal(self, components_settings):
class SimpleComponent(Component):
template: types.django_html = """
{% load component_tags %}
the_arg2: {{ the_arg2 }}
args: {{ args|safe }}
the_kwarg: {{ the_kwarg }}
kwargs: {{ kwargs|safe }}
---
from_context: {{ from_context }}
---
slot_second: {% slot "second" default %}
SLOT_SECOND_DEFAULT
{% endslot %}
"""
def get_context_data(self, the_arg2=None, *args, the_kwarg=None, **kwargs):
return {
"the_arg2": the_arg2,
"the_kwarg": the_kwarg,
"args": args,
"kwargs": kwargs,
}
rendered = SimpleComponent.render()
assertHTMLEqual(
rendered,
"""
the_arg2: None
args: ()
the_kwarg: None
kwargs: {}
---
from_context:
---
slot_second: SLOT_SECOND_DEFAULT
""",
)
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_render_full(self, components_settings):
class SimpleComponent(Component):
template: types.django_html = """
{% load component_tags %}
the_arg: {{ the_arg }}
the_arg2: {{ the_arg2 }}
args: {{ args|safe }}
the_kwarg: {{ the_kwarg }}
kwargs: {{ kwargs|safe }}
---
from_context: {{ from_context }}
---
slot_first: {% slot "first" required %}
{% endslot %}
---
slot_second: {% slot "second" default %}
SLOT_SECOND_DEFAULT
{% endslot %}
"""
def get_context_data(self, the_arg, the_arg2=None, *args, the_kwarg, **kwargs):
return {
"the_arg": the_arg,
"the_arg2": the_arg2,
"the_kwarg": the_kwarg,
"args": args,
"kwargs": kwargs,
}
rendered = SimpleComponent.render(
context={"from_context": 98},
args=["one", "two", "three"],
kwargs={"the_kwarg": "test", "kw2": "ooo"},
slots={"first": "FIRST_SLOT"},
)
assertHTMLEqual(
rendered,
"""
the_arg: one
the_arg2: two
args: ('three',)
the_kwarg: test
kwargs: {'kw2': 'ooo'}
---
from_context: 98
---
slot_first: FIRST_SLOT
---
slot_second: SLOT_SECOND_DEFAULT
""",
)
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_render_to_response_full(self, components_settings):
class SimpleComponent(Component):
template: types.django_html = """
{% load component_tags %}
the_arg: {{ the_arg }}
the_arg2: {{ the_arg2 }}
args: {{ args|safe }}
the_kwarg: {{ the_kwarg }}
kwargs: {{ kwargs|safe }}
---
from_context: {{ from_context }}
---
slot_first: {% slot "first" required %}
{% endslot %}
---
slot_second: {% slot "second" default %}
SLOT_SECOND_DEFAULT
{% endslot %}
"""
def get_context_data(self, the_arg, the_arg2=None, *args, the_kwarg, **kwargs):
return {
"the_arg": the_arg,
"the_arg2": the_arg2,
"the_kwarg": the_kwarg,
"args": args,
"kwargs": kwargs,
}
rendered = SimpleComponent.render_to_response(
context={"from_context": 98},
args=["one", "two", "three"],
kwargs={"the_kwarg": "test", "kw2": "ooo"},
slots={"first": "FIRST_SLOT"},
)
assert isinstance(rendered, HttpResponse)
assertHTMLEqual(
rendered.content.decode(),
"""
the_arg: one
the_arg2: two
args: ('three',)
the_kwarg: test
kwargs: {'kw2': 'ooo'}
---
from_context: 98
---
slot_first: FIRST_SLOT
---
slot_second: SLOT_SECOND_DEFAULT
""",
)
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_render_to_response_change_response_class(self, components_settings):
class MyResponse:
def __init__(self, content: str) -> None:
self.content = bytes(content, "utf-8")
class SimpleComponent(Component):
response_class = MyResponse
template: types.django_html = "HELLO"
rendered = SimpleComponent.render_to_response()
assert isinstance(rendered, MyResponse)
assertHTMLEqual(
rendered.content.decode(),
"HELLO",
)
@djc_test(
parametrize=(
["components_settings", "is_isolated"],
[
[{"context_behavior": "django"}, False],
[{"context_behavior": "isolated"}, True],
],
["django", "isolated"],
)
)
def test_render_slot_as_func(self, components_settings, is_isolated):
class SimpleComponent(Component):
template: types.django_html = """
{% load component_tags %}
{% slot "first" required data1="abc" data2:hello="world" data2:one=123 %}
SLOT_DEFAULT
{% endslot %}
"""
def get_context_data(self, the_arg, the_kwarg=None, **kwargs):
return {
"the_arg": the_arg,
"the_kwarg": the_kwarg,
"kwargs": kwargs,
}
def first_slot(ctx: Context, slot_data: Dict, slot_ref: SlotRef):
assert isinstance(ctx, Context)
# NOTE: Since the slot has access to the Context object, it should behave
# the same way as it does in templates - when in "isolated" mode, then the
# slot fill has access only to the "root" context, but not to the data of
# get_context_data() of SimpleComponent.
if is_isolated:
assert ctx.get("the_arg") is None
assert ctx.get("the_kwarg") is None
assert ctx.get("kwargs") is None
assert ctx.get("abc") is None
else:
assert ctx["the_arg"] == "1"
assert ctx["the_kwarg"] == 3
assert ctx["kwargs"] == {}
assert ctx["abc"] == "def"
slot_data_expected = {
"data1": "abc",
"data2": {"hello": "world", "one": 123},
}
assert slot_data_expected == slot_data
assert isinstance(slot_ref, SlotRef)
assert "SLOT_DEFAULT" == str(slot_ref).strip()
return f"FROM_INSIDE_FIRST_SLOT | {slot_ref}"
rendered = SimpleComponent.render(
context={"abc": "def"},
args=["1"],
kwargs={"the_kwarg": 3},
slots={"first": first_slot},
)
assertHTMLEqual(
rendered,
"FROM_INSIDE_FIRST_SLOT | SLOT_DEFAULT",
)
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_render_raises_on_missing_slot(self, components_settings):
class SimpleComponent(Component):
template: types.django_html = """
{% load component_tags %}
{% slot "first" required %}
{% endslot %}
"""
with pytest.raises(
TemplateSyntaxError,
match=re.escape(
"Slot 'first' is marked as 'required' (i.e. non-optional), yet no fill is provided."
),
):
SimpleComponent.render()
SimpleComponent.render(
slots={"first": "FIRST_SLOT"},
)
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_render_with_include(self, components_settings):
class SimpleComponent(Component):
template: types.django_html = """
{% load component_tags %}
{% include 'slotted_template.html' %}
"""
rendered = SimpleComponent.render()
assertHTMLEqual(
rendered,
"""
<custom-template data-djc-id-a1bc3e>
<header>Default header</header>
<main>Default main</main>
<footer>Default footer</footer>
</custom-template>
""",
)
# See https://github.com/django-components/django-components/issues/580
# And https://github.com/django-components/django-components/commit/fee26ec1d8b46b5ee065ca1ce6143889b0f96764
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_render_with_include_and_context(self, components_settings):
class SimpleComponent(Component):
template: types.django_html = """
{% load component_tags %}
{% include 'slotted_template.html' %}
"""
rendered = SimpleComponent.render(context=Context())
assertHTMLEqual(
rendered,
"""
<custom-template data-djc-id-a1bc3e>
<header>Default header</header>
<main>Default main</main>
<footer>Default footer</footer>
</custom-template>
""",
)
# See https://github.com/django-components/django-components/issues/580
# And https://github.com/django-components/django-components/issues/634
# And https://github.com/django-components/django-components/commit/fee26ec1d8b46b5ee065ca1ce6143889b0f96764
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_render_with_include_and_request_context(self, components_settings):
class SimpleComponent(Component):
template: types.django_html = """
{% load component_tags %}
{% include 'slotted_template.html' %}
"""
rendered = SimpleComponent.render(context=RequestContext(HttpRequest()))
assertHTMLEqual(
rendered,
"""
<custom-template data-djc-id-a1bc3e>
<header>Default header</header>
<main>Default main</main>
<footer>Default footer</footer>
</custom-template>
""",
)
# See https://github.com/django-components/django-components/issues/580
# And https://github.com/django-components/django-components/issues/634
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_request_context_is_populated_from_context_processors(self, components_settings):
@register("thing")
class Thing(Component):
template: types.django_html = """
<kbd>Rendered {{ how }}</kbd>
<div>
CSRF token: {{ csrf_token|default:"<em>No CSRF token</em>" }}
</div>
"""
def get_context_data(self, *args, how: str, **kwargs):
return {"how": how}
class View(ComponentView):
def get(self, request):
how = "via GET request"
return self.component.render_to_response(
context=RequestContext(self.request),
kwargs=self.component.get_context_data(how=how),
)
client = CustomClient(urlpatterns=[path("test_thing/", Thing.as_view())])
response = client.get("/test_thing/")
assert response.status_code == 200
# Full response:
# """
# <kbd>
# Rendered via GET request
# </kbd>
# <div>
# CSRF token:
# <div>
# test_csrf_token
# </div>
# </div>
# """
assertInHTML(
"""
<kbd data-djc-id-a1bc3e>
Rendered via GET request
</kbd>
""",
response.content.decode(),
)
token_re = re.compile(rb"CSRF token:\s+predictabletoken")
token = token_re.findall(response.content)[0]
assert token == b"CSRF token: predictabletoken"
def test_request_context_created_when_no_context(self):
@register("thing")
class Thing(Component):
template: types.django_html = """
CSRF token: {{ csrf_token|default:"<em>No CSRF token</em>" }}
"""
def get(self, request):
return self.render_to_response(request=request)
client = CustomClient(urlpatterns=[path("test_thing/", Thing.as_view())])
response = client.get("/test_thing/")
assert response.status_code == 200
token_re = re.compile(rb"CSRF token:\s+predictabletoken")
token = token_re.findall(response.content)[0]
assert token == b"CSRF token: predictabletoken"
def test_request_context_created_when_already_a_context_dict(self):
@register("thing")
class Thing(Component):
template: types.django_html = """
<p>CSRF token: {{ csrf_token|default:"<em>No CSRF token</em>" }}</p>
<p>Existing context: {{ existing_context|default:"<em>No existing context</em>" }}</p>
"""
def get(self, request):
return self.render_to_response(request=request, context={"existing_context": "foo"})
client = CustomClient(urlpatterns=[path("test_thing/", Thing.as_view())])
response = client.get("/test_thing/")
assert response.status_code == 200
token_re = re.compile(rb"CSRF token:\s+predictabletoken")
token = token_re.findall(response.content)[0]
assert token == b"CSRF token: predictabletoken"
assert "Existing context: foo" in response.content.decode()
def request_context_ignores_context_when_already_a_context(self):
@register("thing")
class Thing(Component):
template: types.django_html = """
<p>CSRF token: {{ csrf_token|default:"<em>No CSRF token</em>" }}</p>
<p>Existing context: {{ existing_context|default:"<em>No existing context</em>" }}</p>
"""
def get(self, request):
return self.render_to_response(request=request, context=Context({"existing_context": "foo"}))
client = CustomClient(urlpatterns=[path("test_thing/", Thing.as_view())])
response = client.get("/test_thing/")
assert response.status_code == 200
token_re = re.compile(rb"CSRF token:\s+(?P<token>[0-9a-zA-Z]{64})")
assert not token_re.findall(response.content)
assert "Existing context: foo" in response.content.decode()
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_render_with_extends(self, components_settings):
class SimpleComponent(Component):
template: types.django_html = """
{% extends 'block.html' %}
{% block body %}
OVERRIDEN
{% endblock %}
"""
rendered = SimpleComponent.render(render_dependencies=False)
assertHTMLEqual(
rendered,
"""
<!DOCTYPE html>
<html data-djc-id-a1bc3e lang="en">
<body>
<main role="main">
<div class='container main-container'>
OVERRIDEN
</div>
</main>
</body>
</html>
""",
)
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_render_can_access_instance(self, components_settings):
class TestComponent(Component):
template = "Variable: <strong>{{ id }}</strong>"
def get_context_data(self, **attrs):
return {
"id": self.id,
}
rendered = TestComponent.render()
assertHTMLEqual(
rendered,
"Variable: <strong data-djc-id-a1bc3e>a1bc3e</strong>",
)
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_render_to_response_can_access_instance(self, components_settings):
class TestComponent(Component):
template = "Variable: <strong>{{ id }}</strong>"
def get_context_data(self, **attrs):
return {
"id": self.id,
}
rendered_resp = TestComponent.render_to_response()
assertHTMLEqual(
rendered_resp.content.decode("utf-8"),
"Variable: <strong data-djc-id-a1bc3e>a1bc3e</strong>",
)
@djc_test
class TestComponentHook:
def test_on_render_before(self):
@register("nested")
class NestedComponent(Component):
template: types.django_html = """
{% load component_tags %}
Hello from nested
<div>
{% slot "content" default / %}
</div>
"""
class SimpleComponent(Component):
template: types.django_html = """
{% load component_tags %}
args: {{ args|safe }}
kwargs: {{ kwargs|safe }}
---
from_on_before: {{ from_on_before }}
---
{% component "nested" %}
Hello from simple
{% endcomponent %}
"""
def get_context_data(self, *args, **kwargs):
return {
"args": args,
"kwargs": kwargs,
}
def on_render_before(self, context: Context, template: Template) -> None:
# Insert value into the Context
context["from_on_before"] = ":)"
# Insert text into the Template
#
# NOTE: Users should NOT do this, because this will insert the text every time
# the component is rendered.
template.nodelist.append(TextNode("\n---\nFROM_ON_BEFORE"))
rendered = SimpleComponent.render()
assertHTMLEqual(
rendered,
"""
args: ()
kwargs: {}
---
from_on_before: :)
---
Hello from nested
<div data-djc-id-a1bc3e data-djc-id-a1bc40>
Hello from simple
</div>
---
FROM_ON_BEFORE
""",
)
# Check that modifying the context or template does nothing
def test_on_render_after(self):
captured_content = None
@register("nested")
class NestedComponent(Component):
template: types.django_html = """
{% load component_tags %}
Hello from nested
<div>
{% slot "content" default / %}
</div>
"""
class SimpleComponent(Component):
template: types.django_html = """
{% load component_tags %}
args: {{ args|safe }}
kwargs: {{ kwargs|safe }}
---
from_on_after: {{ from_on_after }}
---
{% component "nested" %}
Hello from simple
{% endcomponent %}
"""
def get_context_data(self, *args, **kwargs):
return {
"args": args,
"kwargs": kwargs,
}
# Check that modifying the context or template does nothing
def on_render_after(self, context: Context, template: Template, content: str) -> None:
# Insert value into the Context
context["from_on_after"] = ":)"
# Insert text into the Template
template.nodelist.append(TextNode("\n---\nFROM_ON_AFTER"))
nonlocal captured_content
captured_content = content
rendered = SimpleComponent.render()
assertHTMLEqual(
captured_content,
"""
args: ()
kwargs: {}
---
from_on_after:
---
Hello from nested
<div data-djc-id-a1bc3e data-djc-id-a1bc40>
Hello from simple
</div>
""",
)
assertHTMLEqual(
rendered,
"""
args: ()
kwargs: {}
---
from_on_after:
---
Hello from nested
<div data-djc-id-a1bc3e data-djc-id-a1bc40>
Hello from simple
</div>
""",
)
# Check that modifying the context or template does nothing
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_on_render_after_override_output(self, components_settings):
captured_content = None
@register("nested")
class NestedComponent(Component):
template: types.django_html = """
{% load component_tags %}
Hello from nested
<div>
{% slot "content" default / %}
</div>
"""
class SimpleComponent(Component):
template: types.django_html = """
{% load component_tags %}
args: {{ args|safe }}
kwargs: {{ kwargs|safe }}
---
from_on_before: {{ from_on_before }}
---
{% component "nested" %}
Hello from simple
{% endcomponent %}
"""
def get_context_data(self, *args, **kwargs):
return {
"args": args,
"kwargs": kwargs,
}
def on_render_after(self, context: Context, template: Template, content: str) -> str:
nonlocal captured_content
captured_content = content
return "Chocolate cookie recipe: " + content
rendered = SimpleComponent.render()
assertHTMLEqual(
captured_content,
"""
args: ()
kwargs: {}
---
from_on_before:
---
Hello from nested
<div data-djc-id-a1bc3e data-djc-id-a1bc40>
Hello from simple
</div>
""",
)
assertHTMLEqual(
rendered,
"""
Chocolate cookie recipe:
args: ()
kwargs: {}
---
from_on_before:
---
Hello from nested
<div data-djc-id-a1bc3e data-djc-id-a1bc40>
Hello from simple
</div>
""",
)
def test_on_render_before_after_same_context(self):
context_in_before = None
context_in_after = None
@register("nested")
class NestedComponent(Component):
template: types.django_html = """
{% load component_tags %}
Hello from nested
<div>
{% slot "content" default / %}
</div>
"""
class SimpleComponent(Component):
template: types.django_html = """
{% load component_tags %}
args: {{ args|safe }}
kwargs: {{ kwargs|safe }}
---
from_on_after: {{ from_on_after }}
---
{% component "nested" %}
Hello from simple
{% endcomponent %}
"""
def get_context_data(self, *args, **kwargs):
return {
"args": args,
"kwargs": kwargs,
}
def on_render_before(self, context: Context, template: Template) -> None:
context["from_on_before"] = ":)"
nonlocal context_in_before
context_in_before = context
# Check that modifying the context or template does nothing
def on_render_after(self, context: Context, template: Template, html: str) -> None:
context["from_on_after"] = ":)"
nonlocal context_in_after
context_in_after = context
SimpleComponent.render()
assert context_in_before == context_in_after
assert "from_on_before" in context_in_before # type: ignore[operator]
assert "from_on_after" in context_in_after # type: ignore[operator]