Przeglądaj źródła

Merge branch 'main' into 2.0

Falko Schindler 9 miesięcy temu
rodzic
commit
7d4d0cc76f
42 zmienionych plików z 1186 dodań i 824 usunięć
  1. 10 0
      .github/dependabot.yml
  2. 9 11
      .github/workflows/publish.yml
  3. 5 7
      .github/workflows/test.yml
  4. 3 3
      CITATION.cff
  5. 1 1
      examples/authentication/test_authentication.py
  6. 1 1
      examples/chat_app/test_chat_app.py
  7. 1 1
      examples/todo_list/test_todo_list.py
  8. 1 1
      fly.dockerfile
  9. 1 1
      nicegui/client.py
  10. 9 1
      nicegui/element_filter.py
  11. 29 4
      nicegui/elements/scene.js
  12. 12 9
      nicegui/elements/scene.py
  13. 6 0
      nicegui/elements/scene_objects.py
  14. 0 8
      nicegui/elements/scene_view.py
  15. 9 0
      nicegui/elements/select.py
  16. 36 4
      nicegui/elements/tree.py
  17. 0 2
      nicegui/events.py
  18. 2 0
      nicegui/functions/javascript.py
  19. 3 2
      nicegui/functions/notify.py
  20. 1 0
      nicegui/static/nicegui.js
  21. 80 0
      nicegui/testing/general_fixtures.py
  22. 4 199
      nicegui/testing/plugin.py
  23. 84 0
      nicegui/testing/screen_plugin.py
  24. 9 6
      nicegui/testing/user.py
  25. 9 2
      nicegui/testing/user_interaction.py
  26. 14 0
      nicegui/testing/user_notify.py
  27. 61 0
      nicegui/testing/user_plugin.py
  28. 413 451
      poetry.lock
  29. 5 5
      pyproject.toml
  30. 2 2
      release.dockerfile
  31. 4 4
      tests/test_download.py
  32. 11 4
      tests/test_events.py
  33. 63 0
      tests/test_tree.py
  34. 17 0
      tests/test_user_simulation.py
  35. 15 10
      website/documentation/content/aggrid_documentation.py
  36. 22 17
      website/documentation/content/echart_documentation.py
  37. 23 18
      website/documentation/content/json_editor_documentation.py
  38. 5 3
      website/documentation/content/project_structure_documentation.py
  39. 40 34
      website/documentation/content/run_javascript_documentation.py
  40. 81 3
      website/documentation/content/scene_documentation.py
  41. 52 8
      website/documentation/content/tree_documentation.py
  42. 33 2
      website/documentation/content/user_documentation.py

+ 10 - 0
.github/dependabot.yml

@@ -0,0 +1,10 @@
+version: 2
+updates:
+  - package-ecosystem: "github-actions"
+    directory: "/"
+    schedule:
+      interval: "daily"
+  - package-ecosystem: "pip"
+    directory: "/"
+    schedule:
+      interval: "daily"

+ 9 - 11
.github/workflows/publish.yml

@@ -11,15 +11,13 @@ jobs:
     runs-on: ubuntu-latest
 
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v4
       - name: set up Python
-        uses: actions/setup-python@v2
+        uses: actions/setup-python@v5
         with:
           python-version: "3.x"
       - name: set up Poetry
-        uses: abatilo/actions-poetry@v2.0.0
-        with:
-          poetry-version: "1.3.1"
+        uses: abatilo/actions-poetry@v3
       - name: get version
         id: get_version
         run: echo "VERSION=$(echo ${GITHUB_REF/refs\/tags\//})" >> $GITHUB_ENV
@@ -30,7 +28,7 @@ jobs:
           POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_API_TOKEN }}
         run: poetry publish --build
       - name: Create GitHub release entry
-        uses: softprops/action-gh-release@v1
+        uses: softprops/action-gh-release@v2
         id: create_release
         with:
           draft: true
@@ -48,7 +46,7 @@ jobs:
     runs-on: ubuntu-latest
 
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v4
       - name: Prepare
         id: prep
         run: |
@@ -79,7 +77,7 @@ jobs:
           platforms: all
       - name: Login to DockerHub
         if: github.event_name != 'pull_request'
-        uses: docker/login-action@v1
+        uses: docker/login-action@v3
         with:
           username: ${{ secrets.DOCKER_USERNAME }}
           password: ${{ secrets.DOCKER_PASSWORD }}
@@ -89,7 +87,7 @@ jobs:
         with:
           install: true
       - name: Build
-        uses: docker/build-push-action@v2
+        uses: docker/build-push-action@v6
         with:
           context: .
           file: ./release.dockerfile
@@ -114,12 +112,12 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - name: Checkout repository
-        uses: actions/checkout@v2
+        uses: actions/checkout@v4
         with:
           ref: main
 
       - name: Set up Python
-        uses: actions/setup-python@v2
+        uses: actions/setup-python@v5
         with:
           python-version: 3.11
 

+ 5 - 7
.github/workflows/test.yml

@@ -11,15 +11,13 @@ jobs:
     runs-on: ubuntu-latest
     timeout-minutes: 40
     steps:
-      - uses: actions/checkout@v3
+      - uses: actions/checkout@v4
       - name: set up Python
-        uses: actions/setup-python@v4
+        uses: actions/setup-python@v5
         with:
           python-version: ${{ matrix.python }}
       - name: set up Poetry
-        uses: abatilo/actions-poetry@v2.0.0
-        with:
-          poetry-version: "1.6.1"
+        uses: abatilo/actions-poetry@v3
       - name: install dependencies
         run: |
           set -x
@@ -33,11 +31,11 @@ jobs:
       - name: test startup
         run: ./test_startup.sh
       - name: setup chromedriver
-        uses: nanasess/setup-chromedriver@v2.2.0
+        uses: nanasess/setup-chromedriver@v2.2.2
       - name: pytest
         run: pytest
       - name: upload screenshots
-        uses: actions/upload-artifact@v2
+        uses: actions/upload-artifact@v4
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
         with:

+ 3 - 3
CITATION.cff

@@ -8,7 +8,7 @@ authors:
   given-names: Rodja
   orcid: https://orcid.org/0009-0009-4735-6227
 title: 'NiceGUI: Web-based user interfaces with Python. The nice way.'
-version: v1.4.30
-date-released: '2024-07-26'
+version: v1.4.34
+date-released: '2024-08-05'
 url: https://github.com/zauberzeug/nicegui
-doi: 10.5281/zenodo.12926830
+doi: 10.5281/zenodo.13223152

+ 1 - 1
examples/authentication/test_authentication.py

@@ -6,7 +6,7 @@ from . import main
 
 # pylint: disable=missing-function-docstring
 
-pytest_plugins = ['nicegui.testing.plugin']
+pytest_plugins = ['nicegui.testing.user_plugin']
 
 
 @pytest.mark.module_under_test(main)

+ 1 - 1
examples/chat_app/test_chat_app.py

@@ -7,7 +7,7 @@ from nicegui.testing import User
 
 from . import main
 
-pytest_plugins = ['nicegui.testing.plugin']
+pytest_plugins = ['nicegui.testing.user_plugin']
 
 
 @pytest.mark.module_under_test(main)

+ 1 - 1
examples/todo_list/test_todo_list.py

@@ -6,7 +6,7 @@ from . import main
 
 # pylint: disable=missing-function-docstring
 
-pytest_plugins = ['nicegui.testing.plugin']
+pytest_plugins = ['nicegui.testing.user_plugin']
 
 
 @pytest.mark.module_under_test(main)

+ 1 - 1
fly.dockerfile

@@ -1,4 +1,4 @@
-FROM python:3.11.3-slim
+FROM python:3.11-slim
 
 LABEL maintainer="Zauberzeug GmbH <nicegui@zauberzeug.com>"
 

+ 1 - 1
nicegui/client.py

@@ -275,7 +275,7 @@ class Client:
         """Forward an event to the corresponding element."""
         with self:
             sender = self.elements.get(msg['id'])
-            if sender is not None:
+            if sender is not None and not sender.is_ignoring_events:
                 msg['args'] = [None if arg is None else json.loads(arg) for arg in msg.get('args', [])]
                 if len(msg['args']) == 1:
                     msg['args'] = msg['args'][0]

+ 9 - 1
nicegui/element_filter.py

@@ -9,6 +9,8 @@ from .element import Element
 from .elements.mixins.content_element import ContentElement
 from .elements.mixins.source_element import SourceElement
 from .elements.mixins.text_element import TextElement
+from .elements.notification import Notification
+from .elements.select import Select
 
 T = TypeVar('T', bound=Element)
 
@@ -108,11 +110,17 @@ class ElementFilter(Generic[T]):
                     element._props.get('icon'),
                     element._props.get('placeholder'),
                     element._props.get('value'),
-                    element._props.get('options', {}).get('message'),
                     element.text if isinstance(element, TextElement) else None,
                     element.content if isinstance(element, ContentElement) else None,
                     element.source if isinstance(element, SourceElement) else None,
                 ) if content]
+                if isinstance(element, Notification):
+                    element_contents.append(element.message)
+                if isinstance(element, Select):
+                    options = {option['value']: option['label'] for option in element._props.get('options', [])}
+                    element_contents.append(options.get(element.value, ''))
+                    if element.is_showing_popup:
+                        element_contents.extend(options.values())
                 if any(all(needle not in str(haystack) for haystack in element_contents) for needle in self._contents):
                     continue
                 if any(needle in str(haystack) for haystack in element_contents for needle in self._exclude_content):

+ 29 - 4
nicegui/elements/scene.js

