Initial commit

This commit is contained in:
2024-08-27 20:33:44 +02:00
commit 1f1832267d
14794 changed files with 1599592 additions and 0 deletions

View File

@@ -0,0 +1,284 @@
import os
import random
from pathlib import Path
from typing import TYPE_CHECKING
from django.conf import settings
from django.forms import widgets
from django.template import Context
from django.test import SimpleTestCase
from django.utils.safestring import SafeString
from laces.components import Component, MediaContainer
from laces.tests.utils import MediaAssertionMixin
if TYPE_CHECKING:
from typing import Any, Optional, Union
from laces.typing import RenderContext
class TestComponent(MediaAssertionMixin, SimpleTestCase):
"""Directly test the Component class."""
def setUp(self) -> None:
self.component = Component()
def test_render_html(self) -> None:
"""Test the `render_html` method."""
# The default Component does not specify a `template_name` attribute which is
# required for `render_html`. So calling the method on the Component class
# will raise an error.
with self.assertRaises(AttributeError):
self.component.render_html()
def test_get_context_data_parent_context_empty_context(self) -> None:
"""
Test the default get_context_data.
The parent context should not matter, but we use it as it is used in
`render_html` (which passes a `Context` object).
"""
result = self.component.get_context_data(parent_context=Context())
self.assertIsInstance(result, dict)
self.assertEqual(result, {})
def test_media(self) -> None:
"""
Test the `media` property.
The `media` property is added through the `metaclass=MediaDefiningClass`
definition.
"""
empty_media = widgets.Media()
self.assertIsInstance(self.component.media, widgets.Media)
self.assertMediaEqual(self.component.media, empty_media)
class TestComponentSubclasses(MediaAssertionMixin, SimpleTestCase):
"""
Test the Component class through subclasses.
Most functionality of the Component class is only unlocked through subclassing and
definition of certain attributes (like `template_name`) or overriding of the
existing methods. This test class tests the functionality that is unlocked through
subclassing.
"""
@classmethod
def make_example_template_name(cls) -> str:
return f"example-{random.randint(1000, 10000)}.html"
@classmethod
def get_example_template_name(cls) -> str:
example_template_name = cls.make_example_template_name()
while os.path.exists(example_template_name):
example_template_name = cls.make_example_template_name()
return example_template_name
def setUp(self) -> None:
self.example_template_name = self.get_example_template_name()
self.example_template = (
Path(settings.PROJECT_DIR) / "templates" / self.example_template_name
)
# Write content to the template file to ensure it exists.
self.set_example_template_content("")
def set_example_template_content(self, content: str) -> None:
with open(self.example_template, "w") as f:
f.write(content)
def test_render_html_with_template_name_set(self) -> None:
"""
Test `render_html` method with a set `template_name` attribute.
"""
# -----------------------------------------------------------------------------
class ExampleComponent(Component):
template_name = self.example_template_name
# -----------------------------------------------------------------------------
self.set_example_template_content("Test")
result = ExampleComponent().render_html()
self.assertIsInstance(result, str)
self.assertIsInstance(result, SafeString)
self.assertEqual(result, "Test")
def test_render_html_with_template_name_set_and_data_from_get_context_data(
self,
) -> None:
"""
Test `render_html` method with `get_context_data` providing data for the
context.
"""
# -----------------------------------------------------------------------------
class ExampleComponent(Component):
template_name = self.example_template_name
def get_context_data(
self,
parent_context: "Optional[RenderContext]",
) -> "RenderContext":
return {"name": "World"}
# -----------------------------------------------------------------------------
self.set_example_template_content("Hello {{ name }}")
result = ExampleComponent().render_html()
self.assertEqual(result, "Hello World")
def test_render_html_when_get_context_data_returns_None(self) -> None:
"""
Test `render_html` method when `get_context_data` returns `None`.
The `render_html` method raises a `TypeError` when `None` is returned from
`get_context_method`. This behavior was present when the class was extracted
from Wagtail. It is not totally clear why this specific check is needed. By
default, the `get_context_data` method provides and empty dict. If an override
wanted to `get_context_data` return `None`, it should be expected that no
context data is available during rendering. The underlying `template.render`
method does not seem to be ok with `None` as the provided context.
"""
# -----------------------------------------------------------------------------
class ExampleComponent(Component):
def get_context_data(
self,
parent_context: "Optional[Union[Context, dict[str, Any]]]",
) -> None:
return None
# -----------------------------------------------------------------------------
with self.assertRaises(TypeError):
ExampleComponent().render_html()
def test_media_defined_through_nested_class(self) -> None:
"""
Test the `media` property when defined through a nested class.
The `media` property is added through the `metaclass=MediaDefiningClass`
definition. This test ensures that the `media` property is available when
configured through a nested class.
"""
# -----------------------------------------------------------------------------
class ExampleComponent(Component):
class Media:
css = {"all": ["example.css"]}
js = ["example.js"]
# -----------------------------------------------------------------------------
result = ExampleComponent().media
self.assertIsInstance(result, widgets.Media)
self.assertMediaEqual(
result,
widgets.Media(css={"all": ["example.css"]}, js=["example.js"]),
)
def tearDown(self) -> None:
os.remove(path=self.example_template)
class TestMediaContainer(MediaAssertionMixin, SimpleTestCase):
"""
Test the MediaContainer class.
The `MediaContainer` functionality depends on the `django.forms.widgets.Media`
class. The `Media` class provides the logic to combine the media definitions of
multiple objects through its `__add__` method. The `MediaContainer` relies on this
functionality to provide a `media` property that combines the media definitions of
its members.
See also:
https://docs.djangoproject.com/en/4.2/topics/forms/media
"""
def setUp(self) -> None:
self.media_container = MediaContainer()
def test_empty(self) -> None:
result = self.media_container.media
self.assertIsInstance(result, widgets.Media)
self.assertMediaEqual(result, widgets.Media())
def test_single_member(self) -> None:
# -----------------------------------------------------------------------------
class ExampleClass:
media = widgets.Media(css={"all": ["example.css"]})
# -----------------------------------------------------------------------------
example = ExampleClass()
self.media_container.append(example)
result = self.media_container.media
self.assertIsInstance(result, widgets.Media)
self.assertMediaEqual(result, example.media)
self.assertMediaEqual(result, widgets.Media(css={"all": ["example.css"]}))
def test_two_members_of_same_class(self) -> None:
# -----------------------------------------------------------------------------
class ExampleClass:
media = widgets.Media(css={"all": ["example.css"]}, js=["example.js"])
# -----------------------------------------------------------------------------
example_1 = ExampleClass()
example_2 = ExampleClass()
self.media_container.append(example_1)
self.media_container.append(example_2)
result = self.media_container.media
self.assertIsInstance(result, widgets.Media)
self.assertMediaEqual(
result,
widgets.Media(css={"all": ["example.css"]}, js=["example.js"]),
)
def test_two_members_of_different_classes(self) -> None:
# -----------------------------------------------------------------------------
class ExampleClass:
media = widgets.Media(css={"all": ["shared.css"]}, js=["example.js"])
class OtherExampleClass:
media = widgets.Media(
css={
"all": ["other.css", "shared.css"],
"print": ["print.css"],
},
js=["other.js"],
)
# -----------------------------------------------------------------------------
example = ExampleClass()
self.media_container.append(example)
other = OtherExampleClass()
self.media_container.append(other)
result = self.media_container.media
self.assertIsInstance(result, widgets.Media)
self.assertMediaEqual(
result,
widgets.Media(
css={
"all": ["other.css", "shared.css"],
"print": ["print.css"],
},
js=["example.js", "other.js"],
),
)

