Przeglądaj źródła

Merge branch 'develop' into test/Metric-component

namnguyen 10 miesięcy temu
rodzic
commit
25b8467558

+ 26 - 11
frontend/taipy/src/ScenarioDag.tsx

@@ -1,5 +1,5 @@
-import React, { useCallback, useEffect, useMemo, useState } from "react";
-import { Point } from '@projectstorm/geometry';
+import React, { useCallback, useEffect, useMemo, useState, useRef } from "react";
+import { Point } from "@projectstorm/geometry";
 import { CanvasWidget } from "@projectstorm/react-canvas-core";
 import Box from "@mui/material/Box";
 import AppBar from "@mui/material/AppBar";
@@ -76,6 +76,8 @@ const getValidScenario = (scenar: DisplayModel | DisplayModel[]) =>
         ? (scenar[0] as DisplayModel)
         : undefined;
 
+const preventWheel = (e: Event) => e.preventDefault();
+
 const ScenarioDag = (props: ScenarioDagProps) => {
     const { showToolbar = true, onSelect, onAction } = props;
     const [scenarioId, setScenarioId] = useState("");
@@ -85,6 +87,7 @@ const ScenarioDag = (props: ScenarioDagProps) => {
     const [taskStatuses, setTaskStatuses] = useState<TaskStatuses>();
     const dispatch = useDispatch();
     const module = useModule();
+    const canvasRef = useRef<CanvasWidget>(null);
 
     const render = useDynamicProperty(props.render, props.defaultRender, true);
     const className = useClassNames(props.libClassName, props.dynamicClassName, props.className);
@@ -144,16 +147,18 @@ const ScenarioDag = (props: ScenarioDagProps) => {
             // populate model
             doLayout = populateModel(addStatusToDisplayModel(displayModel, taskStatuses), model);
         }
-        const rects = engine.getModel() && engine
-            .getModel()
-            .getNodes()
-            .reduce((pv, nm) => {
-                pv[nm.getID()] = nm.getPosition();
-                return pv;
-            }, {} as Record<string, Point>);
+        const rects =
+            engine.getModel() &&
+            engine
+                .getModel()
+                .getNodes()
+                .reduce((pv, nm) => {
+                    pv[nm.getID()] = nm.getPosition();
+                    return pv;
+                }, {} as Record<string, Point>);
         const hasPos = rects && Object.keys(rects).length;
         if (hasPos) {
-            model.getNodes().forEach(nm => rects[nm.getID()] && nm.setPosition(rects[nm.getID()]));
+            model.getNodes().forEach((nm) => rects[nm.getID()] && nm.setPosition(rects[nm.getID()]));
         }
         engine.setModel(model);
         model.setLocked(true);
@@ -165,10 +170,20 @@ const ScenarioDag = (props: ScenarioDagProps) => {
         showVar && dispatch(createSendUpdateAction(showVar, render, module));
     }, [render, props.updateVars, dispatch, module]);
 
+    useEffect(() => {
+        setTimeout(() => {
+            // wait for div to be referenced and then set a non passive listener on wheel event
+            canvasRef.current?.ref.current?.addEventListener("wheel", preventWheel, { passive: false });
+        }, 300);
+        // remove the listener
+        // eslint-disable-next-line react-hooks/exhaustive-deps
+        return () => canvasRef.current?.ref.current?.removeEventListener("wheel", preventWheel);
+    }, []);
+
     return render && scenarioId ? (
         <Paper sx={sizeSx} id={props.id} className={className}>
             {showToolbar ? <DagTitle zoomToFit={zoomToFit} /> : null}
-            <CanvasWidget engine={engine} />
+            <CanvasWidget engine={engine} ref={canvasRef} />
         </Paper>
     ) : null;
 };

+ 31 - 4
taipy/core/scenario/_scenario_manager.py

@@ -9,7 +9,7 @@
 # an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
 # specific language governing permissions and limitations under the License.
 
-import datetime
+from datetime import datetime
 from functools import partial
 from typing import Any, Callable, Dict, List, Literal, Optional, Union
 
@@ -122,7 +122,7 @@ class _ScenarioManager(_Manager[Scenario], _VersionMixin):
     def _create(
         cls,
         config: ScenarioConfig,
-        creation_date: Optional[datetime.datetime] = None,
+        creation_date: Optional[datetime] = None,
         name: Optional[str] = None,
     ) -> Scenario:
         _task_manager = _TaskManagerFactory._build_manager()