@@ -63,6 +63,7 @@ export default {
     this.objects = new Map();
     this.objects.set("scene", this.scene);
     this.draggable_objects = [];
+    this.is_initialized = false;
 
     window["scene_" + this.$el.id] = this.scene; // NOTE: for selenium tests only
 
@@ -150,7 +151,6 @@ export default {
     };
     const handleDrag = (event) => {
       this.drag_constraints.split(",").forEach((constraint) => applyConstraint(constraint, event.object.position));
-      if (event.type === "drag") return;
       this.$emit(event.type, {
         type: event.type,
         object_id: event.object.object_id,
@@ -159,7 +159,8 @@ export default {
         y: event.object.position.y,
         z: event.object.position.z,
       });
-      this.controls.enabled = event.type == "dragend";
+      if (event.type === "dragstart") this.controls.enabled = false;
+      if (event.type === "dragend") this.controls.enabled = true;
     };
     this.drag_controls.addEventListener("dragstart", handleDrag);
     this.drag_controls.addEventListener("drag", handleDrag);
@@ -196,8 +197,7 @@ export default {
         shift_key: mouseEvent.shiftKey,
       });
     };
-    this.$el.onclick = click_handler;
-    this.$el.ondblclick = click_handler;
+    this.click_events.forEach((event) => this.$el.addEventListener(event, click_handler));
 
     this.texture_loader = new THREE.TextureLoader();
     this.stl_loader = new STLLoader();