View File

@@ -0,0 +1,441 @@
import os
import random
from copy import deepcopy
from pathlib import Path
from typing import TYPE_CHECKING
from unittest.mock import MagicMock
from django.conf import settings
from django.template import Context, Template, TemplateSyntaxError
from django.test import SimpleTestCase
from django.utils.html import format_html
from laces.components import Component
if TYPE_CHECKING:
from typing import Any, Dict, List
from django.utils.safestring import SafeString
from laces.typing import RenderContext
class CopyingMock(MagicMock):
"""
A mock that stores copies of the call arguments.
The default behaviour of a mock is to store references to the call arguments. This
means that if the call arguments are mutable, then the stored call arguments will
change when the call arguments are changed. This is not always desirable. E.g. the
`django.template.Context` class is mutable and the different layers are popped off
the context during rendering. This makes it hard to inspect the context that was
passed to a mock.
This variant of the mock stores copies of the call arguments. This means that the
stored call arguments will not change when the actual call arguments are changed.
This override is based on the Python docs:
https://docs.python.org/3/library/unittest.mock-examples.html#coping-with-mutable-arguments # noqa: E501
"""
def __call__(self, /, *args: "List[Any]", **kwargs: "Dict[str, Any]") -> "Any":
args = deepcopy(args)
kwargs = deepcopy(kwargs)
return super().__call__(*args, **kwargs)
class TestComponentTag(SimpleTestCase):
"""
Test for the `component` template tag.
Extracted from Wagtail. See:
https://github.com/wagtail/wagtail/blob/main/wagtail/admin/tests/test_templatetags.py#L225-L305 # noqa: E501
"""
def setUp(self) -> None:
self.parent_template = Template("")
class ExampleComponent(Component):
# Using a mock to be able to check if the `render_html` method is called.
render_html = CopyingMock(return_value="Rendered HTML")
self.component = ExampleComponent()
def set_parent_template(self, template_string: str) -> None:
template_string = "{% load laces %}" + template_string
self.parent_template = Template(template_string)
def render_parent_template_with_context(
self,
context: "RenderContext",
) -> "SafeString":
"""
Render the parent template with the given context.
Parameters
----------
context: RenderContext
Context to render the parent template with.
Returns
-------
SafeString
The parent template rendered with the given context.
"""
return self.parent_template.render(Context(context))
def assertVariablesAvailableInRenderHTMLParentContext(
self,
expected_context_variables: "Dict[str, Any]",
) -> None:
"""
Assert that the variables defined in the given dictionary are available in the
parent context of the `render_html` method.
Keys and values are checked.
"""
actual_context = self.component.render_html.call_args.args[0]
for key, value in expected_context_variables.items():
self.assertIn(key, actual_context)
actual_value = actual_context[key]
if not isinstance(actual_value, Component):
# Because we are inspecting copies of the context variables, we cannot
# easily compare the components by identity. For now, we just
# skip components.
self.assertEqual(actual_value, value)
def test_render_html_return_in_parent_template(self) -> None:
self.assertEqual(self.component.render_html(), "Rendered HTML")
self.set_parent_template("Before {% component my_component %} After")
result = self.render_parent_template_with_context(
{"my_component": self.component},
)
# This matches the return value of the `render_html` method inserted into the
# parent template.
self.assertEqual(result, "Before Rendered HTML After")
def test_render_html_return_is_escaped(self) -> None:
self.component.render_html.return_value = (
"Look, I'm running with scissors! 8< 8< 8<"
)
self.set_parent_template("{% component my_component %}")
result = self.render_parent_template_with_context(
{"my_component": self.component},
)
self.assertEqual(
result,
"Look, I&#x27;m running with scissors! 8&lt; 8&lt; 8&lt;",
)
def test_render_html_return_not_escaped_when_formatted_html(self) -> None:
self.component.render_html.return_value = format_html("<h1>My component</h1>")
self.set_parent_template("{% component my_component %}")
result = self.render_parent_template_with_context(
{"my_component": self.component},
)
self.assertEqual(result, "<h1>My component</h1>")
def test_render_html_return_not_escaped_when_actually_rendered_template(
self,
) -> None:
example_template_name = f"example-{random.randint(1000, 10000)}.html"
example_template = (
Path(settings.PROJECT_DIR) / "templates" / example_template_name
)
with open(example_template, "w") as f:
f.write("<h1>My component</h1>")
# -----------------------------------------------------------------------------
class RealExampleComponent(Component):
template_name = example_template_name
# -----------------------------------------------------------------------------
component = RealExampleComponent()
self.set_parent_template("{% component my_component %}")
result = self.render_parent_template_with_context(
{"my_component": component},
)
self.assertEqual(result, "<h1>My component</h1>")
os.remove(example_template)
def test_render_html_parent_context_when_only_component_in_context(self) -> None:
self.set_parent_template("{% component my_component %}")
self.render_parent_template_with_context({"my_component": self.component})
self.assertVariablesAvailableInRenderHTMLParentContext(
{"my_component": self.component}
)
def test_render_html_parent_context_when_other_variable_in_context(self) -> None:
self.set_parent_template("{% component my_component %}")
self.render_parent_template_with_context(
{
"my_component": self.component,
"test": "something",
}
)
self.assertVariablesAvailableInRenderHTMLParentContext(
{
"my_component": self.component,
"test": "something",
}
)
def test_render_html_parent_context_when_with_block_sets_extra_context(
self,
) -> None:
self.set_parent_template(
"{% with test='something' %}{% component my_component %}{% endwith %}"
)
self.render_parent_template_with_context({"my_component": self.component})
self.assertVariablesAvailableInRenderHTMLParentContext(
{
"my_component": self.component,
"test": "something",
}
)
def test_render_html_parent_context_when_with_keyword_sets_extra_context(
self,
) -> None:
self.set_parent_template("{% component my_component with test='something' %}")
self.render_parent_template_with_context({"my_component": self.component})
self.assertVariablesAvailableInRenderHTMLParentContext(
{
"my_component": self.component,
"test": "something",
}
)
def test_render_html_parent_context_when_with_only_keyword_limits_extra_context(
self,
) -> None:
self.set_parent_template(
"{% component my_component with test='nothing else' only %}"
)
self.render_parent_template_with_context(
{
"my_component": self.component,
"other": "something else",
}
)
# The `my_component` and `other` variables from the parent's rendering context
# are not included in the context that is passed to the `render_html` method.
# The `test` variable, that was defined with the with-keyword, is present
# though. Both of these effects come form the `only` keyword.
self.assertVariablesAvailableInRenderHTMLParentContext({"test": "nothing else"})
def test_render_html_parent_context_when_with_block_overrides_context(self) -> None:
self.set_parent_template(
"{% with test='something else' %}{% component my_component %}{% endwith %}"
)
self.render_parent_template_with_context(
{
"my_component": self.component,
"test": "something",
}
)
self.assertVariablesAvailableInRenderHTMLParentContext(
{
"my_component": self.component,
# The `test` variable is overriden by the `with` block.
"test": "something else",
}
)
def test_render_html_parent_context_when_with_keyword_overrides_context(
self,
) -> None:
self.set_parent_template(
"{% component my_component with test='something else' %}"
)
self.render_parent_template_with_context(
{
"my_component": self.component,
"test": "something",
}
)
self.assertVariablesAvailableInRenderHTMLParentContext(
{
"my_component": self.component,
# The `test` variable is overriden by the `with` keyword.
"test": "something else",
},
)
def test_render_html_parent_context_when_with_keyword_overrides_with_block(
self,
) -> None:
self.set_parent_template(
"""
{% with test='something' %}
{% component my_component with test='something else' %}
{% endwith %}
"""
)
self.render_parent_template_with_context({"my_component": self.component})
self.assertVariablesAvailableInRenderHTMLParentContext(
{
"my_component": self.component,
"test": "something else",
}
)
def test_fallback_render_method_arg_true_and_object_with_render_method(
self,
) -> None:
# -----------------------------------------------------------------------------
class ExampleNonComponentWithRenderMethod:
def render(self) -> str:
return "Rendered non-component"
# -----------------------------------------------------------------------------
non_component = ExampleNonComponentWithRenderMethod()
self.set_parent_template(
"{% component my_non_component fallback_render_method=True %}"
)
result = self.render_parent_template_with_context(
{"my_non_component": non_component},
)
self.assertEqual(result, "Rendered non-component")
def test_fallback_render_method_arg_true_but_object_without_render_method(
self,
) -> None:
# -----------------------------------------------------------------------------
class ExampleNonComponentWithoutRenderMethod:
pass
# -----------------------------------------------------------------------------
non_component = ExampleNonComponentWithoutRenderMethod()
self.set_parent_template(
"{% component my_non_component fallback_render_method=True %}"
)
with self.assertRaises(ValueError):
self.render_parent_template_with_context(
{"my_non_component": non_component},
)
def test_no_fallback_render_method_arg_and_object_without_render_method(
self,
) -> None:
# -----------------------------------------------------------------------------
class ExampleNonComponentWithoutRenderMethod:
def __repr__(self) -> str:
return "<Example repr>"
# -----------------------------------------------------------------------------
non_component = ExampleNonComponentWithoutRenderMethod()
self.set_parent_template("{% component my_non_component %}")
with self.assertRaises(ValueError) as cm:
self.render_parent_template_with_context(
{"my_non_component": non_component},
)
self.assertEqual(
str(cm.exception),
"Cannot render <Example repr> as a component",
)
def test_as_keyword_stores_render_html_return_as_variable(self) -> None:
self.set_parent_template(
"{% component my_component as my_var %}The result was: {{ my_var }}"
)
result = self.render_parent_template_with_context(
{"my_component": self.component},
)
self.assertEqual(result, "The result was: Rendered HTML")
def test_as_keyword_without_variable_name(self) -> None:
# The template is already parsed when the parent template is set. This is the
# moment where the parsing error is raised.
with self.assertRaises(TemplateSyntaxError) as cm:
self.set_parent_template("{% component my_component as %}")
self.assertEqual(
str(cm.exception),
"'component' tag with 'as' must be followed by a variable name",
)
def test_autoescape_off_block_can_disable_escaping_of_render_html_return(
self,
) -> None:
self.component.render_html.return_value = (
"Look, I'm running with scissors! 8< 8< 8<"
)
self.set_parent_template(
"{% autoescape off %}{% component my_component %}{% endautoescape %}"
)
result = self.render_parent_template_with_context(
{"my_component": self.component},
)
self.assertEqual(
result,
"Look, I'm running with scissors! 8< 8< 8<",
)
def test_parsing_no_arguments(self) -> None:
# The template is already parsed when the parent template is set. This is the
# moment where the parsing error is raised.
with self.assertRaises(TemplateSyntaxError) as cm:
self.set_parent_template("{% component %}")
self.assertEqual(
str(cm.exception),
"'component' tag requires at least one argument, the component object",
)
def test_parsing_unknown_kwarg(self) -> None:
# The template is already parsed when the parent template is set. This is the
# moment where the parsing error is raised.
with self.assertRaises(TemplateSyntaxError) as cm:
self.set_parent_template("{% component my_component unknown_kwarg=True %}")
self.assertEqual(
str(cm.exception),
"'component' tag only accepts 'fallback_render_method' as a keyword argument",
)
def test_parsing_unknown_bit(self) -> None:
# The template is already parsed when the parent template is set. This is the
# moment where the parsing error is raised.
with self.assertRaises(TemplateSyntaxError) as cm:
self.set_parent_template("{% component my_component unknown_bit %}")
self.assertEqual(
str(cm.exception),
"'component' tag received an unknown argument: 'unknown_bit'",
)

View File

@@ -0,0 +1,29 @@
"""Utilities for tests in the `laces` package."""
from django.forms import widgets
class MediaAssertionMixin:
@staticmethod
def assertMediaEqual(first: widgets.Media, second: widgets.Media) -> bool:
"""
Compare two `Media` instances.
The `Media` class does not implement `__eq__`, but its `__repr__` shows how to
recreate the instance.
We can use this to compare two `Media` instances.
Parameters
----------
first : widgets.Media
First `Media` instance.
second : widgets.Media
Second `Media` instance.
Returns
-------
bool
Whether the two `Media` instances are equal.
"""
return repr(first) == repr(second)