django-components/tests/test_extension.py
Juro Oravec 4d35bc97a2
feat: extensions (#1009)
* feat: extensions

* refactor: remove support for passing in extensions as instances
2025-03-08 09:41:28 +01:00

238 lines
9 KiB
Python

import gc
from typing import Any, Callable, Dict, List, cast
from django.template import Context
from django_components import Component, Slot, register, registry
from django_components.app_settings import app_settings
from django_components.component_registry import ComponentRegistry
from django_components.extension import (
ComponentExtension,
OnComponentClassCreatedContext,
OnComponentClassDeletedContext,
OnRegistryCreatedContext,
OnRegistryDeletedContext,
OnComponentRegisteredContext,
OnComponentUnregisteredContext,
OnComponentInputContext,
OnComponentDataContext,
)
from django_components.extensions.view import ViewExtension
from django_components.testing import djc_test
from .testutils import setup_test_config
setup_test_config({"autodiscover": False})
class DummyExtension(ComponentExtension):
"""
Test extension that tracks all hook calls and their arguments.
"""
name = "test_extension"
def __init__(self) -> None:
self.calls: Dict[str, List[Any]] = {
"on_component_class_created": [],
"on_component_class_deleted": [],
"on_registry_created": [],
"on_registry_deleted": [],
"on_component_registered": [],
"on_component_unregistered": [],
"on_component_input": [],
"on_component_data": [],
}
def on_component_class_created(self, ctx: OnComponentClassCreatedContext) -> None:
# NOTE: Store only component name to avoid strong references
self.calls["on_component_class_created"].append(ctx.component_cls.__name__)
def on_component_class_deleted(self, ctx: OnComponentClassDeletedContext) -> None:
# NOTE: Store only component name to avoid strong references
self.calls["on_component_class_deleted"].append(ctx.component_cls.__name__)
def on_registry_created(self, ctx: OnRegistryCreatedContext) -> None:
# NOTE: Store only registry object ID to avoid strong references
self.calls["on_registry_created"].append(id(ctx.registry))
def on_registry_deleted(self, ctx: OnRegistryDeletedContext) -> None:
# NOTE: Store only registry object ID to avoid strong references
self.calls["on_registry_deleted"].append(id(ctx.registry))
def on_component_registered(self, ctx: OnComponentRegisteredContext) -> None:
self.calls["on_component_registered"].append(ctx)
def on_component_unregistered(self, ctx: OnComponentUnregisteredContext) -> None:
self.calls["on_component_unregistered"].append(ctx)
def on_component_input(self, ctx: OnComponentInputContext) -> None:
self.calls["on_component_input"].append(ctx)
def on_component_data(self, ctx: OnComponentDataContext) -> None:
self.calls["on_component_data"].append(ctx)
def with_component_cls(on_created: Callable):
class TestComponent(Component):
template = "Hello {{ name }}!"
def get_context_data(self, name="World"):
return {"name": name}
on_created()
def with_registry(on_created: Callable):
registry = ComponentRegistry()
on_created(registry)
@djc_test
class TestExtension:
@djc_test(
components_settings={"extensions": [DummyExtension]}
)
def test_extensios_setting(self):
assert len(app_settings.EXTENSIONS) == 2
assert isinstance(app_settings.EXTENSIONS[0], ViewExtension)
assert isinstance(app_settings.EXTENSIONS[1], DummyExtension)
@djc_test(
components_settings={"extensions": [DummyExtension]}
)
def test_component_class_lifecycle_hooks(self):
extension = cast(DummyExtension, app_settings.EXTENSIONS[1])
assert len(extension.calls["on_component_class_created"]) == 0
assert len(extension.calls["on_component_class_deleted"]) == 0
did_call_on_comp_cls_created = False
def on_comp_cls_created():
nonlocal did_call_on_comp_cls_created
did_call_on_comp_cls_created = True
# Verify on_component_class_created was called
assert len(extension.calls["on_component_class_created"]) == 1
assert extension.calls["on_component_class_created"][0] == "TestComponent"
# Create a component class in a separate scope, to avoid any references from within
# this test function, so we can garbage collect it after the function returns
with_component_cls(on_comp_cls_created)
assert did_call_on_comp_cls_created
# This should trigger the garbage collection of the component class
gc.collect()
# Verify on_component_class_deleted was called
assert len(extension.calls["on_component_class_deleted"]) == 1
assert extension.calls["on_component_class_deleted"][0] == "TestComponent"
@djc_test(
components_settings={"extensions": [DummyExtension]}
)
def test_registry_lifecycle_hooks(self):
extension = cast(DummyExtension, app_settings.EXTENSIONS[1])
assert len(extension.calls["on_registry_created"]) == 0
assert len(extension.calls["on_registry_deleted"]) == 0
did_call_on_registry_created = False
reg_id = None
def on_registry_created(reg):
nonlocal did_call_on_registry_created
nonlocal reg_id
did_call_on_registry_created = True
reg_id = id(reg)
# Verify on_registry_created was called
assert len(extension.calls["on_registry_created"]) == 1
assert extension.calls["on_registry_created"][0] == reg_id
with_registry(on_registry_created)
assert did_call_on_registry_created
assert reg_id is not None
gc.collect()
# Verify on_registry_deleted was called
assert len(extension.calls["on_registry_deleted"]) == 1
assert extension.calls["on_registry_deleted"][0] == reg_id
@djc_test(
components_settings={"extensions": [DummyExtension]}
)
def test_component_registration_hooks(self):
class TestComponent(Component):
template = "Hello {{ name }}!"
def get_context_data(self, name="World"):
return {"name": name}
registry.register("test_comp", TestComponent)
extension = cast(DummyExtension, app_settings.EXTENSIONS[1])
# Verify on_component_registered was called
assert len(extension.calls["on_component_registered"]) == 1
reg_call: OnComponentRegisteredContext = extension.calls["on_component_registered"][0]
assert reg_call.registry == registry
assert reg_call.name == "test_comp"
assert reg_call.component_cls == TestComponent
registry.unregister("test_comp")
# Verify on_component_unregistered was called
assert len(extension.calls["on_component_unregistered"]) == 1
unreg_call: OnComponentUnregisteredContext = extension.calls["on_component_unregistered"][0]
assert unreg_call.registry == registry
assert unreg_call.name == "test_comp"
assert unreg_call.component_cls == TestComponent
@djc_test(
components_settings={"extensions": [DummyExtension]}
)
def test_component_render_hooks(self):
@register("test_comp")
class TestComponent(Component):
template = "Hello {{ name }}!"
def get_context_data(self, arg1, arg2, name="World"):
return {"name": name}
def get_js_data(self, *args, **kwargs):
return {"script": "console.log('Hello!')"}
def get_css_data(self, *args, **kwargs):
return {"style": "body { color: blue; }"}
# Render the component with some args and kwargs
test_context = Context({"foo": "bar"})
test_slots = {"content": "Some content"}
TestComponent.render(
context=test_context, args=("arg1", "arg2"), kwargs={"name": "Test"}, slots=test_slots
)
extension = cast(DummyExtension, app_settings.EXTENSIONS[1])
# Verify on_component_input was called with correct args
assert len(extension.calls["on_component_input"]) == 1
input_call: OnComponentInputContext = extension.calls["on_component_input"][0]
assert input_call.component_cls == TestComponent
assert isinstance(input_call.component_id, str)
assert input_call.args == ("arg1", "arg2")
assert input_call.kwargs == {"name": "Test"}
assert len(input_call.slots) == 1
assert isinstance(input_call.slots["content"], Slot)
assert input_call.context == test_context
# Verify on_component_data was called with correct args
assert len(extension.calls["on_component_data"]) == 1
data_call: OnComponentDataContext = extension.calls["on_component_data"][0]
assert data_call.component_cls == TestComponent
assert isinstance(data_call.component_id, str)
assert data_call.context_data == {"name": "Test"}
assert data_call.js_data == {"script": "console.log('Hello!')"}
assert data_call.css_data == {"style": "body { color: blue; }"}