@@ -216,6 +216,7 @@ export default {
 
   methods: {
     create(type, id, parent_id, ...args) {
+      if (!this.is_initialized) return;
       let mesh;
       if (type == "group") {
         mesh = new THREE.Group();
@@ -390,6 +391,11 @@ export default {
       if (!this.objects.has(object_id)) return;
       this.objects.get(object_id).geometry = texture_geometry(coords);
     },
+    set_points(object_id, position, color) {
+      const geometry = this.objects.get(object_id).geometry;
+      geometry.setAttribute("position", new THREE.Float32BufferAttribute(position.flat(), 3));
+      geometry.setAttribute("color", new THREE.Float32BufferAttribute(color.flat(), 3));
+    },
     move_camera(x, y, z, look_at_x, look_at_y, look_at_z, up_x, up_y, up_z, duration) {
       if (this.camera_tween) this.camera_tween.stop();
       this.camera_tween = new TWEEN.Tween([
@@ -426,6 +432,23 @@ export default {
         })
         .start();
     },
+    get_camera() {
+      return {
+        position: this.camera.position,
+        up: this.camera.up,
+        rotation: this.camera.rotation,
+        quaternion: this.camera.quaternion,
+        type: this.camera.type,
+        fov: this.camera.fov,
+        aspect: this.camera.aspect,
+        near: this.camera.near,
+        far: this.camera.far,
+        left: this.camera.left,
+        right: this.camera.right,
+        top: this.camera.top,
+        bottom: this.camera.bottom,
+      };
+    },
     resize() {
       const { clientWidth, clientHeight } = this.$el;
       this.renderer.setSize(clientWidth, clientHeight);
@@ -439,6 +462,7 @@ export default {
       this.camera.updateProjectionMatrix();
     },
     init_objects(data) {
+      this.is_initialized = true;
       for (const [
         type,
         id,
@@ -476,6 +500,7 @@ export default {
     grid: Object,
     camera_type: String,
     camera_params: Object,
+    click_events: Array,
     drag_constraints: String,
     background_color: String,
   },

+ 12 - 9
nicegui/elements/scene.py

@@ -5,7 +5,6 @@ from typing import Any, Callable, Dict, List, Literal, Optional, Tuple, Union
 from typing_extensions import Self
 
 from .. import binding
-from ..awaitable_response import AwaitableResponse, NullResponse
 from ..dataclasses import KWONLY_SLOTS
 from ..element import Element
 from ..events import (
@@ -75,6 +74,7 @@ class Scene(Element,
                  grid: Union[bool, Tuple[int, int]] = True,
                  camera: Optional[SceneCamera] = None,
                  on_click: Optional[Callable[..., Any]] = None,
+                 click_events: List[str] = ['click', 'dblclick'],  # noqa: B006
                  on_drag_start: Optional[Callable[..., Any]] = None,
                  on_drag_end: Optional[Callable[..., Any]] = None,
                  drag_constraints: str = '',
@@ -91,7 +91,8 @@ class Scene(Element,
         :param height: height of the canvas
         :param grid: whether to display a grid (boolean or tuple of ``size`` and ``divisions`` for `Three.js' GridHelper <https://threejs.org/docs/#api/en/helpers/GridHelper>`_, default: 100x100)
         :param camera: camera definition, either instance of ``ui.scene.perspective_camera`` (default) or ``ui.scene.orthographic_camera``
-        :param on_click: callback to execute when a 3D object is clicked
+        :param on_click: callback to execute when a 3D object is clicked (use ``click_events`` to specify which events to subscribe to)
+        :param click_events: list of JavaScript click events to subscribe to (default: ``['click', 'dblclick']``)
         :param on_drag_start: callback to execute when a 3D object is dragged
         :param on_drag_end: callback to execute when a 3D object is dropped
         :param drag_constraints: comma-separated JavaScript expression for constraining positions of dragged objects (e.g. ``'x = 0, z = y / 2'``)
@@ -108,9 +109,9 @@ class Scene(Element,
         self.objects: Dict[str, Object3D] = {}
         self.stack: List[Union[Object3D, SceneObject]] = [SceneObject()]
         self._click_handlers = [on_click] if on_click else []
+        self._props['click_events'] = click_events
         self._drag_start_handlers = [on_drag_start] if on_drag_start else []
         self._drag_end_handlers = [on_drag_end] if on_drag_end else []
-        self.is_initialized = False
         self.on('init', self._handle_init)
         self.on('click3d', self._handle_click)
         self.on('dragstart', self._handle_drag)
@@ -167,7 +168,6 @@ class Scene(Element,
         return attribute
 
     def _handle_init(self, e: GenericEventArguments) -> None:
-        self.is_initialized = True
         with self.client.individual_target(e.args['socket_id']):
             self.move_camera(duration=0)
             self.run_method('init_objects', [obj.data for obj in self.objects.values()])
@@ -179,11 +179,6 @@ class Scene(Element,
         await self.client.connected()
         await event.wait()
 
-    def run_method(self, name: str, *args: Any, timeout: float = 1, check_interval: float = 0.01) -> AwaitableResponse:
-        if not self.is_initialized:
-            return NullResponse()
-        return super().run_method(name, *args, timeout=timeout, check_interval=check_interval)
-
     def _handle_click(self, e: GenericEventArguments) -> None:
         arguments = SceneClickEventArguments(
             sender=self,
@@ -263,6 +258,14 @@ class Scene(Element,
                         self.camera.look_at_x, self.camera.look_at_y, self.camera.look_at_z,
                         self.camera.up_x, self.camera.up_y, self.camera.up_z, duration)
 
+    async def get_camera(self) -> Dict[str, Any]:
+        """Get the current camera parameters.
+
+        In contrast to the `camera` property,
+        the result of this method includes the current camera pose caused by the user navigating the scene in the browser.
+        """
+        return await self.run_method('get_camera')
+
     def _handle_delete(self) -> None:
         binding.remove(list(self.objects.values()))
         super()._handle_delete()

+ 6 - 0
nicegui/elements/scene_objects.py

@@ -329,3 +329,9 @@ class PointCloud(Object3D):
         :param point_size: size of the points (default: 1.0)
         """
         super().__init__('point_cloud', points, colors, point_size)
+
+    def set_points(self, points: List[List[float]], colors: List[List[float]]) -> None:
+        """Change the points and colors of the point cloud."""
+        self.args[0] = points
+        self.args[1] = colors
+        self.scene.run_method('set_points', self.id, points, colors)

+ 0 - 8
nicegui/elements/scene_view.py

@@ -3,7 +3,6 @@ from typing import Any, Callable, Optional
 
 from typing_extensions import Self
 
-from ..awaitable_response import AwaitableResponse, NullResponse
 from ..element import Element
 from ..events import GenericEventArguments, SceneClickEventArguments, SceneClickHit, handle_event
 from .scene import Scene, SceneCamera
@@ -45,7 +44,6 @@ class SceneView(Element,
         self._props['camera_type'] = self.camera.type
         self._props['camera_params'] = self.camera.params
         self._click_handlers = [on_click] if on_click else []
-        self.is_initialized = False
         self.on('init', self._handle_init)
         self.on('click3d', self._handle_click)
 
@@ -55,7 +53,6 @@ class SceneView(Element,
         return self
 
     def _handle_init(self, e: GenericEventArguments) -> None:
-        self.is_initialized = True
         with self.client.individual_target(e.args['socket_id']):
             self.move_camera(duration=0)
 
@@ -66,11 +63,6 @@ class SceneView(Element,
         await self.client.connected()
         await event.wait()
 
-    def run_method(self, name: str, *args: Any, timeout: float = 1, check_interval: float = 0.01) -> AwaitableResponse:
-        if not self.is_initialized:
-            return NullResponse()
-        return super().run_method(name, *args, timeout=timeout, check_interval=check_interval)
-
     def _handle_click(self, e: GenericEventArguments) -> None:
         arguments = SceneClickEventArguments(
             sender=self,

+ 9 - 0
nicegui/elements/select.py

@@ -80,6 +80,15 @@ class Select(ValidationElement, ChoiceElement, DisableableElement, component='se
         self._props['multiple'] = multiple
         self._props['clearable'] = clearable
 
+        self._is_showing_popup = False
+        self.on('popup-show', lambda e: setattr(e.sender, '_is_showing_popup', True))
+        self.on('popup-hide', lambda e: setattr(e.sender, '_is_showing_popup', False))
+
+    @property
+    def is_showing_popup(self) -> bool:
+        """Whether the options popup is currently shown."""
+        return self._is_showing_popup
+
     def _event_args_to_value(self, e: GenericEventArguments) -> Any:
         if self.multiple:
             if e.args is None:

+ 36 - 4
nicegui/elements/tree.py

@@ -3,11 +3,11 @@ from typing import Any, Callable, Dict, Iterator, List, Literal, Optional, Set
 from typing_extensions import Self
 
 from .. import helpers
-from ..element import Element
 from ..events import GenericEventArguments, ValueChangeEventArguments, handle_event
+from .mixins.filter_element import FilterElement
 
 
-class Tree(Element):
+class Tree(FilterElement):
 
     def __init__(self,
                  nodes: List[Dict], *,
@@ -36,12 +36,12 @@ class Tree(Element):
         :param on_tick: callback which is invoked when a node is ticked or unticked
         :param tick_strategy: whether and how to use checkboxes ("leaf", "leaf-filtered" or "strict"; default: ``None``)
         """
-        super().__init__('q-tree')
+        super().__init__(tag='q-tree', filter=None)
         self._props['nodes'] = nodes
         self._props['node-key'] = node_key
         self._props['label-key'] = label_key
         self._props['children-key'] = children_key
-        self._props['selected'] = []
+        self._props['selected'] = None
         self._props['expanded'] = []
         self._props['ticked'] = []
         if tick_strategy is not None:
@@ -78,6 +78,20 @@ class Tree(Element):
         self._select_handlers.append(callback)
         return self
 
+    def select(self, node_key: Optional[str]) -> Self:
+        """Select the given node.
+
+        :param node_key: node key to select
+        """
+        if self._props['selected'] != node_key:
+            self._props['selected'] = node_key
+            self.update()
+        return self
+
+    def deselect(self) -> Self:
+        """Remove node selection."""
+        return self.select(None)
+
     def on_expand(self, callback: Callable[..., Any]) -> Self:
         """Add a callback to be invoked when the expansion changes."""
         self._expand_handlers.append(callback)
@@ -88,6 +102,24 @@ class Tree(Element):
         self._tick_handlers.append(callback)
         return self
 
+    def tick(self, node_keys: Optional[List[str]] = None) -> Self:
+        """Tick the given nodes.
+
+        :param node_keys: list of node keys to tick or ``None`` to tick all nodes (default: ``None``)
+        """
+        self._props['ticked'][:] = self._find_node_keys(node_keys).union(self._props['ticked'])
+        self.update()
+        return self
+
+    def untick(self, node_keys: Optional[List[str]] = None) -> Self:
+        """Remove tick from the given nodes.
+
+        :param node_keys: list of node keys to untick or ``None`` to untick all nodes (default: ``None``)
+        """
+        self._props['ticked'][:] = set(self._props['ticked']).difference(self._find_node_keys(node_keys))
+        self.update()
+        return self
+
     def expand(self, node_keys: Optional[List[str]] = None) -> Self:
         """Expand the given nodes.
 

+ 0 - 2
nicegui/events.py

@@ -403,8 +403,6 @@ def handle_event(handler: Optional[Callable[..., Any]], arguments: EventArgument
 
         parent_slot: Union[Slot, nullcontext]
         if isinstance(arguments, UiEventArguments):
-            if arguments.sender.is_ignoring_events:
-                return
             parent_slot = arguments.sender.parent_slot or arguments.sender.client.layout.default_slot
         else:
             parent_slot = nullcontext()

+ 2 - 0
nicegui/functions/javascript.py

@@ -16,6 +16,8 @@ def run_javascript(code: str, *,
     If the function is awaited, the result of the JavaScript code is returned.
     Otherwise, the JavaScript code is executed without waiting for a response.
 
+    Note that requesting data from the client is only supported for page functions, not for the shared auto-index page.
+
     :param code: JavaScript code to run
     :param timeout: timeout in seconds (default: `1.0`)
 

+ 3 - 2
nicegui/functions/notify.py

@@ -1,6 +1,6 @@
 from typing import Any, Literal, Optional, Union
 
-from ..elements.notification import Notification
+from ..context import context
 
 ARG_MAP = {
     'close_button': 'closeBtn',
@@ -49,4 +49,5 @@ def notify(message: Any, *,
     options = {ARG_MAP.get(key, key): value for key, value in locals().items() if key != 'kwargs' and value is not None}
     options['message'] = str(message)
     options.update(kwargs)
-    Notification(options=options)
+    client = context.client
+    client.outbox.enqueue_message('notify', options, client.id)

+ 1 - 0
nicegui/static/nicegui.js

@@ -358,6 +358,7 @@ function createApp(elements, options) {
           window.open(url, target);
         },
         download: (msg) => download(msg.src, msg.filename, msg.media_type, options.prefix),
+        notify: (msg) => Quasar.Notify.create(msg),
       };
       const socketMessageQueue = [];
       let isProcessingSocketMessage = false;

+ 80 - 0
nicegui/testing/general_fixtures.py

@@ -0,0 +1,80 @@
+import importlib
+from typing import Generator, List, Type
+
+import pytest
+from starlette.routing import Route
+
+import nicegui.storage
+from nicegui import Client, app, binding, core, run, ui
+from nicegui.page import page
+
+# pylint: disable=redefined-outer-name
+
+
+def pytest_configure(config: pytest.Config) -> None:
+    """Add the "module_under_test" marker to the pytest configuration."""
+    config.addinivalue_line('markers',
+                            'module_under_test(module): specify the module under test which then gets automatically reloaded.')
+
+
+@pytest.fixture
+def nicegui_reset_globals() -> Generator[None, None, None]:
+    """Reset the global state of the NiceGUI package."""
+    for route in app.routes:
+        if isinstance(route, Route) and route.path.startswith('/_nicegui/auto/static/'):
+            app.remove_route(route.path)
+    for path in {'/'}.union(Client.page_routes.values()):
+        app.remove_route(path)
+    app.openapi_schema = None
+    app.middleware_stack = None
+    app.user_middleware.clear()
+    app.urls.clear()
+    core.air = None
+    # NOTE favicon routes must be removed separately because they are not "pages"
+    for route in app.routes:
+        if isinstance(route, Route) and route.path.endswith('/favicon.ico'):
+            app.routes.remove(route)
+    importlib.reload(core)
+    importlib.reload(run)
+    element_classes: List[Type[ui.element]] = [ui.element]
+    while element_classes:
+        parent = element_classes.pop()
+        for cls in parent.__subclasses__():
+            cls._default_props = {}  # pylint: disable=protected-access
+            cls._default_style = {}  # pylint: disable=protected-access
+            cls._default_classes = []  # pylint: disable=protected-access
+            element_classes.append(cls)
+    Client.instances.clear()
+    Client.page_routes.clear()
+    app.reset()
+    Client.auto_index_client = Client(page('/'), request=None).__enter__()  # pylint: disable=unnecessary-dunder-call
+    # NOTE we need to re-add the auto index route because we removed all routes above
+    app.get('/')(Client.auto_index_client.build_response)
+    binding.reset()
+    yield
+
+
+def prepare_simulation(request: pytest.FixtureRequest) -> None:
+    """Prepare a simulation to be started.
+
+    By using the "module_under_test" marker you can specify the main entry point of the app.
+    """
+    marker = request.node.get_closest_marker('module_under_test')
+    if marker is not None:
+        with Client.auto_index_client:
+            importlib.reload(marker.args[0])
+
+    core.app.config.add_run_config(
+        reload=False,
+        title='Test App',
+        viewport='',
+        favicon=None,
+        dark=False,
+        language='en-US',
+        binding_refresh_interval=0.1,
+        reconnect_timeout=3.0,
+        tailwind=True,
+        prod_js=True,
+        show_welcome_message=False,
+    )
+    nicegui.storage.set_storage_secret('simulated secret')

+ 4 - 199
nicegui/testing/plugin.py

@@ -1,199 +1,4 @@
-import asyncio
-import importlib
-import os
-import shutil
-from pathlib import Path
-from typing import AsyncGenerator, Callable, Dict, Generator, List, Type
-
-import httpx
-import pytest
-from selenium import webdriver
-from selenium.webdriver.chrome.service import Service
-from starlette.routing import Route
-
-import nicegui.storage
-from nicegui import Client, app, binding, core, run, ui
-from nicegui.functions.navigate import Navigate
-from nicegui.page import page
-
-from .screen import Screen
-from .user import User
-
-# pylint: disable=redefined-outer-name
-
-DOWNLOAD_DIR = Path(__file__).parent / 'download'
-
-
-def pytest_configure(config: pytest.Config) -> None:
-    """Add the "module_under_test" marker to the pytest configuration."""
-    config.addinivalue_line('markers',
-                            'module_under_test: specify the module under test which then gets automatically reloaded.')
-
-
-@pytest.fixture
-def nicegui_chrome_options(chrome_options: webdriver.ChromeOptions) -> webdriver.ChromeOptions:
-    """Configure the Chrome options for the NiceGUI tests."""
-    chrome_options.add_argument('disable-dev-shm-usage')
-    chrome_options.add_argument('no-sandbox')
-    chrome_options.add_argument('headless')
-    chrome_options.add_argument('disable-gpu' if 'GITHUB_ACTIONS' in os.environ else '--use-gl=angle')
-    chrome_options.add_argument('window-size=600x600')
-    chrome_options.add_experimental_option('prefs', {
-        'download.default_directory': str(DOWNLOAD_DIR),
-        'download.prompt_for_download': False,  # To auto download the file
-        'download.directory_upgrade': True,
-    })
-    if 'CHROME_BINARY_LOCATION' in os.environ:
-        chrome_options.binary_location = os.environ['CHROME_BINARY_LOCATION']
-    return chrome_options
-
-
-@pytest.fixture
-def capabilities(capabilities: Dict) -> Dict:
-    """Configure the Chrome driver capabilities."""
-    capabilities['goog:loggingPrefs'] = {'browser': 'ALL'}
-    return capabilities
-
-
-@pytest.fixture
-def nicegui_reset_globals() -> Generator[None, None, None]:
-    """Reset the global state of the NiceGUI package."""
-    for route in app.routes:
-        if isinstance(route, Route) and route.path.startswith('/_nicegui/auto/static/'):
-            app.remove_route(route.path)
-    for path in {'/'}.union(Client.page_routes.values()):
-        app.remove_route(path)
-    app.openapi_schema = None
-    app.middleware_stack = None
-    app.user_middleware.clear()
-    app.urls.clear()
-    core.air = None
-    # NOTE favicon routes must be removed separately because they are not "pages"
-    for route in app.routes:
-        if isinstance(route, Route) and route.path.endswith('/favicon.ico'):
-            app.routes.remove(route)
-    importlib.reload(core)
-    importlib.reload(run)
-    element_classes: List[Type[ui.element]] = [ui.element]
-    while element_classes:
-        parent = element_classes.pop()
-        for cls in parent.__subclasses__():
-            cls._default_props = {}  # pylint: disable=protected-access
-            cls._default_style = {}  # pylint: disable=protected-access
-            cls._default_classes = []  # pylint: disable=protected-access
-            element_classes.append(cls)
-    Client.instances.clear()
-    Client.page_routes.clear()
-    app.reset()
-    Client.auto_index_client = Client(page('/'), request=None).__enter__()  # pylint: disable=unnecessary-dunder-call
-    # NOTE we need to re-add the auto index route because we removed all routes above
-    app.get('/')(Client.auto_index_client.build_response)
-    binding.reset()
-    yield
-
-
-@pytest.fixture(scope='session')
-def nicegui_remove_all_screenshots() -> None:
-    """Remove all screenshots from the screenshot directory before the test session."""
-    if os.path.exists(Screen.SCREENSHOT_DIR):
-        for name in os.listdir(Screen.SCREENSHOT_DIR):
-            os.remove(os.path.join(Screen.SCREENSHOT_DIR, name))
-
-
-@pytest.fixture()
-def nicegui_driver(nicegui_chrome_options: webdriver.ChromeOptions) -> Generator[webdriver.Chrome, None, None]:
-    """Create a new Chrome driver instance."""
-    s = Service()
-    driver_ = webdriver.Chrome(service=s, options=nicegui_chrome_options)
-    driver_.implicitly_wait(Screen.IMPLICIT_WAIT)
-    driver_.set_page_load_timeout(4)
-    yield driver_
-    driver_.quit()
-
-
-@pytest.fixture
-def screen(nicegui_reset_globals,  # pylint: disable=unused-argument
-           nicegui_remove_all_screenshots,  # pylint: disable=unused-argument
-           nicegui_driver: webdriver.Chrome,
-           request: pytest.FixtureRequest,
-           caplog: pytest.LogCaptureFixture,
-           ) -> Generator[Screen, None, None]:
-    """Create a new SeleniumScreen fixture."""
-    prepare_simulation(request)
-    screen_ = Screen(nicegui_driver, caplog)
-    yield screen_
-    logs = screen_.caplog.get_records('call')
-    if screen_.is_open:
-        screen_.shot(request.node.name)
-    screen_.stop_server()
-    if DOWNLOAD_DIR.exists():
-        shutil.rmtree(DOWNLOAD_DIR)
-    if logs:
-        pytest.fail('There were unexpected logs. See "Captured log call" below.', pytrace=False)
-
-
-@pytest.fixture
-async def user(nicegui_reset_globals,  # pylint: disable=unused-argument
-               prepare_simulated_auto_index_client,  # pylint: disable=unused-argument
-               request: pytest.FixtureRequest,
-               ) -> AsyncGenerator[User, None]:
-    """Create a new user fixture."""
-    prepare_simulation(request)
-    async with core.app.router.lifespan_context(core.app):
-        async with httpx.AsyncClient(app=core.app, base_url='http://test') as client:
-            yield User(client)
-    ui.navigate = Navigate()
-
-
-@pytest.fixture
-async def create_user(nicegui_reset_globals,  # pylint: disable=unused-argument
-                      prepare_simulated_auto_index_client,  # pylint: disable=unused-argument
-                      request: pytest.FixtureRequest,
-                      ) -> AsyncGenerator[Callable[[], User], None]:
-    """Create a fixture for building new users."""
-    prepare_simulation(request)
-    async with core.app.router.lifespan_context(core.app):
-        yield lambda: User(httpx.AsyncClient(app=core.app, base_url='http://test'))
-    ui.navigate = Navigate()
-
-
-@pytest.fixture()
-def prepare_simulated_auto_index_client(request):
-    """Prepare the simulated auto index client."""
-    original_test = request.node._obj  # pylint: disable=protected-access
-    if asyncio.iscoroutinefunction(original_test):
-        async def wrapped_test(*args, **kwargs):
-            with Client.auto_index_client:
-                return await original_test(*args, **kwargs)
-        request.node._obj = wrapped_test  # pylint: disable=protected-access
-    else:
-        def wrapped_test(*args, **kwargs):
-            Client.auto_index_client.__enter__()  # pylint: disable=unnecessary-dunder-call
-            return original_test(*args, **kwargs)
-        request.node._obj = wrapped_test  # pylint: disable=protected-access
-
-
-def prepare_simulation(request: pytest.FixtureRequest) -> None:
-    """Prepare a simulation to be started.
-
-    By using the "module_under_test" marker you can specify the main entry point of the app.
-    """
-    marker = request.node.get_closest_marker('module_under_test')
-    if marker is not None:
-        with Client.auto_index_client:
-            importlib.reload(marker.args[0])
-
-    core.app.config.add_run_config(
-        reload=False,
-        title='Test App',
-        viewport='',
-        favicon=None,
-        dark=False,
-        language='en-US',
-        binding_refresh_interval=0.1,
-        reconnect_timeout=3.0,
-        tailwind=True,
-        prod_js=True,
-        show_welcome_message=False,
-    )
-    nicegui.storage.set_storage_secret('simulated secret')
+# pylint: disable=unused-import
+from .general_fixtures import nicegui_reset_globals, pytest_configure  # noqa: F401
+from .screen_plugin import nicegui_chrome_options, nicegui_driver, nicegui_remove_all_screenshots, screen  # noqa: F401
+from .user_plugin import create_user, prepare_simulated_auto_index_client, user  # noqa: F401

+ 84 - 0
nicegui/testing/screen_plugin.py

@@ -0,0 +1,84 @@
+import os
+import shutil
+from pathlib import Path
+from typing import Dict, Generator
+
+import pytest
+from selenium import webdriver
+from selenium.webdriver.chrome.service import Service
+
+from .general_fixtures import (  # noqa: F401  # pylint: disable=unused-import
+    nicegui_reset_globals,
+    prepare_simulation,
+    pytest_configure,
+)
+from .screen import Screen
+
+# pylint: disable=redefined-outer-name
+
+DOWNLOAD_DIR = Path(__file__).parent / 'download'
+
+
+@pytest.fixture
+def nicegui_chrome_options(chrome_options: webdriver.ChromeOptions) -> webdriver.ChromeOptions:
+    """Configure the Chrome options for the NiceGUI tests."""
+    chrome_options.add_argument('disable-dev-shm-usage')
+    chrome_options.add_argument('no-sandbox')
+    chrome_options.add_argument('headless')
+    chrome_options.add_argument('disable-gpu' if 'GITHUB_ACTIONS' in os.environ else '--use-gl=angle')
+    chrome_options.add_argument('window-size=600x600')
+    chrome_options.add_experimental_option('prefs', {
+        'download.default_directory': str(DOWNLOAD_DIR),
+        'download.prompt_for_download': False,  # To auto download the file
+        'download.directory_upgrade': True,
+    })
+    if 'CHROME_BINARY_LOCATION' in os.environ:
+        chrome_options.binary_location = os.environ['CHROME_BINARY_LOCATION']
+    return chrome_options
+
+
+@pytest.fixture
+def capabilities(capabilities: Dict) -> Dict:
+    """Configure the Chrome driver capabilities."""
+    capabilities['goog:loggingPrefs'] = {'browser': 'ALL'}
+    return capabilities
+
+
+@pytest.fixture(scope='session')
+def nicegui_remove_all_screenshots() -> None:
+    """Remove all screenshots from the screenshot directory before the test session."""
+    if os.path.exists(Screen.SCREENSHOT_DIR):
+        for name in os.listdir(Screen.SCREENSHOT_DIR):
+            os.remove(os.path.join(Screen.SCREENSHOT_DIR, name))
+
+
+@pytest.fixture()
+def nicegui_driver(nicegui_chrome_options: webdriver.ChromeOptions) -> Generator[webdriver.Chrome, None, None]:
+    """Create a new Chrome driver instance."""
+    s = Service()
+    driver_ = webdriver.Chrome(service=s, options=nicegui_chrome_options)
+    driver_.implicitly_wait(Screen.IMPLICIT_WAIT)
+    driver_.set_page_load_timeout(4)
+    yield driver_
+    driver_.quit()
+
+
+@pytest.fixture
+def screen(nicegui_reset_globals,  # noqa: F811, pylint: disable=unused-argument
+           nicegui_remove_all_screenshots,  # pylint: disable=unused-argument
+           nicegui_driver: webdriver.Chrome,
+           request: pytest.FixtureRequest,
+           caplog: pytest.LogCaptureFixture,
+           ) -> Generator[Screen, None, None]:
+    """Create a new SeleniumScreen fixture."""
+    prepare_simulation(request)
+    screen_ = Screen(nicegui_driver, caplog)
+    yield screen_
+    logs = screen_.caplog.get_records('call')
+    if screen_.is_open:
+        screen_.shot(request.node.name)
+    screen_.stop_server()
+    if DOWNLOAD_DIR.exists():
+        shutil.rmtree(DOWNLOAD_DIR)
+    if logs:
+        pytest.fail('There were unexpected logs. See "Captured log call" below.', pytrace=False)

+ 9 - 6
nicegui/testing/user.py

@@ -2,7 +2,7 @@ from __future__ import annotations
 
 import asyncio
 import re
-from typing import List, Optional, Set, Type, TypeVar, Union, overload
+from typing import Any, List, Optional, Set, Type, TypeVar, Union, overload
 from uuid import uuid4
 
 import httpx
@@ -14,6 +14,7 @@ from nicegui.nicegui import _on_handshake
 
 from .user_interaction import UserInteraction
 from .user_navigate import UserNavigate
+from .user_notify import UserNotify
 
 # pylint: disable=protected-access
 
@@ -31,10 +32,12 @@ class User:
         self.back_history: List[str] = []
         self.forward_history: List[str] = []
         self.navigate = UserNavigate(self)
+        self.notify = UserNotify()
 
-    def __getattribute__(self, name: str) -> asyncio.Any:
-        if name != 'navigate':  # NOTE: avoid infinite recursion
+    def __getattribute__(self, name: str) -> Any:
+        if name not in {'notify', 'navigate'}:  # NOTE: avoid infinite recursion
             ui.navigate = self.navigate
+            ui.notify = self.notify
         return super().__getattribute__(name)
 
     async def open(self, path: str, *, clear_forward_history: bool = True) -> None:
@@ -91,7 +94,7 @@ class User:
         assert self.client
         for _ in range(retries):
             with self.client:
-                if self._gather_elements(target, kind, marker, content):
+                if self.notify.contains(target) or self._gather_elements(target, kind, marker, content):
                     return
                 await asyncio.sleep(0.1)
         raise AssertionError('expected to see at least one ' + self._build_error_message(target, kind, marker, content))
@@ -126,7 +129,7 @@ class User:
         assert self.client
         for _ in range(retries):
             with self.client:
-                if not self._gather_elements(target, kind, marker, content):
+                if not self.notify.contains(target) and not self._gather_elements(target, kind, marker, content):
                     return
                 await asyncio.sleep(0.05)
         raise AssertionError('expected not to see any ' + self._build_error_message(target, kind, marker, content))
@@ -174,7 +177,7 @@ class User:
             if not elements:
                 raise AssertionError('expected to find at least one ' +
                                      self._build_error_message(target, kind, marker, content))
-        return UserInteraction(self, elements)
+        return UserInteraction(self, elements, target)
 
     @property
     def current_layout(self) -> Element:

+ 9 - 2
nicegui/testing/user_interaction.py

@@ -1,6 +1,6 @@
 from __future__ import annotations
 
-from typing import TYPE_CHECKING, Generic, Set, TypeVar
+from typing import TYPE_CHECKING, Generic, Set, Type, TypeVar, Union
 
 from typing_extensions import Self
 
@@ -15,7 +15,7 @@ T = TypeVar('T', bound=Element)
 
 class UserInteraction(Generic[T]):
 
-    def __init__(self, user: User, elements: Set[T]) -> None:
+    def __init__(self, user: User, elements: Set[T], target: Union[str, Type[T], None]) -> None:
         """Interaction object of the simulated user.
 
         This will be returned by the ``find`` method of the ``user`` fixture in pytests.
@@ -25,6 +25,7 @@ class UserInteraction(Generic[T]):
         for element in elements:
             assert isinstance(element, ui.element)
         self.elements = elements
+        self.target = target
 
     def trigger(self, event: str) -> Self:
         """Trigger the given event on the elements selected by the simulated user.
@@ -59,6 +60,12 @@ class UserInteraction(Generic[T]):
                     href = element._props.get('href', '#')  # pylint: disable=protected-access
                     background_tasks.create(self.user.open(href))
                     return self
+                if isinstance(element, ui.select):
+                    if element.is_showing_popup:
+                        assert isinstance(self.target, str), 'Target must be string when clicking on ui.select options'
+                        element.set_value(self.target)
+                    element._is_showing_popup = not element.is_showing_popup  # pylint: disable=protected-access
+                    return self
                 for listener in element._event_listeners.values():  # pylint: disable=protected-access
                     if listener.element_id != element.id:
                         continue

+ 14 - 0
nicegui/testing/user_notify.py

@@ -0,0 +1,14 @@
+from typing import Any, List
+
+
+class UserNotify:
+
+    def __init__(self) -> None:
+        self.messages: List[str] = []
+
+    def __call__(self, message: str, **kwargs) -> None:
+        self.messages.append(message)
+
+    def contains(self, needle: Any) -> bool:
+        """Check if any of the messages contain the given substring."""
+        return isinstance(needle, str) and any(needle in message for message in self.messages)

+ 61 - 0
nicegui/testing/user_plugin.py

@@ -0,0 +1,61 @@
+import asyncio
+from typing import AsyncGenerator, Callable
+
+import httpx
+import pytest
+
+from nicegui import Client, core, ui
+from nicegui.functions.navigate import Navigate
+from nicegui.functions.notify import notify
+
+from .general_fixtures import (  # noqa: F401  # pylint: disable=unused-import
+    nicegui_reset_globals,
+    prepare_simulation,
+    pytest_configure,
+)
+from .user import User
+
+# pylint: disable=redefined-outer-name
+
+
+@pytest.fixture()
+def prepare_simulated_auto_index_client(request):
+    """Prepare the simulated auto index client."""
+    original_test = request.node._obj  # pylint: disable=protected-access
+    if asyncio.iscoroutinefunction(original_test):
+        async def wrapped_test(*args, **kwargs):
+            with Client.auto_index_client:
+                return await original_test(*args, **kwargs)
+        request.node._obj = wrapped_test  # pylint: disable=protected-access
+    else:
+        def wrapped_test(*args, **kwargs):
+            Client.auto_index_client.__enter__()  # pylint: disable=unnecessary-dunder-call
+            return original_test(*args, **kwargs)
+        request.node._obj = wrapped_test  # pylint: disable=protected-access
+
+
+@pytest.fixture
+async def user(nicegui_reset_globals,  # noqa: F811, pylint: disable=unused-argument
+               prepare_simulated_auto_index_client,  # pylint: disable=unused-argument
+               request: pytest.FixtureRequest,
+               ) -> AsyncGenerator[User, None]:
+    """Create a new user fixture."""
+    prepare_simulation(request)
+    async with core.app.router.lifespan_context(core.app):
+        async with httpx.AsyncClient(app=core.app, base_url='http://test') as client:
+            yield User(client)
+    ui.navigate = Navigate()
+    ui.notify = notify
+
+
+@pytest.fixture
+async def create_user(nicegui_reset_globals,  # noqa: F811, pylint: disable=unused-argument
+                      prepare_simulated_auto_index_client,  # pylint: disable=unused-argument
+                      request: pytest.FixtureRequest,
+                      ) -> AsyncGenerator[Callable[[], User], None]:
+    """Create a fixture for building new users."""
+    prepare_simulation(request)
+    async with core.app.router.lifespan_context(core.app):
+        yield lambda: User(httpx.AsyncClient(app=core.app, base_url='http://test'))
+    ui.navigate = Navigate()
+    ui.notify = notify

Plik diff jest za duży
+ 413 - 451
poetry.lock


+ 5 - 5
pyproject.toml

@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "nicegui"
-version = "1.4.34-dev"
+version = "1.4.35-dev"
 description = "Create web-based user interfaces with Python. The nice way."
 authors = ["Zauberzeug GmbH <info@zauberzeug.com>"]
 license = "MIT"
@@ -29,9 +29,9 @@ matplotlib = { version = "^3.5.0", optional = true }
 httpx = ">=0.24.0"
 nicegui-highcharts = { version = "^1.0.1", optional = true }
 ifaddr = ">=0.2.0"
-aiohttp = ">=3.9.2" # https://github.com/zauberzeug/nicegui/security/dependabot/25 and 26
+aiohttp = ">=3.10.2" # https://github.com/zauberzeug/nicegui/security/dependabot/36
 libsass = { version = "^0.23.0", optional = true }
-docutils = "^0.19"
+docutils = ">=0.19.0"
 requests = ">=2.32.0" # https://github.com/zauberzeug/nicegui/security/dependabot/33
 urllib3 = ">=1.26.18,!=2.0.0,!=2.0.1,!=2.0.2,!=2.0.3,!=2.0.4,!=2.0.5,!=2.0.6,!=2.0.7,!=2.1.0,!=2.2.0,!=2.2.1" # https://github.com/zauberzeug/nicegui/security/dependabot/34
 certifi = ">=2024.07.04" # https://github.com/zauberzeug/nicegui/security/dependabot/35
@@ -44,7 +44,7 @@ highcharts = ["nicegui-highcharts"]
 sass = ["libsass"]
 
 [tool.poetry.group.dev.dependencies]
-autopep8 = "^1.5.7"
+autopep8 = ">=1.5.7,<3.0.0"
 debugpy = "^1.3.0"
 pytest-selenium = "^4.1.0"
 pytest-asyncio = ">=0.23.0"
@@ -53,7 +53,7 @@ pytest = "^8.2.2"
 itsdangerous = "^2.1.2" # required by SessionMiddleware (see https://fastapi.tiangolo.com/?h=itsdangerous#optional-dependencies)
 pandas = "^2.0.0"
 secure = ">=0.3.0"
-webdriver-manager = "^3.8.6"
+webdriver-manager = ">=3.8.6,<5.0.0"
 numpy = [
     {version = "^1.24.0", python = "~3.8"},
     {version = "^1.26.0", python = ">=3.9,<3.13"}

+ 2 - 2
release.dockerfile

@@ -1,4 +1,4 @@
-FROM python:3.11.3-slim as builder
+FROM python:3.11-slim as builder
 
 RUN apt-get update && \
     DEBIAN_FRONTEND=noninteractive TZ=Etc/UTC \
@@ -11,7 +11,7 @@ RUN python -m pip install --upgrade pip
 
 RUN python -m pip install --upgrade libsass
 
-FROM python:3.11.3-slim as release
+FROM python:3.11-slim as release
 COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
 ARG VERSION
 

+ 4 - 4
tests/test_download.py

@@ -5,7 +5,7 @@ import pytest
 from fastapi.responses import PlainTextResponse
 
 from nicegui import app, ui
-from nicegui.testing import Screen, plugin
+from nicegui.testing import Screen, screen_plugin
 
 
 @pytest.fixture
@@ -25,7 +25,7 @@ def test_download_text_file(screen: Screen, test_route: str):  # pylint: disable
     screen.open('/')
     screen.click('Download')
     screen.wait(0.5)
-    assert (plugin.DOWNLOAD_DIR / 'test.txt').read_text() == 'test'
+    assert (screen_plugin.DOWNLOAD_DIR / 'test.txt').read_text() == 'test'
 
 
 def test_downloading_local_file_as_src(screen: Screen):
@@ -36,7 +36,7 @@ def test_downloading_local_file_as_src(screen: Screen):
     route_count_before_download = len(app.routes)
     screen.click('download')
     screen.wait(0.5)
-    assert (plugin.DOWNLOAD_DIR / 'slide1.jpg').exists()
+    assert (screen_plugin.DOWNLOAD_DIR / 'slide1.jpg').exists()
     assert len(app.routes) == route_count_before_download
 
 
@@ -46,4 +46,4 @@ def test_download_raw_data(screen: Screen):
     screen.open('/')
     screen.click('download')
     screen.wait(0.5)
-    assert (plugin.DOWNLOAD_DIR / 'test.txt').read_text() == 'test'
+    assert (screen_plugin.DOWNLOAD_DIR / 'test.txt').read_text() == 'test'

+ 11 - 4
tests/test_events.py

@@ -163,19 +163,26 @@ def test_throttling_variants(screen: Screen):
 
 @pytest.mark.parametrize('attribute', ['disabled', 'hidden'])
 def test_server_side_validation(screen: Screen, attribute: Literal['disabled', 'hidden']):
-    b = ui.button('Button', on_click=lambda: ui.label('Success'))
+    b = ui.button('Button', on_click=lambda: ui.label('Button clicked'))
+    n = ui.number('Number', on_change=lambda: ui.label('Number changed'))
     if attribute == 'disabled':
         b.disable()
+        n.disable()
     else:
         b.set_visibility(False)
-    ui.button('Hack', on_click=lambda: ui.run_javascript(f'''
+        n.set_visibility(False)
+    ui.button('Forbidden', on_click=lambda: ui.run_javascript(f'''
         getElement({b.id}).$emit("click", {{"id": {b.id}, "listener_id": "{next(iter(b._event_listeners))}"}});
     '''))  # pylint: disable=protected-access
+    ui.button('Allowed', on_click=lambda: n.set_value(42))
 
     screen.open('/')
-    screen.click('Hack')
+    screen.click('Forbidden')
     screen.wait(0.5)
-    screen.should_not_contain('Success')
+    screen.should_not_contain('Button clicked')  # triggering the click event through JavaScript does not work
+
+    screen.click('Allowed')
+    screen.should_contain('Number changed')  # triggering the change event through Python works
 
 
 def test_js_handler(screen: Screen) -> None:

+ 63 - 0
tests/test_tree.py

@@ -62,3 +62,66 @@ def test_expand_and_collapse_nodes(screen: Screen):
     screen.should_not_contain('2')
     screen.should_contain('A')
     screen.should_contain('B')
+
+
+def test_select_deselect_node(screen: Screen):
+    tree = ui.tree([
+        {'id': 'numbers', 'children': [{'id': '1'}, {'id': '2'}]},
+        {'id': 'letters', 'children': [{'id': 'A'}, {'id': 'B'}]},
+    ], label_key='id')
+
+    ui.button('Select', on_click=lambda: tree.select('2'))
+    ui.button('Deselect', on_click=tree.deselect)
+    ui.label().bind_text_from(tree._props, 'selected', lambda x: f'Selected: {x}')
+
+    screen.open('/')
+    screen.click('Select')
+    screen.should_contain('Selected: 2')
+
+    screen.click('Deselect')
+    screen.should_contain('Selected: None')
+
+
+def test_tick_untick_node_or_nodes(screen: Screen):
+    tree = ui.tree([
+        {'id': 'numbers', 'children': [{'id': '1'}, {'id': '2'}]},
+        {'id': 'letters', 'children': [{'id': 'A'}, {'id': 'B'}]},
+    ], label_key='id', tick_strategy='leaf')
+
+    ui.button('Tick some', on_click=lambda: tree.tick(['1', '2', 'B']))
+    ui.button('Untick some', on_click=lambda: tree.untick(['1', 'B']))
+    ui.button('Tick all', on_click=tree.tick)
+    ui.button('Untick all', on_click=tree.untick)
+    ui.label().bind_text_from(tree._props, 'ticked', lambda x: f'Ticked: {sorted(x)}')
+
+    screen.open('/')
+    screen.should_contain('Ticked: []')
+
+    screen.click('Tick some')
+    screen.should_contain("Ticked: ['1', '2', 'B']")
+
+    screen.click('Untick some')
+    screen.should_contain("Ticked: ['2']")
+
+    screen.click('Tick all')
+    screen.should_contain("Ticked: ['1', '2', 'A', 'B', 'letters', 'numbers']")
+
+    screen.click('Untick all')
+    screen.should_contain('Ticked: []')
+
+
+def test_filter(screen: Screen):
+    t = ui.tree([
+        {'id': 'fruits', 'children': [{'id': 'Apple'}, {'id': 'Banana'}, {'id': 'Cherry'}]},
+    ], label_key='id', tick_strategy='leaf-filtered').expand()
+    ui.button('Filter', on_click=lambda: t.set_filter('a'))
+
+    screen.open('/')
+    screen.should_contain('Apple')
+    screen.should_contain('Banana')
+    screen.should_contain('Cherry')
+
+    screen.click('Filter')
+    screen.should_contain('Apple')
+    screen.should_contain('Banana')
+    screen.should_not_contain('Cherry')

+ 17 - 0
tests/test_user_simulation.py

@@ -333,3 +333,20 @@ async def test_typing(user: User) -> None:
     _ = user.find('World').elements  # Set[ui.element]
     _ = user.find('Hello').elements  # Set[ui.element]
     _ = user.find('!').elements  # Set[ui.element]
+
+
+async def test_select(user: User) -> None:
+    ui.select(options=['A', 'B', 'C'], on_change=lambda e: ui.notify(f'Value: {e.value}'))
+
+    await user.open('/')
+    await user.should_not_see('A')
+    await user.should_not_see('B')
+    await user.should_not_see('C')
+    user.find(ui.select).click()
+    await user.should_see('B')
+    await user.should_see('C')
+    user.find('A').click()
+    await user.should_see('Value: A')
+    await user.should_see('A')
+    await user.should_not_see('B')
+    await user.should_not_see('C')

+ 15 - 10
website/documentation/content/aggrid_documentation.py

@@ -224,18 +224,23 @@ def aggrid_run_row_method():
 @doc.demo('Filter return values', '''
     You can filter the return values of method calls by passing string that defines a JavaScript function.
     This demo runs the grid method "getDisplayedRowAtIndex" and returns the "data" property of the result.
+
+    Note that requesting data from the client is only supported for page functions, not for the shared auto-index page.
 ''')
 def aggrid_filter_return_values():
-    grid = ui.aggrid({
-        'columnDefs': [{'field': 'name'}],
-        'rowData': [{'name': 'Alice'}, {'name': 'Bob'}],
-    })
-
-    async def get_first_name() -> None:
-        row = await grid.run_grid_method('(g) => g.getDisplayedRowAtIndex(0).data')
-        ui.notify(row['name'])
-
-    ui.button('Get First Name', on_click=get_first_name)
+    # @ui.page('/')
+    def page():
+        grid = ui.aggrid({
+            'columnDefs': [{'field': 'name'}],
+            'rowData': [{'name': 'Alice'}, {'name': 'Bob'}],
+        })
+
+        async def get_first_name() -> None:
+            row = await grid.run_grid_method('g => g.getDisplayedRowAtIndex(0).data')
+            ui.notify(row['name'])
+
+        ui.button('Get First Name', on_click=get_first_name)
+    page()  # HIDE
 
 
 doc.reference(ui.aggrid)

+ 22 - 17
website/documentation/content/echart_documentation.py

@@ -79,25 +79,30 @@ def echart_from_pyecharts_demo():
 
     The colon ":" in front of the method name "setOption" indicates that the argument is a JavaScript expression
     that is evaluated on the client before it is passed to the method.
+
+    Note that requesting data from the client is only supported for page functions, not for the shared auto-index page.
 ''')
 def methods_demo() -> None:
-    echart = ui.echart({
-        'xAxis': {'type': 'category', 'data': ['Mon', 'Tue', 'Wed', 'Thu', 'Fri']},
-        'yAxis': {'type': 'value'},
-        'series': [{'type': 'line', 'data': [150, 230, 224, 218, 135]}],
-    })
-
-    ui.button('Show Loading', on_click=lambda: echart.run_chart_method('showLoading'))
-    ui.button('Hide Loading', on_click=lambda: echart.run_chart_method('hideLoading'))
-
-    async def get_width():
-        width = await echart.run_chart_method('getWidth')
-        ui.notify(f'Width: {width}')
-    ui.button('Get Width', on_click=get_width)
-
-    ui.button('Set Tooltip', on_click=lambda: echart.run_chart_method(
-        ':setOption', r'{tooltip: {formatter: params => "$" + params.value}}',
-    ))
+    # @ui.page('/')
+    def page():
+        echart = ui.echart({
+            'xAxis': {'type': 'category', 'data': ['Mon', 'Tue', 'Wed', 'Thu', 'Fri']},
+            'yAxis': {'type': 'value'},
+            'series': [{'type': 'line', 'data': [150, 230, 224, 218, 135]}],
+        })
+
+        ui.button('Show Loading', on_click=lambda: echart.run_chart_method('showLoading'))
+        ui.button('Hide Loading', on_click=lambda: echart.run_chart_method('hideLoading'))
+
+        async def get_width():
+            width = await echart.run_chart_method('getWidth')
+            ui.notify(f'Width: {width}')
+        ui.button('Get Width', on_click=get_width)
+
+        ui.button('Set Tooltip', on_click=lambda: echart.run_chart_method(
+            ':setOption', r'{tooltip: {formatter: params => "$" + params.value}}',
+        ))
+    page()  # HIDE
 
 
 @doc.demo('Arbitrary chart events', '''

+ 23 - 18
website/documentation/content/json_editor_documentation.py

@@ -29,26 +29,31 @@ def main_demo() -> None:
 
     The colon ":" in front of the method name "expand" indicates that the value "path => true" is a JavaScript expression
     that is evaluated on the client before it is passed to the method.
+
+    Note that requesting data from the client is only supported for page functions, not for the shared auto-index page.
 ''')
 def methods_demo() -> None:
-    json = {
-        'Name': 'Alice',
-        'Age': 42,
-        'Address': {
-            'Street': 'Main Street',
-            'City': 'Wonderland',
-        },
-    }
-    editor = ui.json_editor({'content': {'json': json}})
-
-    ui.button('Expand', on_click=lambda: editor.run_editor_method(':expand', 'path => true'))
-    ui.button('Collapse', on_click=lambda: editor.run_editor_method(':expand', 'path => false'))
-    ui.button('Readonly', on_click=lambda: editor.run_editor_method('updateProps', {'readOnly': True}))
-
-    async def get_data() -> None:
-        data = await editor.run_editor_method('get')
-        ui.notify(data)
-    ui.button('Get Data', on_click=get_data)
+    # @ui.page('/')
+    def page():
+        json = {
+            'Name': 'Alice',
+            'Age': 42,
+            'Address': {
+                'Street': 'Main Street',
+                'City': 'Wonderland',
+            },
+        }
+        editor = ui.json_editor({'content': {'json': json}})
+
+        ui.button('Expand', on_click=lambda: editor.run_editor_method(':expand', 'path => true'))
+        ui.button('Collapse', on_click=lambda: editor.run_editor_method(':expand', 'path => false'))
+        ui.button('Readonly', on_click=lambda: editor.run_editor_method('updateProps', {'readOnly': True}))
+
+        async def get_data() -> None:
+            data = await editor.run_editor_method('get')
+            ui.notify(data)
+        ui.button('Get Data', on_click=get_data)
+    page()  # HIDE
 
 
 doc.reference(ui.json_editor)

+ 5 - 3
website/documentation/content/project_structure_documentation.py

@@ -9,6 +9,8 @@ doc.text('Project Structure', '''
     This makes specialized [fixtures](https://docs.pytest.org/en/stable/explanation/fixtures.html) available for testing your NiceGUI user interface.
     With the [`screen` fixture](/documentation/screen) you can run the tests through a headless browser (slow)
     and with the [`user` fixture](/documentation/user) fully simulated in Python (fast).
+    If you only want one kind of test fixtures,
+    you can also use the plugin `nicegui.testing.user_plugin` or `nicegui.testing.screen_plugin`.
 
     There are a multitude of ways to structure your project and tests.
     Here we only present two approaches which we found useful,
@@ -59,7 +61,7 @@ def simple_project_code():
                 from nicegui.testing import User
                 from . import main
 
-                pytest_plugins = ['nicegui.testing.plugin']
+                pytest_plugins = ['nicegui.testing.user_plugin']
 
                 @pytest.mark.module_under_test(main)
                 async def test_click(user: User) -> None:
@@ -136,7 +138,7 @@ def modular_project():
                 from nicegui.testing import User
                 from app.startup import startup
 
-                pytest_plugins = ['nicegui.testing.plugin']
+                pytest_plugins = ['nicegui.testing.user_plugin']
 
                 async def test_click(user: User) -> None:
                     startup()
@@ -197,7 +199,7 @@ def custom_user_fixture():
                 from nicegui.testing import User
                 from app.startup import startup
 
-                pytest_plugins = ['nicegui.testing.plugin']
+                pytest_plugins = ['nicegui.testing.user_plugin']
 
                 @pytest.fixture
                 def user(user: User) -> User:

+ 40 - 34
website/documentation/content/run_javascript_documentation.py

@@ -5,20 +5,23 @@ from . import doc
 
 @doc.demo(ui.run_javascript)
 def main_demo() -> None:
-    def alert():
-        ui.run_javascript('alert("Hello!")')
+    # @ui.page('/')
+    def page():
+        def alert():
+            ui.run_javascript('alert("Hello!")')
 
-    async def get_date():
-        time = await ui.run_javascript('Date()')
-        ui.notify(f'Browser time: {time}')
+        async def get_date():
+            time = await ui.run_javascript('Date()')
+            ui.notify(f'Browser time: {time}')
 
-    def access_elements():
-        ui.run_javascript(f'getElement({label.id}).innerText += " Hello!"')
+        def access_elements():
+            ui.run_javascript(f'getElement({label.id}).innerText += " Hello!"')
 
-    ui.button('fire and forget', on_click=alert)
-    ui.button('receive result', on_click=get_date)
-    ui.button('access elements', on_click=access_elements)
-    label = ui.label()
+        ui.button('fire and forget', on_click=alert)
+        ui.button('receive result', on_click=get_date)
+        ui.button('access elements', on_click=access_elements)
+        label = ui.label()
+    page()  # HIDE
 
 
 @doc.demo('Run async JavaScript', '''
@@ -26,26 +29,29 @@ def main_demo() -> None:
     The following demo shows how to get the current location of the user.
 ''')
 def run_async_javascript():
-    async def show_location():
-        response = await ui.run_javascript('''
-            return await new Promise((resolve, reject) => {
-                if (!navigator.geolocation) {
-                    reject(new Error('Geolocation is not supported by your browser'));
-                } else {
-                    navigator.geolocation.getCurrentPosition(
-                        (position) => {
-                            resolve({
-                                latitude: position.coords.latitude,
-                                longitude: position.coords.longitude,
-                            });
-                        },
-                        () => {
-                            reject(new Error('Unable to retrieve your location'));
-                        }
-                    );
-                }
-            });
-        ''', timeout=5.0)
-        ui.notify(f'Your location is {response["latitude"]}, {response["longitude"]}')
-
-    ui.button('Show location', on_click=show_location)
+    # @ui.page('/')
+    def page():
+        async def show_location():
+            response = await ui.run_javascript('''
+                return await new Promise((resolve, reject) => {
+                    if (!navigator.geolocation) {
+                        reject(new Error('Geolocation is not supported by your browser'));
+                    } else {
+                        navigator.geolocation.getCurrentPosition(
+                            (position) => {
+                                resolve({
+                                    latitude: position.coords.latitude,
+                                    longitude: position.coords.longitude,
+                                });
+                            },
+                            () => {
+                                reject(new Error('Unable to retrieve your location'));
+                            }
+                        );
+                    }
+                });
+            ''', timeout=5.0)
+            ui.notify(f'Your location is {response["latitude"]}, {response["longitude"]}')
+
+        ui.button('Show location', on_click=show_location)
+    page()  # HIDE

+ 81 - 3
website/documentation/content/scene_documentation.py

@@ -60,6 +60,35 @@ def click_events() -> None:
         scene.box().move(x=1, z=1).with_name('box')
 
 
+@doc.demo('Context menu for 3D objects', '''
+    This demo shows how to create a context menu for 3D objects.
+    By setting the `click_events` argument to `['contextmenu']`, the `handle_click` function will be called on right-click.
+    It clears the context menu and adds items based on the object that was clicked.
+''')
+def context_menu_for_3d_objects():
+    from nicegui import events
+
+    def handle_click(e: events.SceneClickEventArguments) -> None:
+        context_menu.clear()
+        name = next((hit.object_name for hit in e.hits if hit.object_name), None)
+        with context_menu:
+            if name == 'sphere':
+                ui.item('SPHERE').classes('font-bold')
+                ui.menu_item('inspect')
+                ui.menu_item('open')
+            if name == 'box':
+                ui.item('BOX').classes('font-bold')
+                ui.menu_item('rotate')
+                ui.menu_item('move')
+
+    with ui.element():
+        context_menu = ui.context_menu()
+        with ui.scene(width=285, height=220, on_click=handle_click,
+                      click_events=['contextmenu']) as scene:
+            scene.sphere().move(x=-1, z=1).with_name('sphere')
+            scene.box().move(x=1, z=1).with_name('box')
+
+
 @doc.demo('Draggable objects', '''
     You can make objects draggable using the `.draggable` method.
     There is an optional `on_drag_start` and `on_drag_end` argument to `ui.scene` to handle drag events.
@@ -88,18 +117,49 @@ def draggable_objects() -> None:
               on_change=lambda e: sphere.draggable(e.value))
 
 
+@doc.demo('Subscribe to the drag event', '''
+    By default, a draggable object is only updated when the drag ends to avoid performance issues.
+    But you can explicitly subscribe to the "drag" event to get immediate updates.
+    In this demo we update the position and size of a box based on the positions of two draggable spheres.
+''')
+def immediate_updates() -> None:
+    from nicegui import events
+
+    with ui.scene(drag_constraints='z=0') as scene:
+        box = scene.box(1, 1, 0.2).move(0, 0).material('Orange')
+        sphere1 = scene.sphere(0.2).move(0.5, -0.5).material('SteelBlue').draggable()
+        sphere2 = scene.sphere(0.2).move(-0.5, 0.5).material('SteelBlue').draggable()
+
+    def handle_drag(e: events.GenericEventArguments) -> None:
+        x1 = sphere1.x if e.args['object_id'] == sphere2.id else e.args['x']
+        y1 = sphere1.y if e.args['object_id'] == sphere2.id else e.args['y']
+        x2 = sphere2.x if e.args['object_id'] == sphere1.id else e.args['x']
+        y2 = sphere2.y if e.args['object_id'] == sphere1.id else e.args['y']
+        box.move((x1 + x2) / 2, (y1 + y2) / 2).scale(x2 - x1, y2 - y1, 1)
+    scene.on('drag', handle_drag)
+
+
 @doc.demo('Rendering point clouds', '''
     You can render point clouds using the `point_cloud` method.
     The `points` argument is a list of point coordinates, and the `colors` argument is a list of RGB colors (0..1).
+    You can update the cloud using its `set_points()` method.
 ''')
 def point_clouds() -> None:
     import numpy as np
 
-    with ui.scene().classes('w-full h-64') as scene:
+    def generate_data(frequency: float = 1.0):
         x, y = np.meshgrid(np.linspace(-3, 3), np.linspace(-3, 3))
-        z = np.sin(x) * np.cos(y) + 1
+        z = np.sin(x * frequency) * np.cos(y * frequency) + 1
         points = np.dstack([x, y, z]).reshape(-1, 3)
-        scene.point_cloud(points=points, colors=points, point_size=0.1)
+        colors = points / [6, 6, 2] + [0.5, 0.5, 0]
+        return points, colors
+
+    with ui.scene().classes('w-full h-64') as scene:
+        points, colors = generate_data()
+        point_cloud = scene.point_cloud(points, colors, point_size=0.1)
+
+    ui.slider(min=0.1, max=3, step=0.1, value=1) \
+        .on_value_change(lambda e: point_cloud.set_points(*generate_data(e.value)))
 
 
 @doc.demo('Wait for Initialization', '''
@@ -137,6 +197,24 @@ def orthographic_camera() -> None:
         scene.box()
 
 
+@doc.demo('Get current camera pose', '''
+    Using the `get_camera` method you can get a dictionary of current camera parameters like position, rotation, field of view and more.
+    This demo shows how to continuously move a sphere towards the camera.
+    Try moving the camera around to see the sphere following it.
+''')
+def camera_pose() -> None:
+    with ui.scene().classes('w-full h-64') as scene:
+        ball = scene.sphere()
+
+    async def move():
+        camera = await scene.get_camera()
+        if camera is not None:
+            ball.move(x=0.95 * ball.x + 0.05 * camera['position']['x'],
+                      y=0.95 * ball.y + 0.05 * camera['position']['y'],
+                      z=1.0)
+    ui.timer(0.1, move)
+
+
 @doc.demo('Custom Background', '''
     You can set a custom background color using the `background_color` parameter of `ui.scene`.
 ''')

+ 52 - 8
website/documentation/content/tree_documentation.py

@@ -35,7 +35,17 @@ def tree_with_custom_header_and_body():
     ''')
 
 
-@doc.demo('Expand and collapse programmatically', '''
+@doc.demo('Tree with checkboxes', '''
+    The tree can be used with checkboxes by setting the "tick-strategy" prop.
+''')
+def tree_with_checkboxes():
+    ui.tree([
+        {'id': 'A', 'children': [{'id': 'A1'}, {'id': 'A2'}]},
+        {'id': 'B', 'children': [{'id': 'B1'}, {'id': 'B2'}]},
+    ], label_key='id', tick_strategy='leaf', on_tick=lambda e: ui.notify(e.value))
+
+
+@doc.demo('Expand/collapse programmatically', '''
     The whole tree or individual nodes can be toggled programmatically using the `expand()` and `collapse()` methods.
     This even works if a node is disabled (e.g. not clickable by the user).
 ''')
@@ -52,14 +62,48 @@ def expand_programmatically():
         ui.button('- A', on_click=lambda: t.collapse(['A']))
 
 
-@doc.demo('Tree with checkboxes', '''
-    The tree can be used with checkboxes by setting the "tick-strategy" prop.
+@doc.demo('Select/deselect programmatically', '''
+    You can select or deselect nodes with the `select()` and `deselect()` methods.
 ''')
-def tree_with_checkboxes():
-    ui.tree([
-        {'id': 'A', 'children': [{'id': 'A1'}, {'id': 'A2'}]},
-        {'id': 'B', 'children': [{'id': 'B1'}, {'id': 'B2'}]},
-    ], label_key='id', tick_strategy='leaf', on_tick=lambda e: ui.notify(e.value))
+def select_programmatically():
+    t = ui.tree([
+        {'id': 'numbers', 'children': [{'id': '1'}, {'id': '2'}]},
+        {'id': 'letters', 'children': [{'id': 'A'}, {'id': 'B'}]},
+    ], label_key='id').expand()
+
+    with ui.row():
+        ui.button('Select A', on_click=lambda: t.select('A'))
+        ui.button('Deselect A', on_click=t.deselect)
+
+
+@doc.demo('Tick/untick programmatically', '''
+    After setting a `tick_strategy`, you can tick or untick nodes with the `tick()` and `untick()` methods.
+    You can either specify a list of node keys or `None` to tick or untick all nodes.
+''')
+def tick_programmatically():
+    t = ui.tree([
+        {'id': 'numbers', 'children': [{'id': '1'}, {'id': '2'}]},
+        {'id': 'letters', 'children': [{'id': 'A'}, {'id': 'B'}]},
+    ], label_key='id', tick_strategy='leaf').expand()
+
+    with ui.row():
+        ui.button('Tick 1, 2 and B', on_click=lambda: t.tick(['1', '2', 'B']))
+        ui.button('Untick 2 and B', on_click=lambda: t.untick(['2', 'B']))
+    with ui.row():
+        ui.button('Tick all', on_click=t.tick)
+        ui.button('Untick all', on_click=t.untick)
+
+
+@doc.demo('Filter nodes', '''
+    You can filter nodes by setting the `filter` property.
+    The tree will only show nodes that match the filter.
+''')
+def filter_nodes():
+    t = ui.tree([
+        {'id': 'fruits', 'children': [{'id': 'Apple'}, {'id': 'Banana'}]},
+        {'id': 'vegetables', 'children': [{'id': 'Potato'}, {'id': 'Tomato'}]},
+    ], label_key='id').expand()
+    ui.input('filter').bind_value_to(t, 'filter')
 
 
 doc.reference(ui.tree)

+ 33 - 2
website/documentation/content/user_documentation.py

@@ -9,7 +9,9 @@ from . import doc
 def user_fixture():
     ui.markdown('''
         We recommend utilizing the `user` fixture instead of the [`screen` fixture](/documentation/screen) wherever possible
-        because execution is as fast as unit tests and it does not need Selenium as a dependency.
+        because execution is as fast as unit tests and it does not need Selenium as a dependency
+        when loaded via `pytest_plugins = ['nicegui.testing.user_plugin']`
+        (see [project structure](/documentation/project_structure)).
         The `user` fixture cuts away the browser and replaces it by a lightweight simulation entirely in Python.
 
         You can assert to "see" specific elements or content, click buttons, type into inputs and trigger events.
@@ -75,7 +77,7 @@ doc.text('Querying', '''
 
 
 @doc.ui
-def modular_project():
+def querying():
     with ui.row(wrap=False).classes('gap-4 items-stretch'):
         with python_window(classes='w-[400px]', title='some UI code'):
             ui.markdown('''
@@ -103,6 +105,35 @@ def modular_project():
             ''')
 
 
+doc.text('Multiple Users', '''
+    Sometimes it is not enough to just interact with the UI as a single user.
+    Besides the `user` fixture, we also provide the `create_user` fixture which is a factory function to create users.
+    The `User` instances are independent from each other and can interact with the UI in parallel.
+    See our [Chat App example](https://github.com/zauberzeug/nicegui/blob/main/examples/chat_app/test_chat_app.py)
+    for a full demonstration.
+''')
+
+
+@doc.ui
+def multiple_users():
+    with python_window(classes='w-[600px]', title='example'):
+        ui.markdown('''
+            ```python
+            async def test_chat(create_user: Callable[[], User]) -> None:
+                userA = create_user()
+                await userA.open('/')
+                userB = create_user()
+                await userB.open('/')
+
+                userA.find(ui.input).type('from A').trigger('keydown.enter')
+                await userB.should_see('from A')
+                userB.find(ui.input).type('from B').trigger('keydown.enter')
+                await userA.should_see('from A')
+                await userA.should_see('from B')
+            ```
+        ''')
+
+
 doc.text('Comparison with the screen fixture', '''
     By cutting out the browser, test execution becomes much faster than the [`screen` fixture](/documentation/screen).
     Of course, some features like screenshots or browser-specific behavior are not available.

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików