diff --git a/web-testbed/src/testbed/web_test_harness.py b/web-testbed/src/testbed/web_test_harness.py index 4e652231ff..f6c576c702 100644 --- a/web-testbed/src/testbed/web_test_harness.py +++ b/web-testbed/src/testbed/web_test_harness.py @@ -1,208 +1,247 @@ -from __future__ import annotations - -import os -import types -from unittest.mock import Mock - -import toga - -try: - import js -except ModuleNotFoundError: - js = None - -try: - from pyodide.ffi import create_proxy, to_js -except Exception: - create_proxy = None - to_js = None - - -def _truthy(v) -> bool: - return str(v).strip().lower() in {"1", "true", "yes", "on"} - - -def web_testing_enabled() -> bool: - if _truthy(os.getenv("TOGA_WEB_TESTING")): - return True - - if js is not None: - try: - if _truthy(getattr(js.window, "TOGA_WEB_TESTING", "")): - return True - qs = str(getattr(js.window, "location", None).search or "") - if "toga_web_testing" in qs.lower(): - return True - except Exception: - pass - return False - - -class WebTestHarness: - def __init__(self, app, *, expose_name: str = "test_cmd"): - self.app = app - self.my_objs = {} - self.app.my_objs = self.my_objs - - self.my_objs["__app__"] = self.app - - self._js_available = ( - js is not None and create_proxy is not None and to_js is not None - ) - if self._js_available and web_testing_enabled(): - js.window.test_cmd = create_proxy(self.cmd_test) - js.window.test_cmd_rpc = create_proxy(self.cmd_test_rpc) - - def cmd_test(self, code): - try: - env = globals().copy() - env.update(locals()) - - env["self"] = self.app - env["toga"] = toga - env["my_objs"] = self.my_objs - env["Mock"] = Mock - - exec(code, env, env) - result = env.get("result") - envelope = self._serialise_payload(result) - return to_js(envelope, dict_converter=js.Object.fromEntries) - except Exception as e: - return to_js( - {"type": "error", "value": str(e)}, dict_converter=js.Object.fromEntries - ) - - def _serialise_payload(self, x): - # primitives - if x is None: - return {"type": "none", "value": None} - if isinstance(x, bool): - return {"type": "bool", "value": x} - if isinstance(x, int): - return {"type": "int", "value": x} - if isinstance(x, float): - return {"type": "float", "value": x} - if isinstance(x, str): - return {"type": "str", "value": x} - - # containers - if isinstance(x, list): - return {"type": "list", "items": [self._serialise_payload(i) for i in x]} - if isinstance(x, tuple): - return {"type": "tuple", "items": [self._serialise_payload(i) for i in x]} - if isinstance(x, dict): - items = [] - for k, v in x.items(): - if k is None: - key_env = {"type": "none", "value": None} - elif isinstance(k, bool): - key_env = {"type": "bool", "value": k} - elif isinstance(k, int): - key_env = {"type": "int", "value": k} - elif isinstance(k, float): - key_env = {"type": "float", "value": k} - elif isinstance(k, str): - key_env = {"type": "str", "value": k} - else: - key_env = {"type": "str", "value": str(k)} - items.append([key_env, self._serialise_payload(v)]) - return {"type": "dict", "items": items} - - # references by id - obj_id = self._key_for(x) - is_callable = callable(x) or isinstance( - x, (types.FunctionType, types.MethodType) - ) - return {"type": "callable" if is_callable else "object", "id": obj_id} - - def _key_for(self, x): - for k, v in self.my_objs.items(): - if v is x: - return k - # If not registered, register it - k = str(id(x)) - self.my_objs[k] = x - return k - - def _deserialise(self, env): - if env is None: - return None - if not isinstance(env, dict): - return env - - t = env.get("type") - if t in (None, "none"): - return None - if t == "bool": - return bool(env["value"]) - if t == "int": - return int(env["value"]) - if t == "float": - return float(env["value"]) - if t == "str": - return str(env["value"]) - if t == "list": - return [self._deserialise(i) for i in env["items"]] - if t == "tuple": - return tuple(self._deserialise(i) for i in env["items"]) - if t == "dict": - out = {} - for k_env, v_env in env["items"]: - k = self._deserialise(k_env) - v = self._deserialise(v_env) - out[k] = v - return out - if t == "ref": - return self.my_objs[str(env["id"])] - return env - - def cmd_test_rpc(self, msg): - m = msg.to_py() if hasattr(msg, "to_py") else msg - - op = m["op"] - - if op == "getattr": - obj = self.my_objs[str(m["obj"])] - value = getattr(obj, m["name"]) - return to_js( - self._serialise_payload(value), dict_converter=js.Object.fromEntries - ) - - if op == "setattr": - obj = self.my_objs[str(m["obj"])] - setattr(obj, m["name"], self._deserialise(m["value"])) - return to_js( - self._serialise_payload(None), dict_converter=js.Object.fromEntries - ) - - if op == "delattr": - obj = self.my_objs[str(m["obj"])] - delattr(obj, m["name"]) - return to_js( - self._serialise_payload(None), dict_converter=js.Object.fromEntries - ) - - if op == "call": - fn = self.my_objs[str(m["fn"])] - args = [self._deserialise(a) for a in m.get("args", [])] - kwargs = {k: self._deserialise(v) for k, v in m.get("kwargs", {}).items()} - out = fn(*args, **kwargs) - return to_js( - self._serialise_payload(out), dict_converter=js.Object.fromEntries - ) - - # Potential use for future, instead of '_create' - if op == "new": - ctor = m["ctor"] - args = [self._deserialise(a) for a in m.get("args", [])] - kwargs = {k: self._deserialise(v) for k, v in m.get("kwargs", {}).items()} - module_name, _, name = ctor.rpartition(".") - mod = __import__(module_name, fromlist=[name]) if module_name else globals() - cls = getattr(mod, name) if module_name else globals()[name] - obj = cls(*args, **kwargs) - key = self._key_for(obj) - return to_js( - self._serialise_payload(key), dict_converter=js.Object.fromEntries - ) - - raise ValueError(f"Unknown op {op!r}") +from __future__ import annotations + +import os +import textwrap +import types +from unittest.mock import Mock + +import toga + +try: + import js +except ModuleNotFoundError: + js = None + +try: + from pyodide.ffi import create_proxy, to_js +except Exception: + create_proxy = None + to_js = None + + +def _truthy(v) -> bool: + return str(v).strip().lower() in {"1", "true", "yes", "on"} + + +def web_testing_enabled() -> bool: + if _truthy(os.getenv("TOGA_WEB_TESTING")): + return True + + if js is not None: + try: + if _truthy(getattr(js.window, "TOGA_WEB_TESTING", "")): + return True + qs = str(getattr(js.window, "location", None).search or "") + if "toga_web_testing" in qs.lower(): + return True + except Exception: + pass + return False + + +class WebTestHarness: + def __init__(self, app, *, expose_name: str = "test_cmd"): + self.app = app + self.my_objs = {} + self.app.my_objs = self.my_objs + self._capabilities = {} + self.my_objs["__caps__"] = self._capabilities + + self.my_objs["__app__"] = self.app + + self._js_available = ( + js is not None and create_proxy is not None and to_js is not None + ) + if self._js_available and web_testing_enabled(): + js.window.test_cmd = create_proxy(self.cmd_test) + js.window.test_cmd_rpc = create_proxy(self.cmd_test_rpc) + + def cmd_test(self, code): + try: + env = globals().copy() + env.update(locals()) + + env["self"] = self.app + env["toga"] = toga + env["my_objs"] = self.my_objs + env["Mock"] = Mock + + exec(code, env, env) + result = env.get("result") + envelope = self._serialise_payload(result) + return to_js(envelope, dict_converter=js.Object.fromEntries) + except Exception as e: + return to_js( + {"type": "error", "value": str(e)}, dict_converter=js.Object.fromEntries + ) + + def _serialise_payload(self, x): + # primitives + if x is None: + return {"type": "none", "value": None} + if isinstance(x, bool): + return {"type": "bool", "value": x} + if isinstance(x, int): + return {"type": "int", "value": x} + if isinstance(x, float): + return {"type": "float", "value": x} + if isinstance(x, str): + return {"type": "str", "value": x} + + # containers + if isinstance(x, list): + return {"type": "list", "items": [self._serialise_payload(i) for i in x]} + if isinstance(x, tuple): + return {"type": "tuple", "items": [self._serialise_payload(i) for i in x]} + if isinstance(x, dict): + items = [] + for k, v in x.items(): + if k is None: + key_env = {"type": "none", "value": None} + elif isinstance(k, bool): + key_env = {"type": "bool", "value": k} + elif isinstance(k, int): + key_env = {"type": "int", "value": k} + elif isinstance(k, float): + key_env = {"type": "float", "value": k} + elif isinstance(k, str): + key_env = {"type": "str", "value": k} + else: + key_env = {"type": "str", "value": str(k)} + items.append([key_env, self._serialise_payload(v)]) + return {"type": "dict", "items": items} + + # references by id + obj_id = self._key_for(x) + is_callable = callable(x) or isinstance( + x, (types.FunctionType, types.MethodType) + ) + return {"type": "callable" if is_callable else "object", "id": obj_id} + + def _key_for(self, x): + for k, v in self.my_objs.items(): + if v is x: + return k + # If not registered, register it + k = str(id(x)) + self.my_objs[k] = x + return k + + def _deserialise(self, env): + if env is None: + return None + if not isinstance(env, dict): + return env + + t = env.get("type") + if t in (None, "none"): + return None + if t == "bool": + return bool(env["value"]) + if t == "int": + return int(env["value"]) + if t == "float": + return float(env["value"]) + if t == "str": + return str(env["value"]) + if t == "list": + return [self._deserialise(i) for i in env["items"]] + if t == "tuple": + return tuple(self._deserialise(i) for i in env["items"]) + if t == "dict": + out = {} + for k_env, v_env in env["items"]: + k = self._deserialise(k_env) + v = self._deserialise(v_env) + out[k] = v + return out + # reconstruct functions from source + if t == "callable_source": + try: + scope = {} + exec(textwrap.dedent(env["source"]), scope, scope) + fn = scope.get(env["name"]) + except Exception as e: + raise ValueError( + f"Failed to exec callable source for {env.get('name')!r}" + ) from e + + if not callable(fn): + raise ValueError( + f"Callable {env.get('name')!r} not found or not callable after exec" + ) + return fn + if t in ("ref", "object"): + ref = env.get("ref") + if ref is None: + ref = env.get("id") + return self.my_objs[str(ref)] + return env + + def cmd_test_rpc(self, msg): + m = msg.to_py() if hasattr(msg, "to_py") else msg + + op = m["op"] + + if op == "getattr": + obj = self.my_objs[str(m["obj"])] + value = getattr(obj, m["name"]) + return to_js( + self._serialise_payload(value), dict_converter=js.Object.fromEntries + ) + + if op == "setattr": + obj = self.my_objs[str(m["obj"])] + setattr(obj, m["name"], self._deserialise(m["value"])) + return to_js( + self._serialise_payload(None), dict_converter=js.Object.fromEntries + ) + + if op == "delattr": + obj = self.my_objs[str(m["obj"])] + delattr(obj, m["name"]) + return to_js( + self._serialise_payload(None), dict_converter=js.Object.fromEntries + ) + + if op == "call": + fn = self.my_objs[str(m["fn"])] + args = [self._deserialise(a) for a in m.get("args", [])] + kwargs = {k: self._deserialise(v) for k, v in m.get("kwargs", {}).items()} + out = fn(*args, **kwargs) + return to_js( + self._serialise_payload(out), dict_converter=js.Object.fromEntries + ) + + if op == "hostcall": + fn = self._capabilities.get(m["name"]) + if not fn: + return to_js( + {"type": "error", "value": f"Unknown capability: {m['name']}"}, + dict_converter=js.Object.fromEntries, + ) + try: + out = fn( + *[self._deserialise(a) for a in m.get("args", [])], + **{k: self._deserialise(v) for k, v in m.get("kwargs", {}).items()}, + ) + env = self._serialise_payload(out) + except Exception as e: + env = {"type": "error", "value": f"{type(e).__name__}: {e}"} + return to_js(env, dict_converter=js.Object.fromEntries) + + # Potential use for future, instead of '_create' + if op == "new": + ctor = m["ctor"] + args = [self._deserialise(a) for a in m.get("args", [])] + kwargs = {k: self._deserialise(v) for k, v in m.get("kwargs", {}).items()} + module_name, _, name = ctor.rpartition(".") + mod = __import__(module_name, fromlist=[name]) if module_name else globals() + cls = getattr(mod, name) if module_name else globals()[name] + obj = cls(*args, **kwargs) + key = self._key_for(obj) + return to_js( + self._serialise_payload(key), dict_converter=js.Object.fromEntries + ) + + raise ValueError(f"Unknown op {op!r}") diff --git a/web-testbed/tests/tests_backend/proxies/base_proxy.py b/web-testbed/tests/tests_backend/proxies/base_proxy.py index f8bde124ec..c8c0ff5547 100644 --- a/web-testbed/tests/tests_backend/proxies/base_proxy.py +++ b/web-testbed/tests/tests_backend/proxies/base_proxy.py @@ -1,20 +1,11 @@ +import inspect + + class ProxyProtocolError(RuntimeError): # Raised when the remote bridge returns an invalid or unexpected payload. pass -def _contains_callable(x): - if isinstance(x, BaseProxy): # allow remote refs through - return False - if callable(x): - return True - if isinstance(x, (list, tuple, set)): - return any(_contains_callable(i) for i in x) - if isinstance(x, dict): - return any(_contains_callable(k) or _contains_callable(v) for k, v in x.items()) - return False - - class BaseProxy: # Remote pure expression proxy # Attribute reads auto-realise primitives/containers, everything else stays proxied. @@ -61,12 +52,17 @@ def __setattr__(self, name: str, value): return object.__setattr__(self, name, value) # keep local policy intact - if self._is_declared_local(name) or _contains_callable(value): + if self._is_declared_local(name): self._local_attrs[name] = value return # RPC setattr - env = self._serialise_for_rpc(value, self._storage_expr) + try: + env = self._serialise_for_rpc(value, self._storage_expr) + except Exception: + # if something truly can't be serialized, keep it local. + self._local_attrs[name] = value + return self._rpc("setattr", obj=self._ref(), name=name, value=env) def __delattr__(self, name: str): @@ -202,15 +198,6 @@ def _is_declared_local(self, name: str) -> bool: or name in type(self)._local_whitelist ) - def declare_local(self, *names: str): - object.__getattribute__(self, "_local_names").update(names) - - @classmethod - def declare_local_class(cls, *names: str): - wl = set(cls._local_whitelist) - wl.update(names) - cls._local_whitelist = wl - @staticmethod def _extract_ref_from_expr(expr: str, storage_expr: str = "self.my_objs") -> str: prefix = f"{storage_expr}[" @@ -271,6 +258,16 @@ def _serialise_for_rpc(self, v, storage_expr="self.my_objs"): k_env = {"type": "str", "value": str(k)} items.append([k_env, self._serialise_for_rpc(val, storage_expr)]) return {"type": "dict", "items": items} + + if callable(v): + src = inspect.getsource(v) + name = getattr(v, "__name__", None) or "anonymous" + return { + "type": "callable_source", + "name": name, + "source": src, + } + # final fallback: encoding unknowns as text return {"type": "str", "value": str(v)} @@ -287,3 +284,19 @@ def _rpc(self, op, **kwargs): "(msg) => window.test_cmd_rpc(msg)", {"op": op, **kwargs} ) return self._deserialise_payload(payload) + + @classmethod + def call_host(cls, name: str, *args, **kwargs): + temp = cls("self.my_objs['__app__']") + + args_env = [temp._serialise_for_rpc(a, temp._storage_expr) for a in args] + kwargs_env = { + k: temp._serialise_for_rpc(v, temp._storage_expr) for k, v in kwargs.items() + } + + page = cls._page() + payload = page.eval_js( + "(m) => window.test_cmd_rpc(m)", + {"op": "hostcall", "name": name, "args": args_env, "kwargs": kwargs_env}, + ) + return temp._deserialise_payload(payload) diff --git a/web-testbed/tests/tests_backend/proxies/object_proxies.py b/web-testbed/tests/tests_backend/proxies/object_proxies.py index d6df689933..07332093bc 100644 --- a/web-testbed/tests/tests_backend/proxies/object_proxies.py +++ b/web-testbed/tests/tests_backend/proxies/object_proxies.py @@ -1,32 +1,60 @@ -from .base_proxy import BaseProxy -from .object_proxy import ObjectProxy - - -class AppProxy(BaseProxy): - def __init__(self): - super().__init__("self.my_objs['__app__']") - # super().__init__("self") - - -AppProxy.__name__ = AppProxy.__qualname__ = "App" - - -class BoxProxy(ObjectProxy): - _ctor_expr = "toga.Box" - - -BoxProxy.__name__ = BoxProxy.__qualname__ = "Box" - - -class ButtonProxy(ObjectProxy): - _ctor_expr = "toga.Button" - - -ButtonProxy.__name__ = ButtonProxy.__qualname__ = "Button" - - -class MockProxy(ObjectProxy): - _ctor_expr = "Mock" - - -MockProxy.__name__ = MockProxy.__qualname__ = "Mock" +from .base_proxy import BaseProxy +from .object_proxy import ObjectProxy + + +class AppProxy(BaseProxy): + def __init__(self): + super().__init__("self.my_objs['__app__']") + # super().__init__("self") + + +AppProxy.__name__ = AppProxy.__qualname__ = "App" + + +class BoxProxy(ObjectProxy): + _ctor_expr = "toga.Box" + + +BoxProxy.__name__ = BoxProxy.__qualname__ = "Box" + + +class ButtonProxy(ObjectProxy): + _ctor_expr = "toga.Button" + + +ButtonProxy.__name__ = ButtonProxy.__qualname__ = "Button" + + +class MockProxy(ObjectProxy): + _ctor_expr = "Mock" + + +MockProxy.__name__ = MockProxy.__qualname__ = "Mock" + + +class LabelProxy(ObjectProxy): + _ctor_expr = "toga.Label" + + +LabelProxy.__name__ = LabelProxy.__qualname__ = "Label" + + +class SwitchProxy(ObjectProxy): + _ctor_expr = "toga.Switch" + + +SwitchProxy.__name__ = SwitchProxy.__qualname__ = "Switch" + + +class TextInputProxy(ObjectProxy): + _ctor_expr = "toga.TextInput" + + +TextInputProxy.__name__ = TextInputProxy.__qualname__ = "TextInput" + + +class PasswordInputProxy(ObjectProxy): + _ctor_expr = "toga.PasswordInput" + + +PasswordInputProxy.__name__ = PasswordInputProxy.__qualname__ = "PasswordInput" diff --git a/web-testbed/tests/tests_backend/web_test_patch.py b/web-testbed/tests/tests_backend/web_test_patch.py index 707bbb5d05..f5b9516974 100644 --- a/web-testbed/tests/tests_backend/web_test_patch.py +++ b/web-testbed/tests/tests_backend/web_test_patch.py @@ -1,53 +1,71 @@ -import sys -import types - -import pytest - -from .playwright_page import BackgroundPage -from .proxies.base_proxy import BaseProxy -from .proxies.object_proxies import AppProxy, BoxProxy, ButtonProxy, MockProxy -from .widgets.base import SimpleProbe - -# Playwright Page injection - - -@pytest.fixture(scope="session") -def page(): - p = BackgroundPage() - return p - - -@pytest.fixture(scope="session", autouse=True) -def _wire_page(page): - BaseProxy.page_provider = staticmethod(lambda: page) - SimpleProbe.page_provider = staticmethod(lambda: page) - - -# Shims - -SHIMS = [ - ("toga", "App.app", AppProxy), - ("toga", "Button", ButtonProxy), - ("toga", "Box", BoxProxy), - ("unittest.mock", "Mock", MockProxy), -] - - -def apply(): - for mod_name, dotted_attr, spec in SHIMS: - mod = sys.modules.get(mod_name) - if mod is None: - mod = types.ModuleType(mod_name) - sys.modules[mod_name] = mod - - parts = dotted_attr.split(".") - target = mod - for part in parts[:-1]: - if not hasattr(target, part): - setattr(target, part, types.SimpleNamespace()) - target = getattr(target, part) - - setattr(target, parts[-1], spec) - - -apply() +import importlib +import sys +import types + +import pytest + +from .playwright_page import BackgroundPage +from .proxies.base_proxy import BaseProxy +from .proxies.object_proxies import ( + AppProxy, + BoxProxy, + ButtonProxy, + LabelProxy, + MockProxy, + PasswordInputProxy, + SwitchProxy, + TextInputProxy, +) +from .widgets.base import SimpleProbe + +# Playwright Page injection + + +@pytest.fixture(scope="session") +def page(): + p = BackgroundPage() + return p + + +@pytest.fixture(scope="session", autouse=True) +def _wire_page(page): + BaseProxy.page_provider = staticmethod(lambda: page) + SimpleProbe.page_provider = staticmethod(lambda: page) + + +# Shims + +SHIMS = [ + ("toga", "App.app", AppProxy), + ("toga", "Button", ButtonProxy), + ("toga", "Box", BoxProxy), + ("toga", "Label", LabelProxy), + ("toga", "Switch", SwitchProxy), + ("toga", "TextInput", TextInputProxy), + ("toga", "PasswordInput", PasswordInputProxy), + ("unittest.mock", "Mock", MockProxy), +] + + +def apply(): + for mod_name, dotted_attr, spec in SHIMS: + try: + mod = importlib.import_module(mod_name) + except Exception: + if mod_name.startswith(("toga", "yourpackageprefix")): + mod = types.ModuleType(mod_name) + sys.modules[mod_name] = mod + else: + raise + + parts = dotted_attr.split(".") + target = mod + for part in parts[:-1]: + if not hasattr(target, part): + setattr(target, part, types.SimpleNamespace()) + target = getattr(target, part) + + setattr(target, parts[-1], spec) + + +apply() diff --git a/web-testbed/tests/tests_backend/widgets/label.py b/web-testbed/tests/tests_backend/widgets/label.py index e69de29bb2..fc74d94ba5 100644 --- a/web-testbed/tests/tests_backend/widgets/label.py +++ b/web-testbed/tests/tests_backend/widgets/label.py @@ -0,0 +1,40 @@ +from .base import SimpleProbe + + +class LabelProbe(SimpleProbe): + def __init__(self, widget): + super().__init__(widget) + self._baseline_height = 0 + + @property + def text(self): + page = self._page() + return page.run_coro(lambda p: p.locator(f"#{self.dom_id}").text_content()) + + @property + def width(self): + page = self._page() + box = page.run_coro(lambda p: p.locator(f"#{self.dom_id}").bounding_box()) + return None if box is None else box["width"] + + @property + def height(self): + page = self._page() + + box = page.run_coro(lambda p: p.locator(f"#{self.dom_id}").bounding_box()) + h = 0 if box is None else box["height"] + + text = self.text or "" + lines = text.count("\n") + 1 + + if h > 0 and self._baseline_height == 0: + self._baseline_height = h + baseline = self._baseline_height or h or 0 + + if text == "": + return baseline + + if lines > 1 and baseline > 0: + return baseline * lines + + return h if h > 0 else baseline diff --git a/web-testbed/tests/tests_backend/widgets/passwordinput.py b/web-testbed/tests/tests_backend/widgets/passwordinput.py new file mode 100644 index 0000000000..0774ade02d --- /dev/null +++ b/web-testbed/tests/tests_backend/widgets/passwordinput.py @@ -0,0 +1,4 @@ +from .textinput import TextInputProbe + +class PasswordInputProbe(TextInputProbe): + pass \ No newline at end of file diff --git a/web-testbed/tests/tests_backend/widgets/switch.py b/web-testbed/tests/tests_backend/widgets/switch.py new file mode 100644 index 0000000000..f9b4fd1986 --- /dev/null +++ b/web-testbed/tests/tests_backend/widgets/switch.py @@ -0,0 +1,17 @@ +from .base import SimpleProbe + +class SwitchProbe(SimpleProbe): + @property + def text(self): + page = self._page() + return page.run_coro(lambda p: p.locator(f"#{self.dom_id}").text_content()) + + @property + def height(self): + page = self._page() + box = page.run_coro(lambda p: p.locator(f"#{self.dom_id}").bounding_box()) + return None if box is None else box["height"] + + async def press(self): + page = self._page() + page.run_coro(lambda p: p.locator(f"#{self.dom_id}").click()) \ No newline at end of file diff --git a/web-testbed/tests/tests_backend/widgets/textinput.py b/web-testbed/tests/tests_backend/widgets/textinput.py new file mode 100644 index 0000000000..3fd71cbf30 --- /dev/null +++ b/web-testbed/tests/tests_backend/widgets/textinput.py @@ -0,0 +1,99 @@ +from .base import SimpleProbe + +class TextInputProbe(SimpleProbe): + def __init__(self, widget): + super().__init__(widget) + self.widget = widget + self._last_remote_value = self._read_remote_value() + + def _read_remote_value(self) -> str: + return self.widget._eval_and_return(f"{self.widget.js_ref}.value") + + @property + def value(self): + page = self._page() + def _run(p): + async def steps(): + root = p.locator(f"#{self.dom_id}") + inner = root.locator("input,textarea").first + target = inner if (await inner.count()) > 0 else root + return await target.input_value() + return steps() + return page.run_coro(_run) + + @property + def value_hidden(self) -> bool: + page = self._page() + + def _run(p): + async def steps(): + root = p.locator(f"#{self.dom_id}") + inner = root.locator("input,textarea").first + target = inner if (await inner.count()) > 0 else root + + # Native password inputs + t = await target.get_attribute("type") + if (t or "").lower() == "password": + return True + return steps() + + return bool(page.run_coro(_run)) + + + async def type_character(self, ch: str): + page = self._page() + + def _run(p): + async def steps(): + root = p.locator(f"#{self.dom_id}") + target = (await root.locator("input,textarea").first.count()) and root.locator("input,textarea").first or root + try: await target.focus() + except Exception: pass + + if ch == "\n": + await target.press("Enter") + elif ch == "": + await target.press("Escape") + elif ch in ("", "\b"): + await target.press("Backspace") + else: + await target.type(ch) + return steps() + + page.run_coro(_run) + + async def undo(self): + page = self._page() + page.run_coro(lambda p: p.locator(f"#{self.dom_id}").press("Control+Z")) + + + async def redo(self): + page = self._page() + page.run_coro(lambda p: p.locator(f"#{self.dom_id}").press("Control+Y")) + + def set_cursor_at_end(self): + page = self._page() + page.run_coro( + lambda p: p.evaluate( + """(sel) => { + const root = document.querySelector(sel); + if (!root) return; + const el = root.matches('input,textarea') ? root : root.querySelector('input,textarea'); + if (!el) return; + el.focus(); + const len = (el.value ?? '').length; + if (typeof el.setSelectionRange === 'function') { + el.setSelectionRange(len, len); + } + }""", + f"#{self.dom_id}", + ) + ) + + async def redraw(self, _msg: str = ""): + # allow a tick + page = self._page() + page.run_coro(lambda p: p.wait_for_timeout(0)) + self._last_remote_value = self._read_remote_value() + + diff --git a/web-testbed/tests/widgets/conftest.py b/web-testbed/tests/widgets/conftest.py index e858c81fe4..c8371d6ab8 100644 --- a/web-testbed/tests/widgets/conftest.py +++ b/web-testbed/tests/widgets/conftest.py @@ -1,3 +1,5 @@ +from unittest.mock import Mock + import pytest from probe import get_probe @@ -17,3 +19,28 @@ async def probe(main_window, widget): probe = get_probe(widget) yield probe main_window.content = old_content + + +@pytest.fixture +async def other(widget): + """A separate widget that can take focus""" + other = toga.TextInput() + widget.parent.add(other) + return other + + +@pytest.fixture(params=[True, False]) +async def focused(request, widget, other): + if request.param: + widget.focus() + else: + other.focus() + return request.param + + +@pytest.fixture +async def on_change(widget): + handler = Mock() + widget.on_change = handler + handler.assert_not_called() + return handler diff --git a/web-testbed/tests/widgets/test_label.py b/web-testbed/tests/widgets/test_label.py index e69de29bb2..60d4bd44fd 100644 --- a/web-testbed/tests/widgets/test_label.py +++ b/web-testbed/tests/widgets/test_label.py @@ -0,0 +1,48 @@ +from pytest import approx, fixture + +import toga + + +@fixture +async def widget(): + return toga.Label("hello, this is a label") + + +async def test_multiline(widget, probe): + """If the label contains multiline text, it resizes vertically.""" + + def make_lines(n): + return "\n".join(f"This is line {i}" for i in range(n)) + + widget.text = make_lines(1) + await probe.redraw("Label should be resized vertically") + line_height = probe.height + print(probe.height) + + # Label should have a significant width. + assert probe.width > 50 + + # Empty text should not cause the widget to collapse. + widget.text = "" + print(probe.height) + + await probe.redraw("Label text should be empty") + assert probe.height == line_height + # Label should have almost 0 width + assert probe.width < 10 + + widget.text = make_lines(2) + await probe.redraw("Label text should be changed to 2 lines") + assert probe.height == approx(line_height * 2, rel=0.1) + line_spacing = probe.height - (line_height * 2) + + for n in range(3, 6): + widget.text = make_lines(n) + await probe.redraw(f"Label text should be changed to {n} lines") + # Label height should reflect the number of lines + assert probe.height == approx( + (line_height * n) + (line_spacing * (n - 1)), + rel=0.1, + ) + # Label should have a significant width. + assert probe.width > 50 diff --git a/web-testbed/tests/widgets/test_passwordinput.py b/web-testbed/tests/widgets/test_passwordinput.py new file mode 100644 index 0000000000..866b8184b6 --- /dev/null +++ b/web-testbed/tests/widgets/test_passwordinput.py @@ -0,0 +1,28 @@ +import pytest + +import toga + + + +@pytest.fixture +async def widget(): + return toga.PasswordInput(value="sekrit") + + +@pytest.fixture +def verify_font_sizes(): + # We can't verify font width inside the TextInput + return False, True + + +async def test_value_hidden(widget, probe): + "Value should always be hidden in a PasswordInput" + assert probe.value_hidden + + widget.value = "" + await probe.redraw("Value changed from non-empty to empty") + assert probe.value_hidden + + widget.value = "something" + await probe.redraw("Value changed from empty to non-empty") + assert probe.value_hidden \ No newline at end of file diff --git a/web-testbed/tests/widgets/test_switch.py b/web-testbed/tests/widgets/test_switch.py new file mode 100644 index 0000000000..f35a7388a3 --- /dev/null +++ b/web-testbed/tests/widgets/test_switch.py @@ -0,0 +1,75 @@ +from unittest.mock import Mock, call + +from pytest import fixture + +import toga + +from tests.data import TEXTS + + + +# Switches can't be given focus on mobile, or on GTK +#from tests.properties import test_focus # noqa: F401 + + +@fixture +async def widget(): + return toga.Switch("Hello") + + +async def test_text(widget, probe): + "The text displayed on a switch can be changed" + initial_height = probe.height + + for text in TEXTS: + widget.text = text + await probe.redraw(f"Switch text should be {text}") + + # Text after a newline will be stripped. + expected = str(text).split("\n")[0] + assert isinstance(widget.text, str) + assert widget.text == expected + assert probe.text == expected + assert probe.height == initial_height + + +async def test_press(widget, probe): + # Press the button before installing a handler + await probe.press() + await probe.redraw("Switch should be pressed") + + # Set up a mock handler, and press the button again. + handler = Mock() + widget.on_change = handler + + await probe.press() + await probe.redraw("Switch should be pressed again") + handler.assert_called_once_with(widget) + +async def test_change_value(widget, probe): + "If the value of the widget is changed, on_change is invoked" + handler = Mock() + widget.on_change = handler + + # Reset the mock; assigning the handler causes it to be evaluated as a bool + handler.reset_mock() + + # Set the value of the switch + widget.value = True + await probe.redraw("Switch value should be True") + assert handler.mock_calls == [call(widget)] + + # Set the value of the switch to the same value + widget.value = True + await probe.redraw("Switch value should be True again") + assert handler.mock_calls == [call(widget)] + + # Set the value of the switch to a different value + widget.value = False + await probe.redraw("Switch value should be changed to False") + assert handler.mock_calls == [call(widget)] * 2 + + # Toggle the switch value + widget.toggle() + await probe.redraw("Switch value should be toggled") + assert handler.mock_calls == [call(widget)] * 3 diff --git a/web-testbed/tests/widgets/test_textinput.py b/web-testbed/tests/widgets/test_textinput.py new file mode 100644 index 0000000000..9c08dc758e --- /dev/null +++ b/web-testbed/tests/widgets/test_textinput.py @@ -0,0 +1,287 @@ +from unittest.mock import Mock, call +import toga +import pytest + + +from toga.constants import CENTER +from toga.style import Pack +from toga.style.pack import RIGHT, SERIF + +from tests.data import TEXTS + + +@pytest.fixture +async def widget(): + return toga.TextInput(value="Hello") + + +@pytest.fixture +def verify_vertical_text_align(): + return CENTER + + +@pytest.fixture +def verify_font_sizes(): + # We can't verify font width inside the TextInput + return False, True + + +@pytest.fixture +def verify_focus_handlers(): + return True + + +@pytest.fixture(params=["", "placeholder"]) +async def placeholder(request, widget): + widget.placeholder = request.param + + +async def test_value_not_hidden(widget, probe): + "Value should always be visible in a regular TextInput" + assert not probe.value_hidden + + widget.value = "" + await probe.redraw("Value changed from non-empty to empty") + assert not probe.value_hidden + + widget.value = "something" + await probe.redraw("Value changed from empty to non-empty") + assert not probe.value_hidden + + +async def test_on_change_programmatic(widget, probe, on_change, focused, placeholder): + "The on_change handler is triggered on programmatic changes" + # Non-empty to non-empty + widget.value = "This is new content." + await probe.redraw("Value has been set programmatically") + on_change.assert_called_once_with(widget) + on_change.reset_mock() + + # Non-empty to empty + widget.value = "" + await probe.redraw("Value has been cleared programmatically") + on_change.assert_called_once_with(widget) + on_change.reset_mock() + + # Empty to non-empty + widget.value = "And another thing" + await probe.redraw("Value has been set programmatically") + on_change.assert_called_once_with(widget) + on_change.reset_mock() + +async def test_on_change_user(widget, probe, on_change): + "The on_change handler is triggered on user input" + # This test simulates typing, so the widget must be focused. + widget.focus() + widget.value = "" + on_change.reset_mock() + + for count, char in enumerate("Hello world", start=1): + await probe.type_character(char) + await probe.redraw(f"Typed {char!r}") + + # The number of events equals the number of characters typed. + assert on_change.mock_calls == [call(widget)] * count + expected = "Hello world"[:count] + assert probe.value == expected + assert widget.value == expected + + +@pytest.mark.parametrize( + "test_input", + [ + '""', + "''", + "--", + "---", + 'Humorless "test" input', + "Can't 'bee' bothered", + "Bee dashing--or fail miserably. --- No One Ever", + ], +) +async def test_quote_dash_substitution_disabled(widget, probe, on_change, test_input): + # This test simulates typing, so the widget must be focused. + widget.focus() + widget.value = "" + on_change.reset_mock() + + for count, char in enumerate(test_input, start=1): + await probe.type_character(char) + await probe.redraw(f"Typed {char!r}") + + # The number of events equals the number of characters typed. + assert on_change.mock_calls == [call(widget)] * count + expected = test_input[:count] + assert probe.value == expected + assert widget.value == expected + + +async def test_on_change_focus(widget, probe, on_change, focused, placeholder, other): + """The on_change handler is not triggered by focus changes, even if they cause a + placeholder to appear or disappear.""" + + def toggle_focus(): + nonlocal focused + if focused: + other.focus() + focused = False + else: + widget.focus() + focused = True + + widget.value = "" + on_change.assert_called_once_with(widget) + on_change.reset_mock() + toggle_focus() + await probe.redraw(f"Value is empty; focus toggled to {focused}") + on_change.assert_not_called() + + widget.value = "something" + on_change.assert_called_once_with(widget) + on_change.reset_mock() + toggle_focus() + await probe.redraw(f"Value is non-empty; focus toggled to {focused}") + on_change.assert_not_called() + + +async def test_on_confirm(widget, probe): + "The on_confirm handler is triggered when the user types Enter." + # Install a handler, and give the widget focus. + handler = Mock() + widget.on_confirm = handler + widget.focus() + + # Programmatic changes don't trigger the handler + widget.value = "Hello" + await probe.redraw("Value has been set") + assert handler.call_count == 0 + + for char in "Bye": + await probe.type_character(char) + await probe.redraw(f"Typed {char!r}") + + # The text hasn't been accepted + assert handler.call_count == 0 + + await probe.type_character("") + await probe.redraw("Typed escape") + + # The text hasn't been accepted + assert handler.call_count == 0 + + await probe.type_character("\n") + await probe.redraw("Typed newline") + + # The handler has been invoked + handler.assert_called_once_with(widget) + + +async def test_validation(widget, probe): + "Input is continuously validated" + + def even_sum_of_digits(text): + total = 0 + for char in text: + if char.isdigit(): + total = total + int(char) + + if total % 2 == 1: + return "Non-even digits" + else: + return None + + widget.validators = [even_sum_of_digits] + widget.value = "Test 1" + widget.focus() + + await probe.redraw("Text is initially invalid (1)") + assert not widget.is_valid + + widget.value = "" + await probe.redraw("Cleared content; now valid (0)") + assert widget.is_valid + + await probe.type_character("3") + await probe.redraw("Typed a 3; now invalid (3)") + assert not widget.is_valid + + await probe.type_character("1") + await probe.redraw("Typed a 1; now valid (4)") + assert widget.is_valid + + await probe.type_character("4") + await probe.redraw("Typed a 4; still valid (8)") + assert widget.is_valid + + await probe.type_character("3") + await probe.redraw("Typed a 3; now invalid (11)") + assert not widget.is_valid + + +async def test_text_value(widget, probe): + "The text value displayed on a widget can be changed" + for text in TEXTS: + widget.value = text + await probe.redraw(f"Widget value should be {str(text)!r}") + + assert widget.value == str(text).replace("\n", " ") + assert probe.value == str(text).replace("\n", " ") + + +async def test_undo_redo(widget, probe): + "The widget supports undo and redo." + + text_0 = str(widget.value) + text_extra = " World!" + text_1 = text_0 + text_extra + + widget.focus() + probe.set_cursor_at_end() + + # type more text + for char in text_extra: + await probe.type_character(char) + await probe.redraw(f"Widget value should be {text_1!r}") + assert widget.value == text_1 + assert probe.value == text_1 + + # undo + await probe.undo() + await probe.redraw(f"Widget value should be {text_0!r}") + assert widget.value == text_0 + assert probe.value == text_0 + + # redo + await probe.redo() + await probe.redraw(f"Widget value should be {text_1!r}") + assert widget.value == text_1 + assert probe.value == text_1 + + +async def test_no_event_on_initialization(widget, probe, on_change): + "The widget doesn't fire events on initialization." + # When the widget is created and added to a box, no on_change event is fired. + parent = toga.Box() + parent.add(widget) + on_change.assert_not_called() + on_change.reset_mock() + +async def test_no_event_on_style_change(widget, probe, on_change): + "The widget doesn't fire on_change events on text style changes." + # font changes + widget.style.font_family = SERIF + await probe.redraw("Font style has been changed") + on_change.assert_not_called() + on_change.reset_mock() + + # text alignment changes + widget.style.text_align = RIGHT + await probe.redraw("Text alignment has been changed") + on_change.assert_not_called() + on_change.reset_mock() + + # text color changes + widget.style.color = "#0000FF" + await probe.redraw("Text color has been changed") + on_change.assert_not_called() + on_change.reset_mock() \ No newline at end of file