From 0d9142d2d38b378847b20acdbaad5465442b455d Mon Sep 17 00:00:00 2001 From: Norman Fomferra Date: Sun, 8 Dec 2024 15:33:08 +0100 Subject: [PATCH 1/9] Python test coverage almost at 100% --- .gitignore | 2 + chartlets.js/CHANGES.md | 3 + chartlets.py/CHANGES.md | 4 +- chartlets.py/chartlets/callback.py | 100 ++++----- chartlets.py/chartlets/channel.py | 8 +- .../chartlets/components/charts/vega.py | 20 +- chartlets.py/chartlets/contribution.py | 1 - .../chartlets/controllers/callback.py | 6 +- .../chartlets/controllers/contributions.py | 5 +- chartlets.py/chartlets/controllers/layout.py | 21 +- chartlets.py/chartlets/extension.py | 30 ++- chartlets.py/chartlets/extensioncontext.py | 13 +- chartlets.py/chartlets/util/assertions.py | 31 ++- chartlets.py/chartlets/util/logger.py | 3 + chartlets.py/tests/callback_test.py | 200 ++++++++++++++++-- chartlets.py/tests/channel_test.py | 4 +- .../tests/components/charts/vega_test.py | 16 +- chartlets.py/tests/container_test.py | 15 ++ chartlets.py/tests/contribution_test.py | 92 ++++++++ chartlets.py/tests/controllers/__init__.py | 0 .../tests/controllers/callback_test.py | 100 +++++++++ .../tests/controllers/contributions_test.py | 47 ++++ chartlets.py/tests/controllers/layout_test.py | 81 +++++++ chartlets.py/tests/extension_test.py | 48 +++++ chartlets.py/tests/extensioncontext_test.py | 88 ++++++++ chartlets.py/tests/response_test.py | 22 ++ chartlets.py/tests/util/__init__.py | 0 chartlets.py/tests/util/assertions_test.py | 77 +++++++ chartlets.py/tests/util/logger_test.py | 11 + 29 files changed, 916 insertions(+), 132 deletions(-) create mode 100644 chartlets.py/chartlets/util/logger.py create mode 100644 chartlets.py/tests/contribution_test.py create mode 100644 chartlets.py/tests/controllers/__init__.py create mode 100644 chartlets.py/tests/controllers/callback_test.py create mode 100644 chartlets.py/tests/controllers/contributions_test.py create mode 100644 chartlets.py/tests/controllers/layout_test.py create mode 100644 chartlets.py/tests/extension_test.py create mode 100644 chartlets.py/tests/extensioncontext_test.py create mode 100644 chartlets.py/tests/response_test.py create mode 100644 chartlets.py/tests/util/__init__.py create mode 100644 chartlets.py/tests/util/assertions_test.py create mode 100644 chartlets.py/tests/util/logger_test.py diff --git a/.gitignore b/.gitignore index b64a940d..bac5e5f8 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,8 @@ node_modules __pycache__ # mkdocs site +# pytest --cov +.coverage # Editor directories and files .vscode/* diff --git a/chartlets.js/CHANGES.md b/chartlets.js/CHANGES.md index 551a9842..223f8d27 100644 --- a/chartlets.js/CHANGES.md +++ b/chartlets.js/CHANGES.md @@ -29,6 +29,9 @@ dark, light, and system mode. * Changed the yet unused descriptor type `CbFunction` for callback functions. + - using `schema` instead of `type` property for callback arguments + - using `return` object with `schema` property for callback return values + ## Version 0.0.29 (from 2024/11/26) diff --git a/chartlets.py/CHANGES.md b/chartlets.py/CHANGES.md index 91b0114f..776fb51c 100644 --- a/chartlets.py/CHANGES.md +++ b/chartlets.py/CHANGES.md @@ -1,6 +1,5 @@ ## Version 0.1.0 (in development) - * Reorganised Chartlets project to better separate demo from library code. Created separate folder `demo` in `chartlets.py` that contains a demo `server` package and example configuration. @@ -14,6 +13,9 @@ * Renamed `Plot` into `VegaChart`, which now also respects a `theme` property. * Changed schema of the yet unused descriptor for callback functions. + - using `schema` instead of `type` property for callback arguments + - using `return` object with `schema` property for callback return values + ## Version 0.0.29 (from 2024/11/26) diff --git a/chartlets.py/chartlets/callback.py b/chartlets.py/chartlets/callback.py index 83e65aba..71eecf6a 100644 --- a/chartlets.py/chartlets/callback.py +++ b/chartlets.py/chartlets/callback.py @@ -2,11 +2,12 @@ import types from typing import Any, Callable -from chartlets.channel import ( +from .channel import ( Input, Output, State, ) +from .util.logger import LOGGER class Callback: @@ -20,7 +21,7 @@ class Callback: def from_decorator( cls, decorator_name: str, - decorator_args: tuple[Any, ...], + decorator_args: tuple | list, function: Any, states_only: bool = False, ) -> "Callback": @@ -126,11 +127,10 @@ def make_function_args( f" expected {num_inputs}," f" but got {num_values}" ) - if delta > 0: - values = (*values, *(delta * (None,))) - print(f"WARNING: {message}") # TODO use logging - else: + if delta < 0: raise TypeError(message) + LOGGER.warning(message) + values = (*values, *(delta * (None,))) param_names = self.param_names[1:] args = [context] @@ -150,15 +150,14 @@ def _parameter_to_dict(parameter: inspect.Parameter) -> dict[str, Any]: empty = inspect.Parameter.empty d = {"name": parameter.name} if parameter.annotation is not empty: - d |= {"schema": _annotation_to_json_schema(parameter.annotation)} + d |= {"schema": annotation_to_json_schema(parameter.annotation)} if parameter.default is not empty: d |= {"default": parameter.default} return d + def _return_to_dict(return_annotation: Any) -> dict[str, Any]: - return { - "schema": _annotation_to_json_schema(return_annotation) - } + return {"schema": annotation_to_json_schema(return_annotation)} _basic_types = { @@ -173,68 +172,55 @@ def _return_to_dict(return_annotation: Any) -> dict[str, Any]: dict: "object", } -_object_types = {"Component": "Component", "Chart": "Chart"} +def annotation_to_json_schema(annotation: Any) -> dict: + from chartlets import Component -def _annotation_to_json_schema(annotation: Any) -> dict: if annotation is Any: return {} - - if annotation in _basic_types: + elif annotation in _basic_types: return {"type": _basic_types[annotation]} - - if isinstance(annotation, types.UnionType): - type_list = list(map(_annotation_to_json_schema, annotation.__args__)) + elif isinstance(annotation, types.UnionType): + assert annotation.__args__ and len(annotation.__args__) > 1 + type_list = list(map(annotation_to_json_schema, annotation.__args__)) type_name_list = [ - t["type"] for t in type_list if isinstance(t.get("type"), str) + t["type"] + for t in type_list + if isinstance(t.get("type"), str) and len(t) == 1 ] - if len(type_name_list) == 1: - return {"type": type_name_list[0]} - elif len(type_name_list) > 1: + if len(type_list) == len(type_name_list): return {"type": type_name_list} - elif len(type_list) == 1: - return type_list[0] - elif len(type_list) > 1: - return {"oneOf": type_list} else: - return {} - - if isinstance(annotation, types.GenericAlias): + return {"oneOf": type_list} + elif isinstance(annotation, types.GenericAlias): + assert annotation.__args__ if annotation.__origin__ is tuple: return { "type": "array", - "items": list(map(_annotation_to_json_schema, annotation.__args__)), + "items": list(map(annotation_to_json_schema, annotation.__args__)), } elif annotation.__origin__ is list: - if annotation.__args__: - return { - "type": "array", - "items": _annotation_to_json_schema(annotation.__args__[0]), - } + assert annotation.__args__ and len(annotation.__args__) == 1 + items_schema = annotation_to_json_schema(annotation.__args__[0]) + if items_schema == {}: + return {"type": "array"} else: - return { - "type": "array", - } + return {"type": "array", "items": items_schema} elif annotation.__origin__ is dict: - if annotation.__args__: - if len(annotation.__args__) == 2 and annotation.__args__[0] is str: - return { - "type": "object", - "additionalProperties": _annotation_to_json_schema( - annotation.__args__[1] - ), - } - else: - return { - "type": "object", - } - else: - type_name = ( - annotation.__name__ if hasattr(annotation, "__name__") else str(annotation) - ) - try: - return {"type": "object", "class": _object_types[type_name]} - except KeyError: - pass + assert annotation.__args__ + assert len(annotation.__args__) == 2 + if annotation.__args__[0] is str: + value_schema = annotation_to_json_schema(annotation.__args__[1]) + if value_schema == {}: + return {"type": "object"} + else: + return {"type": "object", "additionalProperties": value_schema} + elif ( + inspect.isclass(annotation) + and "." not in annotation.__qualname__ + and callable(getattr(annotation, "to_dict", None)) + ): + # Note, for Component classes it is actually possible to generate the object schema + return {"type": "object", "class": annotation.__qualname__} raise TypeError(f"unsupported type annotation: {annotation}") diff --git a/chartlets.py/chartlets/channel.py b/chartlets.py/chartlets/channel.py index e59199b3..7cfc50c2 100644 --- a/chartlets.py/chartlets/channel.py +++ b/chartlets.py/chartlets/channel.py @@ -2,7 +2,7 @@ from typing import Any from .util.assertions import ( - assert_is_given, + assert_is_not_empty, assert_is_instance_of, assert_is_one_of, ) @@ -26,13 +26,13 @@ def to_dict(self) -> dict[str, Any]: return dict(id=self.id, property=self.property) def _validate_params(self, id_: Any, property: Any) -> tuple[str, str | None]: - assert_is_given("id", id_) + assert_is_not_empty("id", id_) assert_is_instance_of("id", id_, str) id: str = id_ if id.startswith("@"): # Other states than component states assert_is_one_of("id", id, ("@app", "@container")) - assert_is_given("property", property) + assert_is_not_empty("property", property) assert_is_instance_of("property", property, str) else: # Component state @@ -44,7 +44,7 @@ def _validate_params(self, id_: Any, property: Any) -> tuple[str, str | None]: pass else: # Components must have valid properties - assert_is_given("property", property) + assert_is_not_empty("property", property) assert_is_instance_of("property", property, str) return id, property diff --git a/chartlets.py/chartlets/components/charts/vega.py b/chartlets.py/chartlets/components/charts/vega.py index 2e746f93..45e6f929 100644 --- a/chartlets.py/chartlets/components/charts/vega.py +++ b/chartlets.py/chartlets/components/charts/vega.py @@ -2,17 +2,23 @@ from typing import Any import warnings +from chartlets import Component + + # Respect that "altair" is an optional dependency. +class AltairDummy: + # noinspection PyPep8Naming + @property + def Chart(self): + warnings.warn("you must install 'altair' to use the VegaChart component") + return int + + try: # noinspection PyUnresolvedReferences import altair - - AltairChart = altair.Chart except ImportError: - warnings.warn("you must install 'altair' to use the VegaChart component") - AltairChart = type(None) - -from chartlets import Component + altair = AltairDummy() @dataclass(frozen=True) @@ -27,7 +33,7 @@ class VegaChart(Component): theme: str | None = None """The name of a [Vega theme](https://vega.github.io/vega-themes/).""" - chart: AltairChart | None = None + chart: altair.Chart | None = None """The [Vega Altair chart](https://altair-viz.github.io/gallery/index.html).""" def to_dict(self) -> dict[str, Any]: diff --git a/chartlets.py/chartlets/contribution.py b/chartlets.py/chartlets/contribution.py index fa99c1a8..fd547446 100644 --- a/chartlets.py/chartlets/contribution.py +++ b/chartlets.py/chartlets/contribution.py @@ -19,7 +19,6 @@ class Contribution(ABC): initial_state: contribution specific attribute values. """ - # noinspection PyShadowingBuiltins def __init__(self, name: str, **initial_state: Any): self.name = name self.initial_state = initial_state diff --git a/chartlets.py/chartlets/controllers/callback.py b/chartlets.py/chartlets/controllers/callback.py index 24e92b1b..9a94ea33 100644 --- a/chartlets.py/chartlets/controllers/callback.py +++ b/chartlets.py/chartlets/controllers/callback.py @@ -2,6 +2,8 @@ from chartlets.extensioncontext import ExtensionContext from chartlets.response import Response +from chartlets.util.assertions import assert_is_instance_of +from chartlets.util.assertions import assert_is_not_none # POST /chartlets/callback @@ -20,8 +22,8 @@ def get_callback_results( On success, the response is a list of state-change requests grouped by contributions. """ - if ext_ctx is None: - return Response.failed(404, f"no contributions configured") + assert_is_not_none("ext_ctx", ext_ctx) + assert_is_instance_of("data", data, dict) # TODO: validate data callback_requests: list[dict] = data.get("callbackRequests") or [] diff --git a/chartlets.py/chartlets/controllers/contributions.py b/chartlets.py/chartlets/controllers/contributions.py index 1cd290e9..04b1ca7f 100644 --- a/chartlets.py/chartlets/controllers/contributions.py +++ b/chartlets.py/chartlets/controllers/contributions.py @@ -1,5 +1,6 @@ from chartlets.extensioncontext import ExtensionContext from chartlets.response import Response +from chartlets.util.assertions import assert_is_not_none def get_contributions(ext_ctx: ExtensionContext | None) -> Response: @@ -13,9 +14,7 @@ def get_contributions(ext_ctx: ExtensionContext | None) -> Response: On success, the response is a dictionary that represents a JSON-serialized component tree. """ - if ext_ctx is None: - return Response.failed(404, f"no contributions configured") - + assert_is_not_none("ext_ctx", ext_ctx) extensions = ext_ctx.extensions contributions = ext_ctx.contributions return Response.success( diff --git a/chartlets.py/chartlets/controllers/layout.py b/chartlets.py/chartlets/controllers/layout.py index 6b95835d..778901e2 100644 --- a/chartlets.py/chartlets/controllers/layout.py +++ b/chartlets.py/chartlets/controllers/layout.py @@ -2,6 +2,9 @@ from chartlets.extensioncontext import ExtensionContext from chartlets.response import Response +from chartlets.util.assertions import assert_is_not_none +from chartlets.util.assertions import assert_is_not_empty +from chartlets.util.assertions import assert_is_instance_of def get_layout( @@ -25,8 +28,10 @@ def get_layout( On success, the response is a dictionary that represents a JSON-serialized component tree. """ - if ext_ctx is None: - return Response.failed(404, f"no contributions configured") + assert_is_not_none("ext_ctx", ext_ctx) + assert_is_not_empty("contrib_point_name", contrib_point_name) + assert_is_instance_of("contrib_index", contrib_index, int) + assert_is_instance_of("data", data, dict) # TODO: validate data input_values = data.get("inputValues") or [] @@ -38,16 +43,20 @@ def get_layout( 404, f"contribution point {contrib_point_name!r} not found" ) - contrib_ref = f"{contrib_point_name}[{contrib_index}]" - try: contribution = contributions[contrib_index] except IndexError: - return Response.failed(404, f"contribution {contrib_ref!r} not found") + return Response.failed( + 404, + ( + f"index range of contribution point {contrib_point_name!r} is" + f" 0 to {len(contributions) - 1}, got {contrib_index}" + ), + ) callback = contribution.layout_callback if callback is None: - return Response.failed(400, f"contribution {contrib_ref!r} has no layout") + return Response.failed(400, f"contribution {contribution.name!r} has no layout") component = callback.invoke(ext_ctx.app_ctx, input_values) diff --git a/chartlets.py/chartlets/extension.py b/chartlets.py/chartlets/extension.py index aec3af5a..877a0382 100644 --- a/chartlets.py/chartlets/extension.py +++ b/chartlets.py/chartlets/extension.py @@ -1,3 +1,4 @@ +import inspect from typing import Any from chartlets.contribution import Contribution @@ -18,8 +19,19 @@ def add_contrib_point(cls, name: str, item_type: type[Contribution]): item_type: The type of items that can be added to the new contribution point. """ + if not inspect.isclass(item_type) or not issubclass(item_type, Contribution): + message = "item_type must be a class derived from chartlets.Contribution" + raise TypeError( + f"{message}, but was {item_type.__name__}" + if hasattr(item_type, "__name__") + else message + ) cls._contrib_points[item_type] = name + @classmethod + def reset_contrib_points(cls): + cls._contrib_points = {} + @classmethod def get_contrib_point_names(cls) -> tuple[str, ...]: """Get names of all known contribution points added @@ -35,8 +47,7 @@ def get_contrib_point_names(cls) -> tuple[str, ...]: def __init__(self, name: str, version: str = "0.0.0"): self.name = name self.version = version - for contrib_point_name in self.get_contrib_point_names(): - setattr(self, contrib_point_name, []) + self._contributions: dict[str, list[Contribution]] = {} def add(self, contribution: Contribution): """Add a contribution to this extension. @@ -53,8 +64,13 @@ def add(self, contribution: Contribution): f"unrecognized contribution of type {contrib_type.__qualname__}" ) contribution.extension = self.name - contributions: list[Contribution] = getattr(self, contrib_point_name) - contributions.append(contribution) + if contrib_point_name in self._contributions: + self._contributions[contrib_point_name].append(contribution) + else: + self._contributions[contrib_point_name] = [contribution] + + def get(self, contrib_point_name: str) -> list[Contribution]: + return self._contributions.get(contrib_point_name, []) def to_dict(self) -> dict[str, Any]: """Convert this extension into a JSON-serializable dictionary. @@ -64,9 +80,5 @@ def to_dict(self) -> dict[str, Any]: return dict( name=self.name, version=self.version, - contributes=[ - contrib_point_name - for contrib_point_name in self.get_contrib_point_names() - if getattr(self, contrib_point_name) - ], + contributes=sorted(self._contributions.keys()), ) diff --git a/chartlets.py/chartlets/extensioncontext.py b/chartlets.py/chartlets/extensioncontext.py index 650aa5d5..16bd24bb 100644 --- a/chartlets.py/chartlets/extensioncontext.py +++ b/chartlets.py/chartlets/extensioncontext.py @@ -1,8 +1,6 @@ import importlib from typing import Any -import sys - from chartlets import Extension, Contribution @@ -15,7 +13,7 @@ def __init__(self, app_ctx: Any, extensions: list[Extension]): # noinspection PyTypeChecker contributions: list[Contribution] = [] for extension in extensions: - contributions.extend(getattr(extension, contrib_point_name)) + contributions.extend(extension.get(contrib_point_name)) # noinspection PyTypeChecker contributions_map[contrib_point_name] = contributions self._contributions = contributions_map @@ -52,16 +50,15 @@ def load( extensions: list[Extension] = [] for ext_ref in extension_refs: try: - module_name, attr_name = ext_ref.rsplit(".", maxsplit=2) + module_name, attr_name = ext_ref.rsplit(".", maxsplit=1) except (ValueError, AttributeError): - raise TypeError(f"contribution syntax error: {ext_ref!r}") + raise ValueError(f"contribution syntax error: {ext_ref!r}") module = importlib.import_module(module_name) extension = getattr(module, attr_name) if not isinstance(extension, Extension): raise TypeError( - f"extension {ext_ref!r} must refer to an" - f" instance of {Extension.__qualname__!r}," - f" but was {type(extension).__qualname__!r}" + f"extension reference {ext_ref!r} is not referring to an" + f" instance of chartlets.Extension" ) extensions.append(extension) return ExtensionContext(app_ctx, extensions) diff --git a/chartlets.py/chartlets/util/assertions.py b/chartlets.py/chartlets/util/assertions.py index bb24d0af..2414fbfa 100644 --- a/chartlets.py/chartlets/util/assertions.py +++ b/chartlets.py/chartlets/util/assertions.py @@ -1,6 +1,22 @@ +from collections.abc import Collection from typing import Any, Container, Type +def assert_is_not_none(name: str, value: Any): + if value is None: + raise ValueError(f"value for {name!r} must not be None") + + +def assert_is_not_empty(name: str, value: Any): + if value is None: + raise ValueError(f"value for {name!r} must be given") + try: + if len(value) == 0: + raise ValueError(f"value for {name!r} must not be empty") + except TypeError: + pass + + def assert_is_one_of(name: str, value: Any, value_set: Container): if value not in value_set: raise ValueError( @@ -10,16 +26,9 @@ def assert_is_one_of(name: str, value: Any, value_set: Container): def assert_is_instance_of(name: str, value: Any, type_set: Type | tuple[Type, ...]): if not isinstance(value, type_set): + if isinstance(type_set, type): + type_set = (type_set,) raise TypeError( - f"value of {name!r} must be an instance of {type_set!r}, but was {value!r}" + f"value of {name!r} must be of type" + f" {" or ".join(map(lambda t: t.__name__, type_set))}, but was {type(value).__name__}" ) - - -def assert_is_none(name: str, value: Any): - if value is not None: - raise TypeError(f"value of {name!r} must be None, but was {value!r}") - - -def assert_is_given(name: str, value: Any): - if not value: - raise ValueError(f"value for {name!r} must be given") diff --git a/chartlets.py/chartlets/util/logger.py b/chartlets.py/chartlets/util/logger.py new file mode 100644 index 00000000..67f24fa0 --- /dev/null +++ b/chartlets.py/chartlets/util/logger.py @@ -0,0 +1,3 @@ +import logging + +LOGGER = logging.getLogger("chartlets") diff --git a/chartlets.py/tests/callback_test.py b/chartlets.py/tests/callback_test.py index dd7abdf9..c8c12f4d 100644 --- a/chartlets.py/tests/callback_test.py +++ b/chartlets.py/tests/callback_test.py @@ -3,8 +3,9 @@ import pytest +from chartlets.callback import Callback, annotation_to_json_schema from chartlets.channel import Input, State, Output -from chartlets.callback import Callback +from chartlets.components import VegaChart, Checkbox, Button # noinspection PyUnusedLocal @@ -24,13 +25,15 @@ def my_callback_2(ctx, n: int) -> tuple[list[str], str | None]: return list(map(str, range(1, n + 1))), str(1) -class CallbackTest(unittest.TestCase): - def test_make_function_args(self): - callback = Callback(my_callback, [Input("a"), Input("b"), Input("c")], []) - ctx = object() - args, kwargs = callback.make_function_args(ctx, [13, "Wow", True]) - self.assertEqual((ctx, 13), args) - self.assertEqual({"b": "Wow", "c": True}, kwargs) +def no_args_callback(): + pass + + +def no_annotations_callback(ctx, a, b): + pass + + +class CallbackToDictTest(unittest.TestCase): def test_to_dict_with_no_outputs(self): callback = Callback( @@ -63,7 +66,7 @@ def test_to_dict_with_no_outputs(self): }, { "name": "e", - "schema": {'type': ['object', 'null']}, + "schema": {"type": ["object", "null"]}, "default": None, }, ], @@ -96,13 +99,15 @@ def test_to_dict_with_two_outputs(self): "function": { "name": "my_callback_2", "parameters": [{"name": "n", "schema": {"type": "integer"}}], - "return": {"schema":{ - "items": [ - {"items": {"type": "string"}, "type": "array"}, - {"type": ["string", "null"]}, - ], - "type": "array", - }}, + "return": { + "schema": { + "items": [ + {"items": {"type": "string"}, "type": "array"}, + {"type": ["string", "null"]}, + ], + "type": "array", + } + }, }, "inputs": [{"id": "n", "property": "value"}], "outputs": [ @@ -114,22 +119,75 @@ def test_to_dict_with_two_outputs(self): ) +# noinspection PyMethodMayBeStatic +class CallbackInvokeTest(unittest.TestCase): + def test_works(self): + callback = Callback(my_callback, [Input(c) for c in "abcde"], [Output("x")]) + ctx = object() + result = callback.invoke(ctx, [12, "Wow", True, list("abc"), dict(x=14)]) + self.assertEqual("12-Wow-True-['a', 'b', 'c']-{'x': 14}", result) + + def test_too_few_input_values(self): + callback = Callback(my_callback, [Input(c) for c in "abcde"], [Output("x")]) + ctx = object() + result = callback.invoke(ctx, [12, "Wow", True]) + self.assertEqual("12-Wow-True-None-None", result) + + def test_too_many_input_values(self): + callback = Callback(my_callback, [Input(c) for c in "abcde"], [Output("x")]) + ctx = object() + with pytest.raises( + TypeError, + match=( + "too many input values given for function" + " 'my_callback': expected 5, but got 6" + ), + ): + callback.make_function_args( + ctx, [11, "Wow", False, list("xyz"), dict(x=17), None] + ) + + # noinspection PyMethodMayBeStatic class FromDecoratorTest(unittest.TestCase): + def test_ok(self): + cb = Callback.from_decorator( + "test", [Input(c) for c in "abcde"] + [Output("x")], my_callback + ) + self.assertIsInstance(cb, Callback) + + def test_no_args(self): + with pytest.raises( + TypeError, + match=( + "function 'no_args_callback' decorated with" + " 'test' must have at least one context parameter" + ), + ): + Callback.from_decorator( + "test", + (), + no_args_callback, + ) + def test_too_few_inputs(self): with pytest.raises( TypeError, - match="too few inputs in decorator 'test' for function" - " 'my_callback': expected 5, but got 0", + match=( + "too few inputs in decorator 'test' for function" + " 'my_callback': expected 5, but got 0" + ), ): Callback.from_decorator("test", (), my_callback) def test_too_many_inputs(self): with pytest.raises( TypeError, - match="too many inputs in decorator 'test' for function" - " 'my_callback': expected 5, but got 7", + match=( + "too many inputs in decorator 'test' for function" + " 'my_callback': expected 5, but got 7" + ), ): Callback.from_decorator( "test", tuple(Input(c) for c in "abcdefg"), my_callback @@ -160,3 +218,105 @@ def test_decorator_args(self): ), ): Callback.from_decorator("test", (13,), my_callback, states_only=True) + + def test_no_annotation(self): + cb = Callback.from_decorator( + "test", (Input("x"), Input("y"), Output("z")), no_annotations_callback + ) + + +# noinspection PyMethodMayBeStatic +class AnnotationToJsonSchemaTest(unittest.TestCase): + def test_any_type(self): + self.assertEqual({}, annotation_to_json_schema(Any)) + + def test_simple_types(self): + self.assertEqual({"type": "null"}, annotation_to_json_schema(None)) + self.assertEqual({"type": "null"}, annotation_to_json_schema(type(None))) + self.assertEqual({"type": "boolean"}, annotation_to_json_schema(bool)) + self.assertEqual({"type": "integer"}, annotation_to_json_schema(int)) + self.assertEqual({"type": "number"}, annotation_to_json_schema(float)) + self.assertEqual({"type": "string"}, annotation_to_json_schema(str)) + self.assertEqual({"type": "array"}, annotation_to_json_schema(list)) + self.assertEqual({"type": "array"}, annotation_to_json_schema(tuple)) + self.assertEqual({"type": "object"}, annotation_to_json_schema(dict)) + + def test_generic_alias(self): + self.assertEqual( + {"type": "array"}, + annotation_to_json_schema(list[Any]), + ) + self.assertEqual( + {"type": "array", "items": {"type": "integer"}}, + annotation_to_json_schema(list[int]), + ) + self.assertEqual( + {"type": "array", "items": [{"type": "array"}]}, + annotation_to_json_schema(tuple[list]), + ) + self.assertEqual( + {"type": "array", "items": [{"type": "string"}, {"type": "integer"}]}, + annotation_to_json_schema(tuple[str, int]), + ) + self.assertEqual( + {"type": "object"}, + annotation_to_json_schema(dict[str, Any]), + ) + self.assertEqual( + { + "type": "object", + "additionalProperties": {"type": "boolean"}, + }, + annotation_to_json_schema(dict[str, bool]), + ) + + def test_union_type(self): + self.assertEqual( + {"type": ["string", "null"]}, annotation_to_json_schema(str | None) + ) + self.assertEqual( + {"type": ["string", "null"]}, annotation_to_json_schema(str | None) + ) + self.assertEqual( + { + "oneOf": [ + {"type": "boolean"}, + { + "type": "array", + "items": [{"type": "string"}, {"type": "boolean"}], + }, + {"type": "null"}, + ] + }, + annotation_to_json_schema(bool | tuple[str, bool] | None), + ) + + def test_component_type(self): + self.assertEqual( + {"type": "object", "class": "VegaChart"}, + annotation_to_json_schema(VegaChart), + ) + self.assertEqual( + {"type": "object", "class": "Button"}, annotation_to_json_schema(Button) + ) + + def test_not_supported(self): + with pytest.raises( + TypeError, match="unsupported type annotation: " + ): + annotation_to_json_schema(object) + + with pytest.raises( + TypeError, match="unsupported type annotation: " + ): + annotation_to_json_schema(set) + + with pytest.raises( + TypeError, match="unsupported type annotation: set\\[str\\]" + ): + annotation_to_json_schema(set[str]) + + with pytest.raises( + TypeError, match="unsupported type annotation: dict\\[int, str\\]" + ): + annotation_to_json_schema(dict[int, str]) diff --git a/chartlets.py/tests/channel_test.py b/chartlets.py/tests/channel_test.py index 07c17bd3..d0e12566 100644 --- a/chartlets.py/tests/channel_test.py +++ b/chartlets.py/tests/channel_test.py @@ -97,7 +97,7 @@ class InputTest(make_base(), unittest.TestCase): channel_cls = Input def test_component_empty_property(self): - with pytest.raises(ValueError, match="value for 'property' must be given"): + with pytest.raises(ValueError, match="value for 'property' must not be empty"): self.channel_cls("dataset_select", "") @@ -105,7 +105,7 @@ class StateTest(make_base(), unittest.TestCase): channel_cls = State def test_component_empty_property(self): - with pytest.raises(ValueError, match="value for 'property' must be given"): + with pytest.raises(ValueError, match="value for 'property' must not be empty"): self.channel_cls("dataset_select", "") diff --git a/chartlets.py/tests/components/charts/vega_test.py b/chartlets.py/tests/components/charts/vega_test.py index 4465d48d..caac501b 100644 --- a/chartlets.py/tests/components/charts/vega_test.py +++ b/chartlets.py/tests/components/charts/vega_test.py @@ -1,8 +1,11 @@ +import unittest + import pandas as pd import altair as alt +import pytest from chartlets.components import VegaChart - +from chartlets.components.charts.vega import AltairDummy from tests.component_test import make_base @@ -57,3 +60,14 @@ def test_without_chart_prop(self): self.cls(id="plot", style={"width": 100}), {"type": "VegaChart", "id": "plot", "style": {"width": 100}}, ) + + +class AltairDummyTest(unittest.TestCase): + def test_dummy(self): + altair = AltairDummy() + with pytest.warns(UserWarning) as wr: + self.assertEqual(int, altair.Chart) + self.assertEqual( + ["you must install 'altair' to use the VegaChart component"], + [f"{w.message}" for w in wr.list], + ) diff --git a/chartlets.py/tests/container_test.py b/chartlets.py/tests/container_test.py index 5f313e65..a44a62e0 100644 --- a/chartlets.py/tests/container_test.py +++ b/chartlets.py/tests/container_test.py @@ -42,3 +42,18 @@ def test_to_dict(self): }, group.to_dict(), ) + + def test_add(self): + g = ItemGroup("g") + a = Item("a") + b = Item("b") + g.add(a) + g.add(b) + self.assertEqual( + { + "type": "ItemGroup", + "id": "g", + "children": [{"type": "Item", "id": "a"}, {"type": "Item", "id": "b"}], + }, + g.to_dict(), + ) diff --git a/chartlets.py/tests/contribution_test.py b/chartlets.py/tests/contribution_test.py new file mode 100644 index 00000000..7454e5cf --- /dev/null +++ b/chartlets.py/tests/contribution_test.py @@ -0,0 +1,92 @@ +from dataclasses import dataclass +from typing import Any +import unittest + +from chartlets import Component, Contribution, Input, Output, State, Callback, Extension + +from chartlets.components import Box, Button, Typography + + +@dataclass(frozen=True) +class Button(Component): + text: str = "" + + +class Panel(Contribution): + pass + # def __init__(self, name: str, **initial_state: Any): + # super().__init__(name, **initial_state) + + +panel = Panel("panel0", title="My 1st Panel") + +Extension.add_contrib_point("panels", Panel) + +ext = Extension("ext0") +ext.add(panel) + + +@panel.layout() +def render_panel(_ctx) -> Box: + return Box(children=[Typography(id="txt", children=["Hello"]), Button(id="btn")]) + + +@panel.callback( + Input("btn", "clicked"), State("txt", "children"), Output("txt", "children") +) +def render_panel(_ctx: Any, _clicked: bool, old_children: list[str]) -> list[str]: + return old_children + [" World!"] + + +# noinspection PyMethodMayBeStatic +class ContributionTest(unittest.TestCase): + + def test_attributes(self): + self.assertEqual("panel0", panel.name) + self.assertEqual("ext0", panel.extension) + self.assertEqual(dict(title="My 1st Panel"), panel.initial_state) + self.assertIsInstance(panel.layout_callback, Callback) + self.assertEqual(1, len(panel.callbacks)) + self.assertIsInstance(panel.callbacks[0], Callback) + + def test_to_dict(self): + self.assertEqual( + { + "name": "panel0", + "extension": "ext0", + "initialState": {"title": "My 1st Panel"}, + "layout": { + "function": { + "name": "render_panel", + "parameters": [], + "return": {"schema": {"class": "Box", "type": "object"}}, + } + }, + "callbacks": [ + { + "function": { + "name": "render_panel", + "parameters": [ + {"name": "_clicked", "schema": {"type": "boolean"}}, + { + "name": "old_children", + "schema": { + "items": {"type": "string"}, + "type": "array", + }, + }, + ], + "return": { + "schema": {"items": {"type": "string"}, "type": "array"} + }, + }, + "inputs": [ + {"id": "btn", "property": "clicked"}, + {"id": "txt", "noTrigger": True, "property": "children"}, + ], + "outputs": [{"id": "txt", "property": "children"}], + } + ], + }, + panel.to_dict(), + ) diff --git a/chartlets.py/tests/controllers/__init__.py b/chartlets.py/tests/controllers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/chartlets.py/tests/controllers/callback_test.py b/chartlets.py/tests/controllers/callback_test.py new file mode 100644 index 00000000..fe58a805 --- /dev/null +++ b/chartlets.py/tests/controllers/callback_test.py @@ -0,0 +1,100 @@ +import unittest + +from chartlets import Input, Response, State, Output +from chartlets import Contribution, Extension, ExtensionContext +from chartlets.components import Checkbox +from chartlets.controllers import get_callback_results + + +class Panel(Contribution): + pass + + +Extension.add_contrib_point("panels", Panel) + +panel = Panel("my_panel") + + +@panel.layout(State("@app", "selected")) +def render_layout(ctx, selected): + return Checkbox("x", value=selected) + + +@panel.callback(Input("@app", "selected"), Output("x")) +def adjust_selection(ctx, selected): + return selected + + +panel_wo_callback = Panel("my_panel_wo_callback") + +ext = Extension("my_ext") +ext.add(panel) +ext.add(panel_wo_callback) + + +class GetCallbackResultsTest(unittest.TestCase): + + def test_success_empty(self): + app_ctx = object() + ext_ctx = ExtensionContext(app_ctx, [ext]) + response = get_callback_results(ext_ctx, {"callbackRequests": []}) + self.assertIsInstance(response, Response) + self.assertEqual(200, response.status) + self.assertEqual([], response.data) + + def test_success_non_empty(self): + app_ctx = object() + ext_ctx = ExtensionContext(app_ctx, [ext]) + response = get_callback_results( + ext_ctx, + { + "callbackRequests": [ + { + "contribPoint": "panels", + "contribIndex": 0, + "callbackIndex": 0, + "inputValues": [True], + } + ] + }, + ) + self.assertIsInstance(response, Response) + self.assertEqual(200, response.status) + self.assertEqual( + [ + { + "contribPoint": "panels", + "contribIndex": 0, + "stateChanges": [{"id": "x", "property": "value", "value": True}], + } + ], + response.data, + ) + + # def test_invalid_contrib_point(self): + # app_ctx = object() + # ext_ctx = ExtensionContext(app_ctx, [ext]) + # response = get_layout(ext_ctx, "menus", 1, {"inputValues": [True]}) + # self.assertIsInstance(response, Response) + # self.assertEqual(404, response.status) + # self.assertEqual("contribution point 'menus' not found", response.reason) + # + # def test_invalid_contrib_index(self): + # app_ctx = object() + # ext_ctx = ExtensionContext(app_ctx, [ext]) + # response = get_layout(ext_ctx, "panels", 15, {"inputValues": [True]}) + # self.assertIsInstance(response, Response) + # self.assertEqual(404, response.status) + # self.assertEqual( + # "index range of contribution point 'panels' is 0 to 1, got 15", response.reason + # ) + # + # def test_no_layout(self): + # app_ctx = object() + # ext_ctx = ExtensionContext(app_ctx, [ext]) + # response = get_layout(ext_ctx, "panels", 1, {"inputValues": [True]}) + # self.assertIsInstance(response, Response) + # self.assertEqual(400, response.status) + # self.assertEqual( + # "contribution 'my_panel_wo_layout' has no layout", response.reason + # ) diff --git a/chartlets.py/tests/controllers/contributions_test.py b/chartlets.py/tests/controllers/contributions_test.py new file mode 100644 index 00000000..ae38d56d --- /dev/null +++ b/chartlets.py/tests/controllers/contributions_test.py @@ -0,0 +1,47 @@ +import unittest + +from chartlets import Response +from chartlets import Contribution, Extension, ExtensionContext +from chartlets.controllers import get_contributions + + +class Panel(Contribution): + pass + + +Extension.add_contrib_point("panels", Panel) + +ext0 = Extension("ext0") +ext0.add(Panel("panel00")) +ext0.add(Panel("panel01")) + +ext1 = Extension("ext1") +ext1.add(Panel("panel10")) +ext1.add(Panel("panel11")) + + +class GetContributionsTest(unittest.TestCase): + + def test_success(self): + app_ctx = object() + ext_ctx = ExtensionContext(app_ctx, [ext0, ext1]) + response = get_contributions(ext_ctx) + self.assertIsInstance(response, Response) + self.assertEqual(200, response.status) + self.assertEqual( + { + "extensions": [ + {"contributes": ["panels"], "name": "ext0", "version": "0.0.0"}, + {"contributes": ["panels"], "name": "ext1", "version": "0.0.0"}, + ], + "contributions": { + "panels": [ + {"extension": "ext0", "initialState": {}, "name": "panel00"}, + {"extension": "ext0", "initialState": {}, "name": "panel01"}, + {"extension": "ext1", "initialState": {}, "name": "panel10"}, + {"extension": "ext1", "initialState": {}, "name": "panel11"}, + ] + }, + }, + response.data, + ) diff --git a/chartlets.py/tests/controllers/layout_test.py b/chartlets.py/tests/controllers/layout_test.py new file mode 100644 index 00000000..c674b0a6 --- /dev/null +++ b/chartlets.py/tests/controllers/layout_test.py @@ -0,0 +1,81 @@ +import unittest + +from chartlets import Input, Response, State +from chartlets import Contribution, Extension, ExtensionContext +from chartlets.components import Checkbox +from chartlets.controllers import get_layout + + +class Panel(Contribution): + pass + + +Extension.add_contrib_point("panels", Panel) + +panel = Panel("my_panel") + + +@panel.layout(State("@app", "selected")) +def render_layout(ctx, selected): + return Checkbox("x", label="Selected", value=selected) + + +panel_wo_layout = Panel("my_panel_wo_layout") + +ext = Extension("my_ext") +ext.add(panel) +ext.add(panel_wo_layout) + + +class GetLayoutTest(unittest.TestCase): + + def test_success_empty(self): + app_ctx = object() + ext_ctx = ExtensionContext(app_ctx, [ext]) + response = get_layout(ext_ctx, "panels", 0, {"inputValues": []}) + self.assertIsInstance(response, Response) + self.assertEqual(200, response.status) + self.assertEqual( + {"type": "Checkbox", "id": "x", "label": "Selected"}, + response.data, + ) + + def test_success_non_empty(self): + app_ctx = object() + ext_ctx = ExtensionContext(app_ctx, [ext]) + response = get_layout(ext_ctx, "panels", 0, {"inputValues": [True]}) + self.assertIsInstance(response, Response) + self.assertEqual(200, response.status) + self.assertEqual( + {"type": "Checkbox", "id": "x", "label": "Selected", "value": True}, + response.data, + ) + + def test_invalid_contrib_point(self): + app_ctx = object() + ext_ctx = ExtensionContext(app_ctx, [ext]) + response = get_layout(ext_ctx, "menus", 1, {"inputValues": [True]}) + self.assertIsInstance(response, Response) + self.assertEqual(404, response.status) + self.assertEqual("contribution point 'menus' not found", response.reason) + + def test_invalid_contrib_index(self): + app_ctx = object() + ext_ctx = ExtensionContext(app_ctx, [ext]) + response = get_layout(ext_ctx, "panels", 15, {"inputValues": [True]}) + self.assertIsInstance(response, Response) + self.assertEqual(404, response.status) + self.assertEqual( + "index range of contribution point 'panels' is 0 to 1, got 15", + response.reason, + ) + + def test_no_layout(self): + app_ctx = object() + ext_ctx = ExtensionContext(app_ctx, [ext]) + response = get_layout(ext_ctx, "panels", 1, {"inputValues": [True]}) + self.assertIsInstance(response, Response) + self.assertEqual(400, response.status) + self.assertEqual( + "contribution 'my_panel_wo_layout' has no layout", response.reason + ) diff --git a/chartlets.py/tests/extension_test.py b/chartlets.py/tests/extension_test.py new file mode 100644 index 00000000..7ff090bf --- /dev/null +++ b/chartlets.py/tests/extension_test.py @@ -0,0 +1,48 @@ +import unittest + +import pytest + +from chartlets import Extension + + +# noinspection PyMethodMayBeStatic +class ExtensionTest(unittest.TestCase): + + def setUp(self): + Extension.reset_contrib_points() + + def tearDown(self): + Extension.reset_contrib_points() + + def test_attributes(self): # + ext = Extension("ext") + self.assertEqual("ext", ext.name) + self.assertEqual("0.0.0", ext.version) + + def test_to_dict(self): + ext = Extension("ext") + self.assertEqual( + {"contributes": [], "name": "ext", "version": "0.0.0"}, + ext.to_dict(), + ) + + def test_add(self): + ext = Extension("ext") + with pytest.raises(TypeError, match="unrecognized contribution of type str"): + # noinspection PyTypeChecker + ext.add("x") + + def test_add_contrib_point(self): + with pytest.raises( + TypeError, + match="item_type must be a class derived from chartlets.Contribution", + ): + # noinspection PyTypeChecker + Extension.add_contrib_point("panels", "Panel") + + with pytest.raises( + TypeError, + match="item_type must be a class derived from chartlets.Contribution, but was str", + ): + # noinspection PyTypeChecker + Extension.add_contrib_point("panels", str) diff --git a/chartlets.py/tests/extensioncontext_test.py b/chartlets.py/tests/extensioncontext_test.py new file mode 100644 index 00000000..946fa8cd --- /dev/null +++ b/chartlets.py/tests/extensioncontext_test.py @@ -0,0 +1,88 @@ +import unittest + +import pytest + +from chartlets import Contribution, Extension, ExtensionContext + + +class Panel(Contribution): + pass + + +class ToolbarItem(Contribution): + pass + + +test_ext = Extension("test", "0.1.4") + + +class ExtensionContextTest(unittest.TestCase): + def setUp(self): + Extension.reset_contrib_points() + + def tearDown(self): + Extension.reset_contrib_points() + + def test_attributes(self): + Extension.add_contrib_point("panels", Panel) + Extension.add_contrib_point("toolbarItems", ToolbarItem) + + ext0 = Extension("ext0") + ext0.add(Panel("panel0")) + ext0.add(ToolbarItem("tbi0")) + ext1 = Extension("ext1") + ext1.add(Panel("panel1")) + ext1.add(ToolbarItem("tbi1")) + + app_ctx = object() + ext_ctx = ExtensionContext(app_ctx, [ext0, ext1]) + + self.assertIs(app_ctx, ext_ctx.app_ctx) + self.assertIsInstance(ext_ctx.extensions, list) + self.assertEqual(2, len(ext_ctx.extensions)) + self.assertIsInstance(ext_ctx.contributions, dict) + self.assertEqual(2, len(ext_ctx.contributions)) + + +# noinspection PyMethodMayBeStatic +class ExtensionContextLoadTest(unittest.TestCase): + def setUp(self): + Extension.reset_contrib_points() + + def tearDown(self): + Extension.reset_contrib_points() + + def test_load_ok(self): + Extension.add_contrib_point("panels", Panel) + test_ext.add(Panel("panel0")) + test_ext.add(Panel("panel1")) + test_ext.add(Panel("panel2")) + + app_ctx = object() + ext_ctx = ExtensionContext.load(app_ctx, [f"{__name__}.test_ext"]) + + self.assertIs(app_ctx, ext_ctx.app_ctx) + self.assertEqual(1, len(ext_ctx.extensions)) + ext = ext_ctx.extensions[0] + self.assertIsInstance(ext, Extension) + self.assertEqual("test", ext.name) + self.assertEqual("0.1.4", ext.version) + panels = ext.get("panels") + self.assertIsInstance(panels, list) + self.assertEqual(3, len(panels)) + + def test_load_invalid_ext_ref(self): + app_ctx = object() + with pytest.raises(ValueError, match="contribution syntax error: 'test_ext'"): + ExtensionContext.load(app_ctx, [f"test_ext"]) + + def test_load_invalid_ext_type(self): + app_ctx = object() + with pytest.raises( + TypeError, + match=( + "extension reference 'tests.extensioncontext_test.ExtensionContextLoadTest'" + " is not referring to an instance of chartlets.Extension" + ), + ): + ExtensionContext.load(app_ctx, [f"{__name__}.ExtensionContextLoadTest"]) diff --git a/chartlets.py/tests/response_test.py b/chartlets.py/tests/response_test.py new file mode 100644 index 00000000..3f330609 --- /dev/null +++ b/chartlets.py/tests/response_test.py @@ -0,0 +1,22 @@ +import unittest + +from chartlets import Response + + +class ResponseTest(unittest.TestCase): + + def test_success(self): + response = Response.success([1, 2, 3]) + self.assertIsInstance(response, Response) + self.assertEqual(True, response.ok) + self.assertEqual([1, 2, 3], response.data) + self.assertEqual(200, response.status) + self.assertEqual(None, response.reason) + + def test_failed(self): + response = Response.failed(501, "what the heck") + self.assertIsInstance(response, Response) + self.assertEqual(False, response.ok) + self.assertEqual(None, response.data) + self.assertEqual(501, response.status) + self.assertEqual("what the heck", response.reason) diff --git a/chartlets.py/tests/util/__init__.py b/chartlets.py/tests/util/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/chartlets.py/tests/util/assertions_test.py b/chartlets.py/tests/util/assertions_test.py new file mode 100644 index 00000000..962f6d14 --- /dev/null +++ b/chartlets.py/tests/util/assertions_test.py @@ -0,0 +1,77 @@ +import unittest +from typing import Any + +import pytest + +from chartlets.util.assertions import assert_is_not_none +from chartlets.util.assertions import assert_is_not_empty +from chartlets.util.assertions import assert_is_one_of +from chartlets.util.assertions import assert_is_instance_of + + +# noinspection PyMethodMayBeStatic +class AssertIsNotNoneTest(unittest.TestCase): + + def test_ok(self): + assert_is_not_none("x", 0) + assert_is_not_none("x", "") + assert_is_not_none("x", []) + + def test_raises(self): + with pytest.raises(ValueError, match="value for 'x' must not be None"): + assert_is_not_none("x", None) + + +# noinspection PyMethodMayBeStatic +class AssertIsNotEmptyTest(unittest.TestCase): + + def test_ok(self): + assert_is_not_empty("x", "Hallo") + assert_is_not_empty("x", 0) + assert_is_not_empty("x", 1) + assert_is_not_empty("x", True) + assert_is_not_empty("x", False) + + def test_raises(self): + with pytest.raises(ValueError, match="value for 'x' must be given"): + assert_is_not_empty("x", None) + with pytest.raises(ValueError, match="value for 'x' must not be empty"): + assert_is_not_empty("x", "") + with pytest.raises(ValueError, match="value for 'x' must not be empty"): + assert_is_not_empty("x", []) + + +# noinspection PyMethodMayBeStatic +class AssertIsOneOfTest(unittest.TestCase): + + def test_ok(self): + assert_is_one_of("x", "a", ("a", 2, True)) + assert_is_one_of("x", 2, ("a", 2, True)) + assert_is_one_of("x", True, ("a", 2, True)) + + def test_raises(self): + with pytest.raises( + ValueError, + match="value of 'x' must be one of \\('a', 2, True\\), but was 'b'", + ): + assert_is_one_of("x", "b", ("a", 2, True)) + + +# noinspection PyMethodMayBeStatic +class AssertIsInstanceOfTest(unittest.TestCase): + + def test_ok(self): + assert_is_instance_of("x", "a", (int, str)) + assert_is_instance_of("x", 2, (int, str)) + assert_is_instance_of("x", True, bool) + + def test_raises(self): + with pytest.raises( + TypeError, match="value of 'x' must be of type str, but was int" + ): + assert_is_instance_of("x", 2, str) + + with pytest.raises( + TypeError, match="value of 'x' must be of type int or str, but was object" + ): + assert_is_instance_of("x", object(), (int, str)) diff --git a/chartlets.py/tests/util/logger_test.py b/chartlets.py/tests/util/logger_test.py new file mode 100644 index 00000000..ac3c04b1 --- /dev/null +++ b/chartlets.py/tests/util/logger_test.py @@ -0,0 +1,11 @@ +import logging +import unittest + +from chartlets.util.logger import LOGGER + + +class LoggerTest(unittest.TestCase): + + def test_logger(self): + self.assertIsInstance(LOGGER, logging.Logger) + self.assertEqual("chartlets", LOGGER.name) From c1edcbf6a43448eb93bfa5a5b1d8af0ea82f2b07 Mon Sep 17 00:00:00 2001 From: Norman Fomferra Date: Sun, 8 Dec 2024 15:57:24 +0100 Subject: [PATCH 2/9] Python test coverage now at 100% --- .../components/charts/vega_no_altair_test.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 chartlets.py/tests/components/charts/vega_no_altair_test.py diff --git a/chartlets.py/tests/components/charts/vega_no_altair_test.py b/chartlets.py/tests/components/charts/vega_no_altair_test.py new file mode 100644 index 00000000..fdaf9cb4 --- /dev/null +++ b/chartlets.py/tests/components/charts/vega_no_altair_test.py @@ -0,0 +1,31 @@ +import importlib +import pathlib +import sys + +import pytest + + +def test_no_altair(monkeypatch: pytest.MonkeyPatch): + """Test that the VegaChart component handles the absense + of "altair" gracefully. + """ + project_root = pathlib.Path(__file__).absolute().parent + while project_root.parent != project_root and not (project_root / "chartlets" / "__init__.py").exists(): + project_root = project_root.parent + + # Simulate the absence of the 'altair' module + print("project_root:", project_root) + monkeypatch.setattr(sys, "path", [f"{project_root}"]) + if "altair" in sys.modules: + monkeypatch.delitem(sys.modules,"altair") + + # Import the code that handles the missing "altair" package + importlib.invalidate_caches() + vega_module = importlib.import_module("chartlets.components.charts.vega") + importlib.reload(vega_module) + + # Assert "chartlets.components.charts.vega" handles the + # missing package appropriately by using an "altair" dummy. + altair = vega_module.altair + assert altair is not None + assert altair.Chart is int From 2caf28ed1d6361096353f4ec6f3d3777ac3b0316 Mon Sep 17 00:00:00 2001 From: Norman Fomferra Date: Sun, 8 Dec 2024 16:41:38 +0100 Subject: [PATCH 3/9] Improved robustness of controllers --- .../chartlets/controllers/_helpers.py | 44 +++++ .../chartlets/controllers/callback.py | 56 ++++-- chartlets.py/chartlets/controllers/layout.py | 28 +-- chartlets.py/chartlets/util/assertions.py | 3 +- .../components/charts/vega_no_altair_test.py | 7 +- .../tests/controllers/callback_test.py | 171 ++++++++++++++---- chartlets.py/tests/util/assertions_test.py | 5 + 7 files changed, 244 insertions(+), 70 deletions(-) create mode 100644 chartlets.py/chartlets/controllers/_helpers.py diff --git a/chartlets.py/chartlets/controllers/_helpers.py b/chartlets.py/chartlets/controllers/_helpers.py new file mode 100644 index 00000000..b2a6bbe7 --- /dev/null +++ b/chartlets.py/chartlets/controllers/_helpers.py @@ -0,0 +1,44 @@ +from chartlets import Contribution +from chartlets import ExtensionContext +from chartlets import Response +from chartlets.util.assertions import assert_is_not_empty +from chartlets.util.assertions import assert_is_instance_of + + +def get_contribution( + ext_ctx: ExtensionContext, + contrib_point_name: str, + contrib_index: int, +) -> tuple[Contribution, None] | tuple[None, Response]: + """Get the contribution for given `contrib_point_name` at `contrib_index`. + + Args: + ext_ctx: Extension context. + contrib_point_name: Contribution point name. + contrib_index: Contribution index. + Returns: + A pair comprising an optional `Contribution` and optional `Response` object. + """ + assert_is_instance_of("ext_ctx", ext_ctx, ExtensionContext) + assert_is_not_empty("contrib_point_name", contrib_point_name) + assert_is_instance_of("contrib_index", contrib_index, int) + + try: + contributions = ext_ctx.contributions[contrib_point_name] + except KeyError: + return None, Response.failed( + 404, f"contribution point {contrib_point_name!r} not found" + ) + + try: + contribution = contributions[contrib_index] + except IndexError: + return None, Response.failed( + 404, + ( + f"index range of contribution point {contrib_point_name!r} is" + f" 0 to {len(contributions) - 1}, got {contrib_index}" + ), + ) + + return contribution, None diff --git a/chartlets.py/chartlets/controllers/callback.py b/chartlets.py/chartlets/controllers/callback.py index 9a94ea33..67054631 100644 --- a/chartlets.py/chartlets/controllers/callback.py +++ b/chartlets.py/chartlets/controllers/callback.py @@ -4,6 +4,7 @@ from chartlets.response import Response from chartlets.util.assertions import assert_is_instance_of from chartlets.util.assertions import assert_is_not_none +from ._helpers import get_contribution # POST /chartlets/callback @@ -29,22 +30,42 @@ def get_callback_results( callback_requests: list[dict] = data.get("callbackRequests") or [] # TODO: assert correctness, set status code on error - state_change_requests: list[dict] = [] + state_change_requests: list[dict[str, Any]] = [] for callback_request in callback_requests: contrib_point_name: str = callback_request["contribPoint"] contrib_index: int = callback_request["contribIndex"] callback_index: int = callback_request["callbackIndex"] input_values: list = callback_request["inputValues"] - contributions = ext_ctx.contributions[contrib_point_name] - contribution = contributions[contrib_index] - callback = contribution.callbacks[callback_index] + contribution, response = get_contribution( + ext_ctx, contrib_point_name, contrib_index + ) + if response is not None: + return response + + callbacks = contribution.callbacks + if not callbacks: + return Response.failed( + 400, f"contribution {contribution.name!r} has no callbacks" + ) + + try: + callback = callbacks[callback_index] + except IndexError: + return Response.failed( + 404, + ( + f"index range of callbacks of contribution {contribution.name!r} is" + f" 0 to {len(callbacks) - 1}, got {callback_index}" + ), + ) + output_values = callback.invoke(ext_ctx.app_ctx, input_values) if len(callback.outputs) == 1: output_values = (output_values,) - state_changes: list[dict] = [] + state_changes: list[dict[str, Any]] = [] for output_index, output in enumerate(callback.outputs): output_value = output_values[output_index] state_changes.append( @@ -59,12 +80,23 @@ def get_callback_results( } ) - state_change_requests.append( - { - "contribPoint": contrib_point_name, - "contribIndex": contrib_index, - "stateChanges": state_changes, - } - ) + existing_scr: dict[str, Any] | None = None + for scr in state_change_requests: + if ( + scr["contribPoint"] == contrib_point_name + and scr["contribIndex"] == contrib_index + ): + existing_scr = scr + break + if existing_scr is not None: + existing_scr["stateChanges"].extend(state_changes) + else: + state_change_requests.append( + { + "contribPoint": contrib_point_name, + "contribIndex": contrib_index, + "stateChanges": state_changes, + } + ) return Response.success(state_change_requests) diff --git a/chartlets.py/chartlets/controllers/layout.py b/chartlets.py/chartlets/controllers/layout.py index 778901e2..2f7f8e5e 100644 --- a/chartlets.py/chartlets/controllers/layout.py +++ b/chartlets.py/chartlets/controllers/layout.py @@ -2,9 +2,8 @@ from chartlets.extensioncontext import ExtensionContext from chartlets.response import Response -from chartlets.util.assertions import assert_is_not_none -from chartlets.util.assertions import assert_is_not_empty from chartlets.util.assertions import assert_is_instance_of +from ._helpers import get_contribution def get_layout( @@ -28,31 +27,16 @@ def get_layout( On success, the response is a dictionary that represents a JSON-serialized component tree. """ - assert_is_not_none("ext_ctx", ext_ctx) - assert_is_not_empty("contrib_point_name", contrib_point_name) - assert_is_instance_of("contrib_index", contrib_index, int) assert_is_instance_of("data", data, dict) # TODO: validate data input_values = data.get("inputValues") or [] - try: - contributions = ext_ctx.contributions[contrib_point_name] - except KeyError: - return Response.failed( - 404, f"contribution point {contrib_point_name!r} not found" - ) - - try: - contribution = contributions[contrib_index] - except IndexError: - return Response.failed( - 404, - ( - f"index range of contribution point {contrib_point_name!r} is" - f" 0 to {len(contributions) - 1}, got {contrib_index}" - ), - ) + contribution, response = get_contribution( + ext_ctx, contrib_point_name, contrib_index + ) + if response is not None: + return response callback = contribution.layout_callback if callback is None: diff --git a/chartlets.py/chartlets/util/assertions.py b/chartlets.py/chartlets/util/assertions.py index 2414fbfa..5018f1c9 100644 --- a/chartlets.py/chartlets/util/assertions.py +++ b/chartlets.py/chartlets/util/assertions.py @@ -30,5 +30,6 @@ def assert_is_instance_of(name: str, value: Any, type_set: Type | tuple[Type, .. type_set = (type_set,) raise TypeError( f"value of {name!r} must be of type" - f" {" or ".join(map(lambda t: t.__name__, type_set))}, but was {type(value).__name__}" + f" {" or ".join(map(lambda t: t.__name__, type_set))}," + f" but was {'None' if value is None else type(value).__name__}" ) diff --git a/chartlets.py/tests/components/charts/vega_no_altair_test.py b/chartlets.py/tests/components/charts/vega_no_altair_test.py index fdaf9cb4..63f3d9f6 100644 --- a/chartlets.py/tests/components/charts/vega_no_altair_test.py +++ b/chartlets.py/tests/components/charts/vega_no_altair_test.py @@ -10,14 +10,17 @@ def test_no_altair(monkeypatch: pytest.MonkeyPatch): of "altair" gracefully. """ project_root = pathlib.Path(__file__).absolute().parent - while project_root.parent != project_root and not (project_root / "chartlets" / "__init__.py").exists(): + while ( + project_root.parent != project_root + and not (project_root / "chartlets" / "__init__.py").exists() + ): project_root = project_root.parent # Simulate the absence of the 'altair' module print("project_root:", project_root) monkeypatch.setattr(sys, "path", [f"{project_root}"]) if "altair" in sys.modules: - monkeypatch.delitem(sys.modules,"altair") + monkeypatch.delitem(sys.modules, "altair") # Import the code that handles the missing "altair" package importlib.invalidate_caches() diff --git a/chartlets.py/tests/controllers/callback_test.py b/chartlets.py/tests/controllers/callback_test.py index fe58a805..8b0093ad 100644 --- a/chartlets.py/tests/controllers/callback_test.py +++ b/chartlets.py/tests/controllers/callback_test.py @@ -17,14 +17,19 @@ class Panel(Contribution): @panel.layout(State("@app", "selected")) def render_layout(ctx, selected): - return Checkbox("x", value=selected) + return Checkbox("sel", value=selected) -@panel.callback(Input("@app", "selected"), Output("x")) +@panel.callback(Input("@app", "selected"), Output("sel")) def adjust_selection(ctx, selected): return selected +@panel.callback(Input("@app", "num_items"), Output("sel", "disabled")) +def disable_selector(ctx, num_items): + return num_items == 0 + + panel_wo_callback = Panel("my_panel_wo_callback") ext = Extension("my_ext") @@ -42,7 +47,38 @@ def test_success_empty(self): self.assertEqual(200, response.status) self.assertEqual([], response.data) - def test_success_non_empty(self): + def test_success_for_1_request(self): + app_ctx = object() + ext_ctx = ExtensionContext(app_ctx, [ext]) + response = get_callback_results( + ext_ctx, + { + "callbackRequests": [ + { + "contribPoint": "panels", + "contribIndex": 0, + "callbackIndex": 1, + "inputValues": [0], + } + ] + }, + ) + self.assertIsInstance(response, Response) + self.assertEqual(200, response.status) + self.assertEqual( + [ + { + "contribPoint": "panels", + "contribIndex": 0, + "stateChanges": [ + {"id": "sel", "property": "disabled", "value": True} + ], + }, + ], + response.data, + ) + + def test_success_for_2_requests(self): app_ctx = object() ext_ctx = ExtensionContext(app_ctx, [ext]) response = get_callback_results( @@ -54,7 +90,13 @@ def test_success_non_empty(self): "contribIndex": 0, "callbackIndex": 0, "inputValues": [True], - } + }, + { + "contribPoint": "panels", + "contribIndex": 0, + "callbackIndex": 1, + "inputValues": [5], + }, ] }, ) @@ -65,36 +107,99 @@ def test_success_non_empty(self): { "contribPoint": "panels", "contribIndex": 0, - "stateChanges": [{"id": "x", "property": "value", "value": True}], - } + "stateChanges": [ + {"id": "sel", "property": "value", "value": True}, + {"id": "sel", "property": "disabled", "value": False}, + ], + }, ], response.data, ) - # def test_invalid_contrib_point(self): - # app_ctx = object() - # ext_ctx = ExtensionContext(app_ctx, [ext]) - # response = get_layout(ext_ctx, "menus", 1, {"inputValues": [True]}) - # self.assertIsInstance(response, Response) - # self.assertEqual(404, response.status) - # self.assertEqual("contribution point 'menus' not found", response.reason) - # - # def test_invalid_contrib_index(self): - # app_ctx = object() - # ext_ctx = ExtensionContext(app_ctx, [ext]) - # response = get_layout(ext_ctx, "panels", 15, {"inputValues": [True]}) - # self.assertIsInstance(response, Response) - # self.assertEqual(404, response.status) - # self.assertEqual( - # "index range of contribution point 'panels' is 0 to 1, got 15", response.reason - # ) - # - # def test_no_layout(self): - # app_ctx = object() - # ext_ctx = ExtensionContext(app_ctx, [ext]) - # response = get_layout(ext_ctx, "panels", 1, {"inputValues": [True]}) - # self.assertIsInstance(response, Response) - # self.assertEqual(400, response.status) - # self.assertEqual( - # "contribution 'my_panel_wo_layout' has no layout", response.reason - # ) + def test_invalid_contrib_point(self): + app_ctx = object() + ext_ctx = ExtensionContext(app_ctx, [ext]) + response = get_callback_results( + ext_ctx, + { + "callbackRequests": [ + { + "contribPoint": "tools", + "contribIndex": 0, + "callbackIndex": 0, + "inputValues": [True], + } + ] + }, + ) + self.assertIsInstance(response, Response) + self.assertEqual(404, response.status) + self.assertEqual("contribution point 'tools' not found", response.reason) + + def test_invalid_contrib_index(self): + app_ctx = object() + ext_ctx = ExtensionContext(app_ctx, [ext]) + response = get_callback_results( + ext_ctx, + { + "callbackRequests": [ + { + "contribPoint": "panels", + "contribIndex": 9, + "callbackIndex": 0, + "inputValues": [True], + } + ] + }, + ) + self.assertIsInstance(response, Response) + self.assertEqual(404, response.status) + self.assertEqual( + "index range of contribution point 'panels' is 0 to 1, got 9", + response.reason, + ) + + def test_invalid_callback_index(self): + app_ctx = object() + ext_ctx = ExtensionContext(app_ctx, [ext]) + response = get_callback_results( + ext_ctx, + { + "callbackRequests": [ + { + "contribPoint": "panels", + "contribIndex": 0, + "callbackIndex": 7, + "inputValues": [True], + } + ] + }, + ) + self.assertIsInstance(response, Response) + self.assertEqual(404, response.status) + self.assertEqual( + "index range of callbacks of contribution 'my_panel' is 0 to 1, got 7", + response.reason, + ) + + def test_no_callback(self): + app_ctx = object() + ext_ctx = ExtensionContext(app_ctx, [ext]) + response = get_callback_results( + ext_ctx, + { + "callbackRequests": [ + { + "contribPoint": "panels", + "contribIndex": 1, + "callbackIndex": 0, + "inputValues": [True], + } + ] + }, + ) + self.assertIsInstance(response, Response) + self.assertEqual(400, response.status) + self.assertEqual( + "contribution 'my_panel_wo_callback' has no callbacks", response.reason + ) diff --git a/chartlets.py/tests/util/assertions_test.py b/chartlets.py/tests/util/assertions_test.py index 962f6d14..aa59f821 100644 --- a/chartlets.py/tests/util/assertions_test.py +++ b/chartlets.py/tests/util/assertions_test.py @@ -75,3 +75,8 @@ def test_raises(self): TypeError, match="value of 'x' must be of type int or str, but was object" ): assert_is_instance_of("x", object(), (int, str)) + + with pytest.raises( + TypeError, match="value of 'x' must be of type str, but was None" + ): + assert_is_instance_of("x", None, str) From 7bde7df4927fb9d1519a2f63124f9116188d5ded Mon Sep 17 00:00:00 2001 From: Norman Fomferra Date: Sun, 8 Dec 2024 16:55:28 +0100 Subject: [PATCH 4/9] Fixed ext_ctx annotations and api docs --- chartlets.py/chartlets/controllers/callback.py | 14 +++++++------- .../chartlets/controllers/contributions.py | 9 ++++----- chartlets.py/chartlets/controllers/layout.py | 6 +++--- 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/chartlets.py/chartlets/controllers/callback.py b/chartlets.py/chartlets/controllers/callback.py index 67054631..6f924039 100644 --- a/chartlets.py/chartlets/controllers/callback.py +++ b/chartlets.py/chartlets/controllers/callback.py @@ -3,19 +3,16 @@ from chartlets.extensioncontext import ExtensionContext from chartlets.response import Response from chartlets.util.assertions import assert_is_instance_of -from chartlets.util.assertions import assert_is_not_none from ._helpers import get_contribution -# POST /chartlets/callback def get_callback_results( - ext_ctx: ExtensionContext | None, data: dict[str, Any] + ext_ctx: ExtensionContext, data: dict[str, Any] ) -> Response: """Generate the response for the endpoint `POST /chartlets/callback`. Args: - ext_ctx: Extension context. If `None`, - the function returns a 404 error response. + ext_ctx: Extension context. data: A dictionary deserialized from a request JSON body that should contain a key `callbackRequests` of type `list`. Returns: @@ -23,13 +20,12 @@ def get_callback_results( On success, the response is a list of state-change requests grouped by contributions. """ - assert_is_not_none("ext_ctx", ext_ctx) + assert_is_instance_of("ext_ctx", ext_ctx, ExtensionContext) assert_is_instance_of("data", data, dict) # TODO: validate data callback_requests: list[dict] = data.get("callbackRequests") or [] - # TODO: assert correctness, set status code on error state_change_requests: list[dict[str, Any]] = [] for callback_request in callback_requests: contrib_point_name: str = callback_request["contribPoint"] @@ -80,6 +76,7 @@ def get_callback_results( } ) + # find an existing state change request existing_scr: dict[str, Any] | None = None for scr in state_change_requests: if ( @@ -88,9 +85,12 @@ def get_callback_results( ): existing_scr = scr break + if existing_scr is not None: + # merge with existing state change request existing_scr["stateChanges"].extend(state_changes) else: + # append new state change request state_change_requests.append( { "contribPoint": contrib_point_name, diff --git a/chartlets.py/chartlets/controllers/contributions.py b/chartlets.py/chartlets/controllers/contributions.py index 04b1ca7f..e18a3f33 100644 --- a/chartlets.py/chartlets/controllers/contributions.py +++ b/chartlets.py/chartlets/controllers/contributions.py @@ -1,20 +1,19 @@ from chartlets.extensioncontext import ExtensionContext from chartlets.response import Response -from chartlets.util.assertions import assert_is_not_none +from chartlets.util.assertions import assert_is_instance_of -def get_contributions(ext_ctx: ExtensionContext | None) -> Response: +def get_contributions(ext_ctx: ExtensionContext) -> Response: """Generate the response for the endpoint `GET /chartlets/contributions`. Args: - ext_ctx: Extension context. If `None`, - the function returns a 404 error response. + ext_ctx: Extension context. Returns: A `Response` object. On success, the response is a dictionary that represents a JSON-serialized component tree. """ - assert_is_not_none("ext_ctx", ext_ctx) + assert_is_instance_of("ext_ctx", ext_ctx, ExtensionContext) extensions = ext_ctx.extensions contributions = ext_ctx.contributions return Response.success( diff --git a/chartlets.py/chartlets/controllers/layout.py b/chartlets.py/chartlets/controllers/layout.py index 2f7f8e5e..21072b0c 100644 --- a/chartlets.py/chartlets/controllers/layout.py +++ b/chartlets.py/chartlets/controllers/layout.py @@ -7,7 +7,7 @@ def get_layout( - ext_ctx: ExtensionContext | None, + ext_ctx: ExtensionContext, contrib_point_name: str, contrib_index: int, data: dict[str, Any], @@ -16,8 +16,7 @@ def get_layout( `POST /chartlets/layout/{contrib_point_name}/{contrib_index}`. Args: - ext_ctx: Extension context. If `None`, - the function returns a 404 error response. + ext_ctx: Extension context. contrib_point_name: Contribution point name. contrib_index: Contribution index. data: A dictionary deserialized from a request JSON body @@ -27,6 +26,7 @@ def get_layout( On success, the response is a dictionary that represents a JSON-serialized component tree. """ + assert_is_instance_of("ext_ctx", ext_ctx, ExtensionContext) assert_is_instance_of("data", data, dict) # TODO: validate data From cafba0ce2f562b2c55e7bf5d5ca7103de6da0ccd Mon Sep 17 00:00:00 2001 From: Norman Fomferra Date: Mon, 9 Dec 2024 12:21:53 +0100 Subject: [PATCH 5/9] added React hooks tests; removed unused makeContributionsHook() --- chartlets.js/package-lock.json | 241 +++++++++++++++++++- chartlets.js/packages/lib/package.json | 6 +- chartlets.js/packages/lib/src/hooks.test.ts | 101 ++++++++ chartlets.js/packages/lib/src/hooks.ts | 34 +-- chartlets.js/packages/lib/src/index.ts | 1 - chartlets.js/packages/lib/vite.config.ts | 8 +- chartlets.js/packages/lib/vitest.setup.ts | 1 + 7 files changed, 368 insertions(+), 24 deletions(-) create mode 100644 chartlets.js/packages/lib/src/hooks.test.ts create mode 100644 chartlets.js/packages/lib/vitest.setup.ts diff --git a/chartlets.js/package-lock.json b/chartlets.js/package-lock.json index 8e5532c5..5d98633f 100644 --- a/chartlets.js/package-lock.json +++ b/chartlets.js/package-lock.json @@ -8,6 +8,13 @@ "packages/*" ] }, + "node_modules/@adobe/css-tools": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.1.tgz", + "integrity": "sha512-12WGKBQzjUAI4ayyF4IAtfw2QR/IDoqk6jTddXDhtYTJF9ASmoE1zst7cVtP0aL/F1jUJL5r+JxKXKEgHNbEUQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -2079,6 +2086,97 @@ "@swc/counter": "^0.1.3" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", + "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.6.3.tgz", + "integrity": "sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "chalk": "^3.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "lodash": "^4.17.21", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.1.0", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.1.0.tgz", + "integrity": "sha512-Q2ToPvg0KsVL0ohND9A3zLJWcOXXcO8IDu3fj11KhNt0UlCWyFyvnCIBkd12tidB2lkiVRG8VFqdhcqhqnAQtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@types/argparse": { "version": "1.0.38", "resolved": "https://registry.npmjs.org/@types/argparse/-/argparse-1.0.38.tgz", @@ -2086,6 +2184,14 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -2742,6 +2848,16 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/array-union": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", @@ -3055,6 +3171,13 @@ "node": ">= 8" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssstyle": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.1.0.tgz", @@ -3411,6 +3534,16 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -3437,6 +3570,14 @@ "node": ">=6.0.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -4264,6 +4405,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -4685,6 +4836,17 @@ "node": "20 || >=22" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.14", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.14.tgz", @@ -4776,6 +4938,16 @@ "node": ">= 0.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -5198,6 +5370,44 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -5310,6 +5520,20 @@ "vega-lite": "*" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/regenerator-runtime": { "version": "0.14.1", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", @@ -5759,6 +5983,19 @@ "node": ">=8" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -7191,6 +7428,8 @@ "zustand": "^5.0" }, "devDependencies": { + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.1.0", "@types/node": "^20.11.17", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.1", @@ -7207,7 +7446,7 @@ "typescript": "^5.6.2", "vite": "^5.4.6", "vite-plugin-dts": "^4.2.4", - "vitest": "^2.1.1" + "vitest": "^2.1.8" }, "peerDependencies": { "@mui/material": ">=6", diff --git a/chartlets.js/packages/lib/package.json b/chartlets.js/packages/lib/package.json index a200c31c..7f037724 100644 --- a/chartlets.js/packages/lib/package.json +++ b/chartlets.js/packages/lib/package.json @@ -59,9 +59,9 @@ "zustand": "^5.0" }, "peerDependencies": { + "@mui/material": ">=6", "react": ">=18", "react-dom": ">=18", - "@mui/material": ">=6", "react-vega": ">=7", "vega-themes": ">=2" }, @@ -74,6 +74,8 @@ } }, "devDependencies": { + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.1.0", "@types/node": "^20.11.17", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.1", @@ -90,6 +92,6 @@ "typescript": "^5.6.2", "vite": "^5.4.6", "vite-plugin-dts": "^4.2.4", - "vitest": "^2.1.1" + "vitest": "^2.1.8" } } diff --git a/chartlets.js/packages/lib/src/hooks.test.ts b/chartlets.js/packages/lib/src/hooks.test.ts new file mode 100644 index 00000000..bfac0693 --- /dev/null +++ b/chartlets.js/packages/lib/src/hooks.test.ts @@ -0,0 +1,101 @@ +import { renderHook, act } from "@testing-library/react"; +import { + useConfiguration, + useExtensions, + useContributionsResult, + useContributionsRecord, + useThemeMode, + useContributions, + useComponentChangeHandlers, +} from "./hooks"; + +describe("useConfiguration", () => { + it("should initially return a stable object", () => { + const { result, rerender } = renderHook(() => useConfiguration()); + const c1 = result.current; + rerender(); + const c2 = result.current; + expect(c1).toEqual({}); + expect(c2).toEqual({}); + expect(c1).toBe(c2); + }); +}); + +describe("useExtensions", () => { + it("should initially return a stable array", () => { + const { result, rerender } = renderHook(() => useExtensions()); + const e1 = result.current; + rerender(); + const e2 = result.current; + expect(e1).toEqual([]); + expect(e2).toEqual([]); + expect(e1).toBe(e2); + }); +}); + +describe("useContributionsResult", () => { + it("should initially return a stable object", () => { + const { result, rerender } = renderHook(() => useContributionsResult()); + const cr1 = result.current; + rerender(); + const cr2 = result.current; + expect(cr1).toEqual({}); + expect(cr2).toEqual({}); + expect(cr1).toBe(cr2); + }); +}); + +describe("useContributionsRecord", () => { + it("should initially return a stable object", () => { + const { result, rerender } = renderHook(() => useContributionsRecord()); + const cr1 = result.current; + rerender(); + const cr2 = result.current; + expect(cr1).toEqual({}); + expect(cr2).toEqual({}); + expect(cr1).toBe(cr2); + }); +}); + +describe("useThemeMode", () => { + it("should initially return undefined", () => { + const { result } = renderHook(() => useThemeMode()); + expect(result.current).toBeUndefined; + }); +}); + +describe("useContributions", () => { + it("should initially return a stable empty array for empty contributions", () => { + const { result, rerender } = renderHook(() => useContributions("panels")); + const c1 = result.current; + rerender(); + const c2 = result.current; + expect(c1).toEqual([]); + expect(c2).toEqual([]); + expect(c1).toBe(c2); + }); +}); + +describe("useComponentChangeHandlers", () => { + it("should return an array", () => { + const { result } = renderHook(() => + useComponentChangeHandlers("panels", 3), + ); + expect(Array.isArray(result.current)).toBe(true); + expect(result.current.length).toBe(3); + }); + + it("should return an array of callable handlers", () => { + const { result } = renderHook(() => + useComponentChangeHandlers("panels", 3), + ); + act(() => + result.current[0]({ + componentType: "button", + id: "btn", + property: "clicked", + value: true, + }), + ); + }); +}); diff --git a/chartlets.js/packages/lib/src/hooks.ts b/chartlets.js/packages/lib/src/hooks.ts index d4096ed9..43882e14 100644 --- a/chartlets.js/packages/lib/src/hooks.ts +++ b/chartlets.js/packages/lib/src/hooks.ts @@ -1,6 +1,6 @@ import type { StoreState } from "@/types/state/store"; import { store } from "@/store"; -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; import type { ContributionState } from "@/types/state/contribution"; import type { ComponentChangeEvent, @@ -28,31 +28,29 @@ export const useContributionsResult = () => useStore(selectContributionsResult); export const useContributionsRecord = () => useStore(selectContributionsRecord); export const useThemeMode = () => useStore(selectThemeMode); -export function makeContributionsHook( - contribPoint: string, -): () => ContributionState[] { - return () => { - const selectContributions = (state: StoreState) => - state.contributionsRecord[contribPoint]; - const contributions = useStore(selectContributions); - return useMemo(() => { - return (contributions || []) as ContributionState[]; - }, [contributions]); - }; -} - /** * A hook that retrieves the contributions for the given contribution * point given by `contribPoint`. * + * A stable empty array is returned if there are no contributions or + * the contribution point does not exist. + * * @param contribPoint Contribution point name. * @typeParam S Type of the container state. + * @returns Array of contributions. */ export function useContributions( contribPoint: string, ): ContributionState[] { - const contributionsRecord = useContributionsRecord(); - return contributionsRecord[contribPoint] as ContributionState[]; + const selectContributions = useCallback( + (state: StoreState) => state.contributionsRecord[contribPoint], + [contribPoint], + ); + const contributions = useStore(selectContributions); + return useMemo( + () => (contributions || []) as ContributionState[], + [contributions], + ); } /** @@ -60,9 +58,13 @@ export function useContributions( * component change handlers of type `ComponentChangeHandler` for * the contribution point given by `contribPoint`. * + * A stable empty array is returned if there are no contributions or + * the contribution point does not exist. + * * @param contribPoint Contribution point name. * @param numContribs Number of contributions. This should be the length * of the array of contributions you get using the `useContributions` hook. + * @returns Array of component change handlers */ export function useComponentChangeHandlers( contribPoint: string, diff --git a/chartlets.js/packages/lib/src/index.ts b/chartlets.js/packages/lib/src/index.ts index ed8a851f..d8ed9d02 100644 --- a/chartlets.js/packages/lib/src/index.ts +++ b/chartlets.js/packages/lib/src/index.ts @@ -28,7 +28,6 @@ export { useContributionsRecord, useContributions, useComponentChangeHandlers, - makeContributionsHook, useThemeMode, } from "@/hooks"; diff --git a/chartlets.js/packages/lib/vite.config.ts b/chartlets.js/packages/lib/vite.config.ts index 5f04e79b..f24a3ee7 100644 --- a/chartlets.js/packages/lib/vite.config.ts +++ b/chartlets.js/packages/lib/vite.config.ts @@ -59,13 +59,13 @@ export default defineConfig({ }, }, test: { + globals: true, environment: "jsdom", - onConsoleLog: (/*_log: string, _type: "stdout" | "stderr"*/): - | false - | void => { + setupFiles: "vitest.setup.ts", + onConsoleLog: (_log: string, _type: "stdout" | "stderr"): false | void => { const logLevel = process.env.VITE_LOG_LEVEL; if (!logLevel || logLevel === "OFF") { - return false; + //return false; } }, }, diff --git a/chartlets.js/packages/lib/vitest.setup.ts b/chartlets.js/packages/lib/vitest.setup.ts new file mode 100644 index 00000000..d0de870d --- /dev/null +++ b/chartlets.js/packages/lib/vitest.setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom"; From 2023dc5025734e74db8e9ffeac3e1ba4f2ab7731 Mon Sep 17 00:00:00 2001 From: Norman Fomferra Date: Mon, 9 Dec 2024 12:24:18 +0100 Subject: [PATCH 6/9] added import to make WebStorm happy --- chartlets.js/packages/lib/src/hooks.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/chartlets.js/packages/lib/src/hooks.test.ts b/chartlets.js/packages/lib/src/hooks.test.ts index bfac0693..d12cf694 100644 --- a/chartlets.js/packages/lib/src/hooks.test.ts +++ b/chartlets.js/packages/lib/src/hooks.test.ts @@ -1,3 +1,4 @@ +import { describe, it, expect } from "vitest"; import { renderHook, act } from "@testing-library/react"; import { useConfiguration, From 797b4b1d25cf2d49c034e1a58e7df696f2f21842 Mon Sep 17 00:00:00 2001 From: Norman Fomferra Date: Mon, 9 Dec 2024 12:52:19 +0100 Subject: [PATCH 7/9] added first component tests --- .../packages/lib/src/plugins/mui/Box.test.tsx | 14 ++++++++ .../lib/src/plugins/mui/Button.test.tsx | 35 +++++++++++++++++++ .../lib/src/plugins/vega/VegaChart.test.tsx | 14 ++++++++ 3 files changed, 63 insertions(+) create mode 100644 chartlets.js/packages/lib/src/plugins/mui/Box.test.tsx create mode 100644 chartlets.js/packages/lib/src/plugins/mui/Button.test.tsx create mode 100644 chartlets.js/packages/lib/src/plugins/vega/VegaChart.test.tsx diff --git a/chartlets.js/packages/lib/src/plugins/mui/Box.test.tsx b/chartlets.js/packages/lib/src/plugins/mui/Box.test.tsx new file mode 100644 index 00000000..7f726b3f --- /dev/null +++ b/chartlets.js/packages/lib/src/plugins/mui/Box.test.tsx @@ -0,0 +1,14 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { Box } from "./Box"; +import type { ComponentChangeHandler } from "@/types/state/event"; + +describe("Box", () => { + it("should render the Box component", () => { + const onChange: ComponentChangeHandler = () => {}; + render( + , + ); + expect(screen.getByText("Hallo!")).not.toBeUndefined(); + }); +}); diff --git a/chartlets.js/packages/lib/src/plugins/mui/Button.test.tsx b/chartlets.js/packages/lib/src/plugins/mui/Button.test.tsx new file mode 100644 index 00000000..722d256b --- /dev/null +++ b/chartlets.js/packages/lib/src/plugins/mui/Button.test.tsx @@ -0,0 +1,35 @@ +import { describe, it, expect } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import type { + ComponentChangeEvent, + ComponentChangeHandler, +} from "@/types/state/event"; +import { Button } from "./Button"; + +describe("Button", () => { + it("should render the Button component", () => { + const onChange: ComponentChangeHandler = () => {}; + render( +