@@ -288,9 +288,8 @@ class _ScenarioManager(_Manager[Scenario], _VersionMixin):
     def _get_primary_scenarios(cls) -> List[Scenario]:
         return [scenario for scenario in cls._get_all() if scenario.is_primary]
 
-    @classmethod
+    @staticmethod
     def _sort_scenarios(
-        cls,
         scenarios: List[Scenario],
         descending: bool = False,
         sort_key: Literal["name", "id", "config_id", "creation_date", "tags"] = "name",
@@ -306,6 +305,34 @@ class _ScenarioManager(_Manager[Scenario], _VersionMixin):
             scenarios.sort(key=lambda x: (x.name, x.id), reverse=descending)
         return scenarios
 
+    @staticmethod
+    def _filter_by_creation_time(
+        scenarios: List[Scenario],
+        created_start_time: Optional[datetime] = None,
+        created_end_time: Optional[datetime] = None,
+    ) -> List[Scenario]:
+        """
+        Filter a list of scenarios by a given creation time period.
+        The time period is inclusive.
+
+        Parameters:
+            created_start_time (Optional[datetime]): Start time of the period.
+            created_end_time (Optional[datetime]): End time of the period.
+
+        Returns:
+            List[Scenario]: List of scenarios created in the given time period.
+        """
+        if not created_start_time and not created_end_time:
+            return scenarios
+
+        if not created_start_time:
+            return [scenario for scenario in scenarios if scenario.creation_date <= created_end_time]
+
+        if not created_end_time:
+            return [scenario for scenario in scenarios if created_start_time <= scenario.creation_date]
+
+        return [scenario for scenario in scenarios if created_start_time <= scenario.creation_date <= created_end_time]
+
     @classmethod
     def _is_promotable_to_primary(cls, scenario: Union[Scenario, ScenarioId]) -> bool:
         if isinstance(scenario, str):

+ 13 - 0
taipy/core/taipy.py

@@ -510,6 +510,8 @@ def get_scenarios(
     tag: Optional[str] = None,
     is_sorted: bool = False,
     descending: bool = False,
+    created_start_time: Optional[datetime] = None,
+    created_end_time: Optional[datetime] = None,
     sort_key: Literal["name", "id", "config_id", "creation_date", "tags"] = "name",
 ) -> List[Scenario]:
     """Retrieve a list of existing scenarios filtered by cycle or tag.
@@ -526,6 +528,8 @@ def get_scenarios(
             The default value is False.
         descending (bool): If True, sort the output list of scenarios in descending order.
             The default value is False.
+        created_start_time (Optional[datetime]): The optional inclusive start date to filter scenarios by creation date.
+        created_end_time (Optional[datetime]): The optional inclusive end date to filter scenarios by creation date.
         sort_key (Literal["name", "id", "creation_date", "tags"]): The optional sort_key to
             decide upon what key scenarios are sorted. The sorting is in increasing order for
             dates, in alphabetical order for name and id, and in lexicographical order for tags.
@@ -548,6 +552,8 @@ def get_scenarios(
     else:
         scenarios = []
 
+    if created_start_time or created_end_time:
+        scenarios = scenario_manager._filter_by_creation_time(scenarios, created_start_time, created_end_time)
     if is_sorted:
         scenario_manager._sort_scenarios(scenarios, descending, sort_key)
     return scenarios
@@ -569,6 +575,8 @@ def get_primary(cycle: Cycle) -> Optional[Scenario]:
 def get_primary_scenarios(
     is_sorted: bool = False,
     descending: bool = False,
+    created_start_time: Optional[datetime] = None,
+    created_end_time: Optional[datetime] = None,
     sort_key: Literal["name", "id", "config_id", "creation_date", "tags"] = "name",
 ) -> List[Scenario]:
     """Retrieve a list of all primary scenarios.
@@ -578,6 +586,8 @@ def get_primary_scenarios(
             The default value is False.
         descending (bool): If True, sort the output list of scenarios in descending order.
             The default value is False.
+        created_start_time (Optional[datetime]): The optional inclusive start date to filter scenarios by creation date.
+        created_end_time (Optional[datetime]): The optional inclusive end date to filter scenarios by creation date.
         sort_key (Literal["name", "id", "creation_date", "tags"]): The optional sort_key to
             decide upon what key scenarios are sorted. The sorting is in increasing order for
             dates, in alphabetical order for name and id, and in lexicographical order for tags.
@@ -589,6 +599,9 @@ def get_primary_scenarios(
     """
     scenario_manager = _ScenarioManagerFactory._build_manager()
     scenarios = scenario_manager._get_primary_scenarios()
+
+    if created_start_time or created_end_time:
+        scenarios = scenario_manager._filter_by_creation_time(scenarios, created_start_time, created_end_time)
     if is_sorted:
         scenario_manager._sort_scenarios(scenarios, descending, sort_key)
     return scenarios

+ 48 - 48
taipy/gui/builder/_element.py

@@ -18,11 +18,13 @@ import io
 import re
 import sys
 import typing as t
+import uuid
 from abc import ABC, abstractmethod
 from collections.abc import Iterable
 from types import FrameType, FunctionType
 
 from .._warnings import _warn
+from ..utils import _getscopeattr
 
 if sys.version_info < (3, 9):
     from ..utils.unparse import _Unparser
@@ -50,8 +52,8 @@ class _Element(ABC):
         return obj
 
     def __init__(self, *args, **kwargs) -> None:
-        self.__variables: t.Dict[str, FunctionType] = {}
         self._properties: t.Dict[str, t.Any] = {}
+        self._lambdas: t.Dict[str, str] = {}
         self.__calling_frame = t.cast(
             FrameType, t.cast(FrameType, t.cast(FrameType, inspect.currentframe()).f_back).f_back
         )
@@ -65,6 +67,11 @@ class _Element(ABC):
         self._properties.update(kwargs)
         self.parse_properties()
 
+    def _evaluate_lambdas(self, gui: Gui):
+        for k, lmbd in self._lambdas.items():
+            expr = gui._evaluate_expr(f"{{{lmbd}}}")
+            gui._bind_var_val(k, _getscopeattr(gui, expr))
+
     # Convert property value to string/function
     def parse_properties(self):
         self._properties = {
@@ -86,53 +93,46 @@ class _Element(ABC):
             return value
         if isinstance(value, FunctionType):
             if key.startswith("on_"):
-                return value
-            else:
-                try:
-                    st = ast.parse(inspect.getsource(value.__code__).strip())
-                    lambda_by_name: t.Dict[str, ast.Lambda] = {}
-                    _LambdaByName(self._ELEMENT_NAME, lambda_by_name).visit(st)
-                    lambda_fn = lambda_by_name.get(
-                        key,
-                        lambda_by_name.get(_LambdaByName._DEFAULT_NAME, None)
-                        if key == self._DEFAULT_PROPERTY
-                        else None,
+                if value.__name__.startswith("<"):
+                    return value
+                return value.__name__
+
+            try:
+                st = ast.parse(inspect.getsource(value.__code__).strip())
+                lambda_by_name: t.Dict[str, ast.Lambda] = {}
+                _LambdaByName(self._ELEMENT_NAME, lambda_by_name).visit(st)
+                lambda_fn = lambda_by_name.get(
+                    key,
+                    lambda_by_name.get(_LambdaByName._DEFAULT_NAME, None) if key == self._DEFAULT_PROPERTY else None,
+                )
+                if lambda_fn is not None:
+                    args = [arg.arg for arg in lambda_fn.args.args]
+                    targets = [
+                        compr.target.id  # type: ignore[attr-defined]
+                        for node in ast.walk(lambda_fn.body)
+                        if isinstance(node, ast.ListComp)
+                        for compr in node.generators
+                    ]
+                    tree = _TransformVarToValue(self.__calling_frame, args + targets + _python_builtins).visit(
+                        lambda_fn
                     )
-                    if lambda_fn is not None:
-                        args = [arg.arg for arg in lambda_fn.args.args]
-                        targets = [
-                            compr.target.id  # type: ignore[attr-defined]
-                            for node in ast.walk(lambda_fn.body)
-                            if isinstance(node, ast.ListComp)
-                            for compr in node.generators
-                        ]
-                        tree = _TransformVarToValue(self.__calling_frame, args + targets + _python_builtins).visit(
-                            lambda_fn
-                        )
-                        ast.fix_missing_locations(tree)
-                        if sys.version_info < (3, 9):  # python 3.8 ast has no unparse
-                            string_fd = io.StringIO()
-                            _Unparser(tree, string_fd)
-                            string_fd.seek(0)
-                            lambda_text = string_fd.read()
-                        else:
-                            lambda_text = ast.unparse(tree)
-                        new_code = compile(f"{_Element._NEW_LAMBDA_NAME} = {lambda_text}", "<ast>", "exec")
-                        namespace: t.Dict[str, FunctionType] = {}
-                        exec(new_code, namespace)
-                        var_name = f"__lambda_{id(namespace[_Element._NEW_LAMBDA_NAME])}"
-                        self.__variables[var_name] = namespace[_Element._NEW_LAMBDA_NAME]
-                        return f'{{{var_name}({", ".join(args)})}}'
-                except Exception as e:
-                    _warn("Error in lambda expression", e)
+                    ast.fix_missing_locations(tree)
+                    if sys.version_info < (3, 9):  # python 3.8 ast has no unparse
+                        string_fd = io.StringIO()
+                        _Unparser(tree, string_fd)
+                        string_fd.seek(0)
+                        lambda_text = string_fd.read()
+                    else:
+                        lambda_text = ast.unparse(tree)
+                    lambda_name = f"__lambda_{uuid.uuid4().hex}"
+                    self._lambdas[lambda_name] = lambda_text
+                    return f'{{{lambda_name}({", ".join(args)})}}'
+            except Exception as e:
+                _warn("Error in lambda expression", e)
         if hasattr(value, "__name__"):
             return str(getattr(value, "__name__"))  # noqa: B009
         return str(value)
 
-    def _bind_variables(self, gui: "Gui"):
-        for var_name, var_value in self.__variables.items():
-            gui._bind_var_val(var_name, var_value)
-
     @abstractmethod
     def _render(self, gui: "Gui") -> str:
         pass
@@ -159,7 +159,7 @@ class _Block(_Element):
         _BuilderContextManager().pop()
 
     def _render(self, gui: "Gui") -> str:
-        self._bind_variables(gui)
+        self._evaluate_lambdas(gui)
         el = _BuilderFactory.create_element(gui, self._ELEMENT_NAME, self._deepcopy_properties())
         return f"{el[0]}{self._render_children(gui)}</{el[1]}>"
 
@@ -170,7 +170,7 @@ class _Block(_Element):
 class _DefaultBlock(_Block):
     _ELEMENT_NAME = "part"
 
-    def __init__(self, *args, **kwargs):
+    def __init__(self, *args, **kwargs):  # do not remove as it could break the search in frames
         super().__init__(*args, **kwargs)
 
 
@@ -223,7 +223,7 @@ class html(_Block):
         self._content = args[1] if len(args) > 1 else ""
 
     def _render(self, gui: "Gui") -> str:
-        self._bind_variables(gui)
+        self._evaluate_lambdas(gui)
         if self._ELEMENT_NAME:
             attrs = ""
             if self._properties:
@@ -236,11 +236,11 @@ class html(_Block):
 class _Control(_Element):
     """NOT DOCUMENTED"""
 
-    def __init__(self, *args, **kwargs):
+    def __init__(self, *args, **kwargs):  # do not remove as it could break the search in frames
         super().__init__(*args, **kwargs)
 
     def _render(self, gui: "Gui") -> str:
-        self._bind_variables(gui)
+        self._evaluate_lambdas(gui)
         el = _BuilderFactory.create_element(gui, self._ELEMENT_NAME, self._deepcopy_properties())
         return (
             f"<div>{el[0]}</{el[1]}></div>"

+ 2 - 0
taipy/gui/builder/_utils.py

@@ -39,6 +39,8 @@ class _TransformVarToValue(ast.NodeTransformer):
         if var_parts[0] in self.non_vars:
             return node
         value = _get_value_in_frame(self.frame, var_parts[0])
+        if callable(value):
+            return node
         if len(var_parts) > 1:
             value = attrgetter(var_parts[1])(value)
         return ast.Constant(value=value, kind=None)

+ 53 - 0
tests/core/scenario/test_scenario_manager.py

@@ -13,6 +13,7 @@ from datetime import datetime, timedelta
 from typing import Callable, Iterable, Optional
 from unittest.mock import ANY, patch
 
+import freezegun
 import pytest
 
 from taipy.config.common.frequency import Frequency
@@ -1481,3 +1482,55 @@ def test_get_scenarios_by_config_id_in_multiple_versions_environment():
 
     assert len(_ScenarioManager._get_by_config_id(scenario_config_1.id)) == 3
     assert len(_ScenarioManager._get_by_config_id(scenario_config_2.id)) == 2
+
+
+def test_filter_scenarios_by_creation_datetime():
+    scenario_config_1 = Config.configure_scenario("s1", sequence_configs=[])
+
+    with freezegun.freeze_time("2024-01-01"):
+        s_1_1 = _ScenarioManager._create(scenario_config_1)
+    with freezegun.freeze_time("2024-01-03"):
+        s_1_2 = _ScenarioManager._create(scenario_config_1)
+    with freezegun.freeze_time("2024-02-01"):
+        s_1_3 = _ScenarioManager._create(scenario_config_1)
+
+    all_scenarios = _ScenarioManager._get_all()
+
+    filtered_scenarios = _ScenarioManager._filter_by_creation_time(
+        scenarios=all_scenarios,
+        created_start_time=datetime(2024, 1, 1),
+        created_end_time=datetime(2024, 1, 2),
+    )
+    assert len(filtered_scenarios) == 1
+    assert [s_1_1] == filtered_scenarios
+
+    # The time period is inclusive
+    filtered_scenarios = _ScenarioManager._filter_by_creation_time(
+        scenarios=all_scenarios,
+        created_start_time=datetime(2024, 1, 1),
+        created_end_time=datetime(2024, 1, 3),
+    )
+    assert len(filtered_scenarios) == 2
+    assert sorted([s_1_1.id, s_1_2.id]) == sorted([scenario.id for scenario in filtered_scenarios])
+
+    filtered_scenarios = _ScenarioManager._filter_by_creation_time(
+        scenarios=all_scenarios,
+        created_start_time=datetime(2023, 1, 1),
+        created_end_time=datetime(2025, 1, 1),
+    )
+    assert len(filtered_scenarios) == 3
+    assert sorted([s_1_1.id, s_1_2.id, s_1_3.id]) == sorted([scenario.id for scenario in filtered_scenarios])
+
+    filtered_scenarios = _ScenarioManager._filter_by_creation_time(
+        scenarios=all_scenarios,
+        created_start_time=datetime(2024, 2, 1),
+    )
+    assert len(filtered_scenarios) == 1
+    assert [s_1_3] == filtered_scenarios
+
+    filtered_scenarios = _ScenarioManager._filter_by_creation_time(
+        scenarios=all_scenarios,
+        created_end_time=datetime(2024, 1, 2),
+    )
+    assert len(filtered_scenarios) == 1
+    assert [s_1_1] == filtered_scenarios

+ 53 - 0
tests/core/scenario/test_scenario_manager_with_sql_repo.py

@@ -11,6 +11,7 @@
 
 from datetime import datetime, timedelta
 
+import freezegun
 import pytest
 
 from taipy.config.common.frequency import Frequency
@@ -435,3 +436,55 @@ def test_get_scenarios_by_config_id_in_multiple_versions_environment(init_sql_re
 
     assert len(_ScenarioManager._get_by_config_id(scenario_config_1.id)) == 3
     assert len(_ScenarioManager._get_by_config_id(scenario_config_2.id)) == 2
+
+
+def test_filter_scenarios_by_creation_datetime(init_sql_repo):
+    scenario_config_1 = Config.configure_scenario("s1", sequence_configs=[])
+
+    with freezegun.freeze_time("2024-01-01"):
+        s_1_1 = _ScenarioManager._create(scenario_config_1)
+    with freezegun.freeze_time("2024-01-03"):
+        s_1_2 = _ScenarioManager._create(scenario_config_1)
+    with freezegun.freeze_time("2024-02-01"):
+        s_1_3 = _ScenarioManager._create(scenario_config_1)
+
+    all_scenarios = _ScenarioManager._get_all()
+
+    filtered_scenarios = _ScenarioManager._filter_by_creation_time(
+        scenarios=all_scenarios,
+        created_start_time=datetime(2024, 1, 1),
+        created_end_time=datetime(2024, 1, 2),
+    )
+    assert len(filtered_scenarios) == 1
+    assert [s_1_1] == filtered_scenarios
+
+    # The time period is inclusive
+    filtered_scenarios = _ScenarioManager._filter_by_creation_time(
+        scenarios=all_scenarios,
+        created_start_time=datetime(2024, 1, 1),
+        created_end_time=datetime(2024, 1, 3),
+    )
+    assert len(filtered_scenarios) == 2
+    assert sorted([s_1_1.id, s_1_2.id]) == sorted([scenario.id for scenario in filtered_scenarios])
+
+    filtered_scenarios = _ScenarioManager._filter_by_creation_time(
+        scenarios=all_scenarios,
+        created_start_time=datetime(2023, 1, 1),
+        created_end_time=datetime(2025, 1, 1),
+    )
+    assert len(filtered_scenarios) == 3
+    assert sorted([s_1_1.id, s_1_2.id, s_1_3.id]) == sorted([scenario.id for scenario in filtered_scenarios])
+
+    filtered_scenarios = _ScenarioManager._filter_by_creation_time(
+        scenarios=all_scenarios,
+        created_start_time=datetime(2024, 2, 1),
+    )
+    assert len(filtered_scenarios) == 1
+    assert [s_1_3] == filtered_scenarios
+
+    filtered_scenarios = _ScenarioManager._filter_by_creation_time(
+        scenarios=all_scenarios,
+        created_end_time=datetime(2024, 1, 2),
+    )
+    assert len(filtered_scenarios) == 1
+    assert [s_1_1] == filtered_scenarios

+ 6 - 0
tests/core/test_taipy.py

@@ -431,6 +431,9 @@ class TestTaipy:
         with mock.patch("taipy.core.scenario._scenario_manager._ScenarioManager._get_all_by_tag") as mck:
             tp.get_scenarios(tag="tag")
             mck.assert_called_once_with("tag")
+        with mock.patch("taipy.core.scenario._scenario_manager._ScenarioManager._filter_by_creation_time") as mck:
+            tp.get_scenarios(created_start_time=datetime.datetime(2021, 1, 1))
+            mck.assert_called_once_with([], datetime.datetime(2021, 1, 1), None)
 
     def test_get_scenarios_sorted(self):
         scenario_1_cfg = Config.configure_scenario(id="scenario_1")
@@ -500,6 +503,9 @@ class TestTaipy:
         with mock.patch("taipy.core.scenario._scenario_manager._ScenarioManager._get_primary_scenarios") as mck:
             tp.get_primary_scenarios()
             mck.assert_called_once_with()
+        with mock.patch("taipy.core.scenario._scenario_manager._ScenarioManager._filter_by_creation_time") as mck:
+            tp.get_scenarios(created_end_time=datetime.datetime(2021, 1, 1))
+            mck.assert_called_once_with([], None, datetime.datetime(2021, 1, 1))
 
     def test_set_primary(self, scenario):
         with mock.patch("taipy.core.scenario._scenario_manager._ScenarioManager._set_primary") as mck:

+ 30 - 0
tests/gui/builder/test_on_action.py

@@ -0,0 +1,30 @@
+# Copyright 2021-2024 Avaiga Private Limited
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+#        http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+# an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations under the License.
+
+import taipy.gui.builder as tgb
+from taipy.gui import Gui, notify
+
+
+def test_builder_on_function(gui: Gui, test_client, helpers):
+    def on_slider(state):
+        notify(state, "success", f"Value: {state.value}")
+    gui._bind_var_val("on_slider", on_slider)
+    with tgb.Page(frame=None) as page:
+        tgb.slider(value="{value}", on_change=on_slider)  # type: ignore[attr-defined] # noqa: B023
+    expected_list = ['<Slider','onChange="on_slider"']
+    helpers.test_control_builder(gui, page, expected_list)
+
+
+def test_builder_on_lambda(gui: Gui, test_client, helpers):
+    with tgb.Page(frame=None) as page:
+        tgb.slider(value="{value}", on_change=lambda s: notify(s, "success", f"Lambda Value: {s.value}"))  # type: ignore[attr-defined] # noqa: B023
+    expected_list = ['<Slider','onChange="__lambda_']
+    helpers.test_control_builder(gui, page, expected_list)