From 608b79b042dfd6250cd8c620c26d288d5bc90173 Mon Sep 17 00:00:00 2001 From: Rodja Trappe Date: Thu, 29 Aug 2024 13:26:40 +0200 Subject: [PATCH] Testing complex elements with `User` simulation (#3635) * allow simulated uploads * about testing complex elements * testing upload table in one scenario because it is also a demo in the docs and not as "artifical" * code review --------- Co-authored-by: Falko Schindler --- nicegui/elements/upload.py | 45 +++++++++------- tests/test_user_simulation.py | 29 +++++++++- .../content/user_documentation.py | 53 ++++++++++++++++++- 3 files changed, 107 insertions(+), 20 deletions(-) diff --git a/nicegui/elements/upload.py b/nicegui/elements/upload.py index c0c81aa55..a14e21f89 100644 --- a/nicegui/elements/upload.py +++ b/nicegui/elements/upload.py @@ -1,4 +1,4 @@ -from typing import Any, Callable, Dict, Optional, cast +from typing import Any, Callable, Dict, List, Optional, cast from fastapi import Request from starlette.datastructures import UploadFile @@ -60,28 +60,37 @@ def __init__(self, *, @app.post(self._props['url']) async def upload_route(request: Request) -> Dict[str, str]: form = await request.form() - for data in form.values(): - for upload_handler in self._upload_handlers: - handle_event(upload_handler, UploadEventArguments( - sender=self, - client=self.client, - content=cast(UploadFile, data).file, - name=cast(UploadFile, data).filename or '', - type=cast(UploadFile, data).content_type or '', - )) - for multi_upload_handler in self._multi_upload_handlers: - handle_event(multi_upload_handler, MultiUploadEventArguments( - sender=self, - client=self.client, - contents=[cast(UploadFile, data).file for data in form.values()], - names=[cast(UploadFile, data).filename or '' for data in form.values()], - types=[cast(UploadFile, data).content_type or '' for data in form.values()], - )) + uploads = [cast(UploadFile, data) for data in form.values()] + self.handle_uploads(uploads) return {'upload': 'success'} if on_rejected: self.on_rejected(on_rejected) + def handle_uploads(self, uploads: List[UploadFile]) -> None: + """Handle the uploaded files. + + This method is primarily intended for internal use and for simulating file uploads in tests. + """ + for upload in uploads: + for upload_handler in self._upload_handlers: + handle_event(upload_handler, UploadEventArguments( + sender=self, + client=self.client, + content=upload.file, + name=upload.filename or '', + type=upload.content_type or '', + )) + multi_upload_args = MultiUploadEventArguments( + sender=self, + client=self.client, + contents=[upload.file for upload in uploads], + names=[upload.filename or '' for upload in uploads], + types=[upload.content_type or '' for upload in uploads], + ) + for multi_upload_handler in self._multi_upload_handlers: + handle_event(multi_upload_handler, multi_upload_args) + def on_upload(self, callback: Callable[..., Any]) -> Self: """Add a callback to be invoked when a file is uploaded.""" self._upload_handlers.append(callback) diff --git a/tests/test_user_simulation.py b/tests/test_user_simulation.py index 85315be7f..0f02a135a 100644 --- a/tests/test_user_simulation.py +++ b/tests/test_user_simulation.py @@ -1,9 +1,13 @@ +import csv +from io import BytesIO from typing import Callable, Dict, Type import pytest +from fastapi import UploadFile +from fastapi.datastructures import Headers from fastapi.responses import PlainTextResponse -from nicegui import app, ui +from nicegui import app, events, ui from nicegui.testing import User # pylint: disable=missing-function-docstring @@ -350,3 +354,26 @@ async def test_select(user: User) -> None: await user.should_see('A') await user.should_not_see('B') await user.should_not_see('C') + + +async def test_upload_table(user: User) -> None: + def receive_file(e: events.UploadEventArguments) -> None: + reader = csv.DictReader(e.content.read().decode('utf-8').splitlines()) + ui.table(columns=[{'name': h, 'label': h.capitalize(), 'field': h} for h in reader.fieldnames or []], + rows=list(reader)) + ui.upload(on_upload=receive_file) + + await user.open('/') + upload = user.find(ui.upload).elements.pop() + headers = Headers(raw=[(b'content-type', b'text/csv')]) + upload.handle_uploads([UploadFile(BytesIO(b'name,age\nAlice,30\nBob,28'), headers=headers)]) + + table = user.find(ui.table).elements.pop() + assert table.columns == [ + {'name': 'name', 'label': 'Name', 'field': 'name'}, + {'name': 'age', 'label': 'Age', 'field': 'age'}, + ] + assert table.rows == [ + {'name': 'Alice', 'age': '30'}, + {'name': 'Bob', 'age': '28'}, + ] diff --git a/website/documentation/content/user_documentation.py b/website/documentation/content/user_documentation.py index bd2853465..b628d6ff2 100644 --- a/website/documentation/content/user_documentation.py +++ b/website/documentation/content/user_documentation.py @@ -78,7 +78,7 @@ def async_execution(): @doc.ui def querying(): - with ui.row(wrap=False).classes('gap-4 items-stretch'): + with ui.row().classes('gap-4 items-stretch'): with python_window(classes='w-[400px]', title='some UI code'): ui.markdown(''' ```python @@ -105,6 +105,57 @@ def querying(): ''') +doc.text('Complex elements', ''' + There are some elements with complex visualization and interaction behaviors (`ui.upload`, `ui.table`, ...). + Not every aspect of these elements can be tested with `should_see` and `UserInteraction`. + Still, you can grab them with `user.find(...)` and do the testing on the elements themselves. +''') + + +@doc.ui +def upload_table(): + with ui.row().classes('gap-4 items-stretch'): + with python_window(classes='w-[500px]', title='some UI code'): + ui.markdown(''' + ```python + def receive_file(e: events.UploadEventArguments): + content = e.content.read().decode('utf-8') + reader = csv.DictReader(content.splitlines()) + ui.table( + columns=[{ + 'name': h, + 'label': h.capitalize(), + 'field': h, + } for h in reader.fieldnames or []], + rows=list(reader), + ) + + ui.upload(on_upload=receive_file) + ``` + ''') + + with python_window(classes='w-[500px]', title='user assertions'): + ui.markdown(''' + ```python + upload = user.find(ui.upload).elements.pop() + upload.handle_uploads([UploadFile( + BytesIO(b'name,age\\nAlice,30\\nBob,28'), + filename='data.csv', + headers=Headers(raw=[(b'content-type', b'text/csv')]), + )]) + table = user.find(ui.table).elements.pop() + assert table.columns == [ + {'name': 'name', 'label': 'Name', 'field': 'name'}, + {'name': 'age', 'label': 'Age', 'field': 'age'}, + ] + assert table.rows == [ + {'name': 'Alice', 'age': '30'}, + {'name': 'Bob', 'age': '28'}, + ] + ``` + ''') + + 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.