diff --git a/tests/requirements.txt b/tests/requirements.txt index 93c09e8..9f6c7d8 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1,4 +1,5 @@ pytest +pytest-asyncio seleniumbase pixelmatch Pillow diff --git a/tests/test_component.py b/tests/test_component.py new file mode 100644 index 0000000..5164e23 --- /dev/null +++ b/tests/test_component.py @@ -0,0 +1,130 @@ +import asyncio +import pytest + +from trame_client.widgets.core import TrameComponent +from trame.decorators import change, controller, trigger +from trame.app import get_server + +from trame.ui.html import DivLayout +from trame.widgets import html + +CLIENT_TYPE = "vue3" + + +class TestComponent(TrameComponent): + def __init__(self, server): + self._steps = [] + super().__init__(server) + + def validate(self): + assert self._steps == [ + "a changed to 1", + "a changed to 2", + "ctrl.b() # set", + "ctrl.c() # add", + ] + + @change("a") + def on_a(self, a, **_): + self._steps.append(f"a changed to {a}") + + @controller.set("b") + def on_ctrl_b(self): + self._steps.append("ctrl.b() # set") + + @controller.add("c") + def on_ctrl_c(self): + self._steps.append("ctrl.c() # add") + + @trigger("hello") + def on_trigger(self): ... + + +class WidgetComponent(html.Div): + def __init__(self, **kwargs): + self._steps = [] + super().__init__(**kwargs) + self.state.setdefault("a", 1) + + with self: + html.Label("Hello World {{ a }}") + + def validate(self): + assert self._steps == [ + "a changed to 1", + "a changed to 2", + "ctrl.b() # set", + "ctrl.c() # add", + ] + + @change("a") + def on_a(self, a, **_): + self._steps.append(f"a changed to {a}") + + @controller.set("b") + def on_ctrl_b(self): + self._steps.append("ctrl.b() # set") + + @controller.add("c") + def on_ctrl_c(self): + self._steps.append("ctrl.c() # add") + + @trigger("hello") + def on_trigger(self): ... + + +@pytest.mark.asyncio +async def test_component(): + server = get_server("test_component", client_type=CLIENT_TYPE) + server.state.a = 1 + + component = TestComponent(server) + + server.state.ready() + await asyncio.sleep(0.1) + + with server.state: + server.state.a = 2 + + component.ctrl.b() + component.ctrl.c() + + assert server.trigger_name(component.on_trigger) == "hello" + + await asyncio.sleep(0.1) + + component.validate() + + +@pytest.mark.asyncio +async def test_widget_as_component(): + server = get_server("test_widget_as_component", client_type=CLIENT_TYPE) + + with DivLayout(server) as layout: + WidgetComponent(ctx_name="test_comp") + + assert ( + layout.html + == """
+
+ +
+
""" + ) + + server.state.ready() + await asyncio.sleep(0.1) + + with server.state: + server.state.a = 2 + + server.controller.b() + server.controller.c() + + assert server.trigger_name(server.context.test_comp.on_trigger) == "hello" + + await asyncio.sleep(0.1) + + server.context.test_comp.validate() diff --git a/trame_client/utils/formatter.py b/trame_client/utils/formatter.py index 9b8e730..8a89a7c 100644 --- a/trame_client/utils/formatter.py +++ b/trame_client/utils/formatter.py @@ -41,7 +41,7 @@ def to_pretty_html(html_content: str) -> str: color = COLOR_PALETTE[int(indent / indent_step) % len(COLOR_PALETTE)] output_lines.append( - f"{color}{' '*indent}{line.replace(' >', '>')}{BgColors.ENDC}" + f"{color}{' ' * indent}{line.replace(' >', '>')}{BgColors.ENDC}" ) if delta > 0: indent += compute_indent(line) diff --git a/trame_client/widgets/core.py b/trame_client/widgets/core.py index 2cbb648..c35cb6e 100644 --- a/trame_client/widgets/core.py +++ b/trame_client/widgets/core.py @@ -1,5 +1,7 @@ import sys import logging +import inspect + from ..utils.defaults import TrameDefault from ..utils.formatter import to_pretty_html @@ -67,6 +69,10 @@ logger = logging.getLogger(__name__) +def can_be_decorated(x): + return inspect.ismethod(x) or inspect.isfunction(x) + + def py2js_key(key): return key.replace("_", "-") @@ -211,7 +217,102 @@ def __call__(self, layout=None, **kwargs): HTML_CTX.add_child(self) -class AbstractElement: +class TrameComponent: + """ + Base trame class that has access to a trame server instance + on which we provide simple accessor and method decoration capabilities. + """ + + def __init__(self, server, ctx_name=None, **_): + """ + Initialize TrameComponent with its server. + + Keyword arguments: + server -- the server to link to (default None) + ctx_name -- name to use to bind current instance to server.context (default None) + """ + self._server = server + + if ctx_name: + self.ctx[ctx_name] = self + + self._bind_annotated_methods() + + @property + def server(self): + """Return the associated trame server instance""" + return self._server + + @property + def state(self): + """Return the associated server state""" + return self.server.state + + @property + def ctrl(self): + """Return the associated server controller""" + return self.server.controller + + @property + def ctx(self): + """Return the associated server context""" + return self.server.context + + def _bind_annotated_methods(self): + # Look for method decorator + for k in inspect.getmembers(self.__class__, can_be_decorated): + fn = getattr(self, k[0]) + + # Handle @state.change + s_translator = self.state.translator + if "_trame_state_change" in fn.__dict__: + state_change_names = fn.__dict__["_trame_state_change"] + logger.debug( + f"state.change({[f'{s_translator.translate_key(v)}' for v in state_change_names]})({k[0]})" + ) + self.state.change(*[f"{v}" for v in state_change_names])(fn) + + # Handle @trigger + if "_trame_trigger_names" in fn.__dict__: + trigger_names = fn.__dict__["_trame_trigger_names"] + for trigger_name in trigger_names: + logger.debug(f"trigger({trigger_name})({k[0]})") + self.server.trigger(f"{trigger_name}")(fn) + + # Handle @ctrl.[add, once, add_task, set] + if "_trame_controller" in fn.__dict__: + actions = fn.__dict__["_trame_controller"] + for action in actions: + name = action.get("name") + method = action.get("method") + decorate = getattr(self.ctrl, method) + logger.debug(f"ctrl.{method}({name})({k[0]})") + decorate(name)(fn) + + def _unbind_annotated_methods(self): + # Look for method decorator + for k in inspect.getmembers(self.__class__, can_be_decorated): + fn = getattr(self, k[0]) + + # Handle @state.change + methods_to_detach = {} + if "_trame_state_change" in fn.__dict__: + methods_to_detach.add(fn) + + if methods_to_detach: + for fn_list in self.state._change_callbacks.values(): + to_remove = set(fn_list) | methods_to_detach + for fn in to_remove: + fn_list.remove(fn) + + # Handle @trigger + # TODO + + # Handle @ctrl + # TODO + + +class AbstractElement(TrameComponent): """ A Vue component which can integrate with the rest of trame @@ -266,12 +367,19 @@ class AbstractElement: >>> print(html.Template(raw_attrs=["v-slot:item.1", 'class="bg-red"', '@click.stop="a=2"'])) ...