From 042c2a61653c02f8ef21533554b0d978b84a091b Mon Sep 17 00:00:00 2001 From: MarcSkovMadsen Date: Thu, 14 Mar 2024 06:17:17 +0000 Subject: [PATCH 1/3] add draft todo + test todo tutorials --- doc/tutorials/intermediate/build_todo.md | 408 ++++++++++++++++++++ doc/tutorials/intermediate/index.md | 2 + doc/tutorials/intermediate/test_todo.md | 462 +++++++++++++++++++++++ 3 files changed, 872 insertions(+) create mode 100644 doc/tutorials/intermediate/build_todo.md create mode 100644 doc/tutorials/intermediate/test_todo.md diff --git a/doc/tutorials/intermediate/build_todo.md b/doc/tutorials/intermediate/build_todo.md new file mode 100644 index 0000000000..b6187291e8 --- /dev/null +++ b/doc/tutorials/intermediate/build_todo.md @@ -0,0 +1,408 @@ +# Build a Todo App + +In this section, we will work on building a *Todo App* together so that our wind turbine technicians can keep track of their tasks. As a team, we will collaborate to create an app that provides the following functionality: + +- Adding, removing, and clearing all tasks +- Marking a task as solved +- Keeping track of the number of completed tasks +- Disabling or hiding buttons when necessary + +In the basic tutorials we built a [basic todo app](../basic/build_todo.md) using a *function based approach*. This time we will be using a Parameterized class based approach which will make the todo app more extensible and maintainable in the long term. + +:::{note} +When we ask everyone to *run the code* in the sections below, you may either execute the code directly in the Panel docs via the green *run* button, in a cell in a notebook, or in a file `app.py` that is served with `panel serve app.py --autoreload`. +::: + + + +:::{dropdown} Requirements + +```bash +panel +``` + +::: + +:::{dropdown} Code + +```python +"""An app to manage tasks""" +from typing import List + +import param + +import panel as pn + +BUTTON_WIDTH = 125 + + +class Task(pn.viewable.Viewer): + """A model of a Task""" + + value: str = param.String(doc="A description of the task") + completed: bool = param.Boolean( + doc="If True the task has been completed. Otherwise not." + ) + + def __panel__(self): + completed = pn.widgets.Checkbox.from_param( + self.param.completed, name="", align="center", sizing_mode="fixed" + ) + content = pn.pane.Markdown(object=self.param.value) + return pn.Row(completed, content, sizing_mode="stretch_width") + + +class TaskList(param.Parameterized): + """Provides methods to add and remove tasks as well as calculate summary statistics""" + + value: List[Task] = param.List(class_=Task, doc="A list of Tasks") + + total_tasks = param.Integer(doc="The number of Tasks") + has_tasks = param.Boolean(doc="Whether or not the TaskList contains Tasks") + + completed_tasks = param.Integer(doc="The number of completed tasks") + + status = param.String( + doc="A string explaining the number of completed tasks and total number of tasks." + ) + + def __init__(self, **params): + self._task_watchers = {} + + super().__init__(**params) + + self._handle_completed_changed() + + def add_task(self, task: Task): + """Adds a Task to the value list""" + self.value = [*self.value, task] + + def remove_all_tasks(self): + """Removes all tasks from the value list""" + self._task_watchers = {} + self.value = [] + + def _handle_completed_changed(self, _=None): + self.completed_tasks = sum(task.completed for task in self.value) + + @param.depends("value", watch=True, on_init=True) + def _add_task_watchers(self): + for task in self.value: + if not task in self._task_watchers: + self._task_watchers[task] = task.param.watch( + self._handle_completed_changed, "completed" + ) + + @param.depends("value", watch=True, on_init=True) + def _handle_value_changed(self): + self.total_tasks = len(self.value) + self.has_tasks = self.total_tasks > 0 + + @param.depends("total_tasks", "completed_tasks", watch=True, on_init=True) + def _update_status(self): + self.status = f"{self.completed_tasks} of {self.total_tasks} tasks completed" + + +class TaskInput(pn.viewable.Viewer): + """A Widget that provides tasks as input""" + + value: Task = param.ClassSelector(class_=Task, doc="""The Task input by the user""") + + def _no_value(self, value): + return not bool(value) + + def __panel__(self): + text_input = pn.widgets.TextInput( + name="Task", placeholder="Enter a task", sizing_mode="stretch_width" + ) + text_input_has_value = pn.rx(self._no_value)(text_input.param.value_input) + submit_task = pn.widgets.Button( + name="Add", + align="center", + button_type="primary", + width=BUTTON_WIDTH, + sizing_mode="fixed", + disabled=text_input_has_value, + ) + + @pn.depends(text_input, submit_task, watch=True) + def clear_text_input(*_): + if text_input.value: + self.value = Task(value=text_input.value) + text_input.value = text_input.value_input = "" + + return pn.Row(text_input, submit_task, sizing_mode="stretch_width") + + +class TaskRow(pn.viewable.Viewer): + """Display a task in a Row together with a Remove button""" + + value: Task = param.ClassSelector( + class_=Task, allow_None=True, doc="The Task to display" + ) + + remove: bool = param.Event( + doc="The event is triggered when the user clicks the Remove Button" + ) + + def __panel__(self): + remove_button = pn.widgets.Button.from_param( + self.param.remove, width=BUTTON_WIDTH, icon="trash", sizing_mode="fixed" + ) + return pn.Row(self.value, remove_button) + + +class TaskListEditor(pn.viewable.Viewer): + """Component that enables a user to manage a list of tasks""" + + value: TaskList = param.ClassSelector(class_=TaskList) + + @param.depends("value.value") + def _layout(self): + tasks = self.value.value + rows = [TaskRow(value=task) for task in tasks] + for row in rows: + + def remove(_, task=row.value): + self.value.value = [item for item in tasks if not item == task] + + pn.bind(remove, row.param.remove, watch=True) + + return pn.Column(*rows) + + def __panel__(self): + task_input = TaskInput() + pn.bind(self.value.add_task, task_input.param.value, watch=True) + clear = pn.widgets.Button( + name="Remove All", + button_type="primary", + button_style="outline", + width=BUTTON_WIDTH, + sizing_mode="fixed", + visible=self.value.param.has_tasks, + on_click=lambda e: self.value.remove_all_tasks(), + ) + + return pn.Column( + "## WTG Task List", + pn.pane.Markdown(self.value.param.status), + task_input, + self._layout, + pn.Row(pn.Spacer(), clear), + max_width=500, + ) + + +if pn.state.served: + pn.extension(sizing_mode="stretch_width", design="material") + + task_list = TaskList( + value=[ + Task(value="Inspect the blades", completed=True), + Task(value="Inspect the nacelle"), + Task(value="Tighten the bolts"), + ] + ) + TaskListEditor(value=task_list).servable() +``` + +::: + +## Explanation + +### Import Necessary Libraries + +```python +"""An app to manage tasks""" +from typing import List +import param +import panel as pn + +pn.extension(sizing_mode="stretch_width", design="material") +``` + +Here, we import the required libraries for our task management app. We use `List` from `typing` module to define a list of tasks, `param` for declaring parameters, and `panel` for creating the user interface. + +### Define Constants + +```python +BUTTON_WIDTH = 125 +``` + +We set a constant `BUTTON_WIDTH` to control the width of buttons in our app. + +### Define Task Model + +```python +class Task(pn.viewable.Viewer): + """A model of a Task""" + + value: str = param.String(doc="A description of the task") + completed: bool = param.Boolean( + doc="If True the task has been completed. Otherwise not." + ) + + def __panel__(self): + completed = pn.widgets.Checkbox.from_param( + self.param.completed, name="", align="center", sizing_mode="fixed" + ) + content = pn.pane.Markdown(object=self.param.value) + return pn.Row(completed, content, sizing_mode="stretch_width") +``` + +This class defines the model of a task. It has two attributes: `value` (description of the task) and `completed` (whether the task is completed or not). The `__panel__` method renders the task as a row containing a checkbox for completion status and the task description. + +### Define TaskList Class + +```python +class TaskList(param.Parameterized): + """Provides methods to add and remove tasks as well as calculate summary statistics""" + + value: List[Task] = param.List(class_=Task, doc="A list of Tasks") + + total_tasks = param.Integer(doc="The number of Tasks") + has_tasks = param.Boolean(doc="Whether or not the TaskList contains Tasks") + + completed_tasks = param.Integer(doc="The number of completed tasks") + + status = param.String( + doc="A string explaining the number of completed tasks and total number of tasks." + ) + + def __init__(self, **params): + # Initialize task watchers + self._task_watchers = {} + + super().__init__(**params) + + # Update completed tasks count + self._handle_completed_changed() + + def add_task(self, task: Task): + """Adds a Task to the value list""" + self.value = [*self.value, task] + + def remove_all_tasks(self): + """Removes all tasks from the value list""" + self._task_watchers = {} + self.value = [] + + def _handle_completed_changed(self, _=None): + # Update completed tasks count when tasks are marked as completed + self.completed_tasks = sum(task.completed for task in self.value) +``` + +This class represents a list of tasks. It provides methods to add and remove tasks, as well as calculate summary statistics such as the total number of tasks, the number of completed tasks, and a status message. + +### Define TaskInput Class + +```python +class TaskInput(pn.viewable.Viewer): + """A Widget that provides tasks as input""" + + value: Task = param.ClassSelector(class_=Task, doc="""The Task input by the user""") + + def __panel__(self): + text_input = pn.widgets.TextInput( + name="Task", placeholder="Enter a task", sizing_mode="stretch_width" + ) + text_input_has_value = pn.rx(self._no_value)(text_input.param.value_input) + submit_task = pn.widgets.Button( + name="Add", + align="center", + button_type="primary", + width=BUTTON_WIDTH, + sizing_mode="fixed", + disabled=text_input_has_value, + ) + + @pn.depends(text_input, submit_task, watch=True) + def clear_text_input(*_): + if text_input.value: + self.value = Task(value=text_input.value) + text_input.value = text_input.value_input = "" + + return pn.Row(text_input, submit_task, sizing_mode="stretch_width") +``` + +This class represents a widget for users to input tasks. + +The `__panel__` method defines the appearance and behavior of the task input widget. It consists of a text input field for entering task descriptions and a button to submit the task. + +### Define TaskRow Class + +```python +class TaskRow(pn.viewable.Viewer): + """Display a task in a Row together with a Remove button""" + + value: Task = param.ClassSelector( + class_=Task, allow_None=True, doc="The Task to display" + ) + + remove: bool = param.Event( + doc="The event is triggered when the user clicks the Remove Button" + ) + + def __panel__(self): + remove_button = pn.widgets.Button.from_param( + self.param.remove, width=BUTTON_WIDTH, icon="trash", sizing_mode="fixed" + ) + return pn.Row(self.value, remove_button) +``` + +This method defines the appearance of the task row, which consists of the task description and a button to remove the task. + +### Define TaskListEditor Class + +```python +class TaskListEditor(pn.viewable.Viewer): + """Component that enables a user to manage a list of tasks""" + + value: TaskList = param.ClassSelector(class_=TaskList) + + def __panel__(self): + task_input = TaskInput() + pn.bind(self.value.add_task, task_input.param.value, watch=True) + clear = pn.widgets.Button( + name="Remove All", + button_type="primary", + button_style="outline", + width=BUTTON_WIDTH, + sizing_mode="fixed", + visible=self.value.param.has_tasks, + on_click=lambda e: self.value.remove_all_tasks(), + ) + + return pn.Column( + "## WTG Task List", + pn.pane.Markdown(self.value.param.status), + task_input, + self._layout, + pn.Row(pn.Spacer(), clear), + max_width=500, + ) +``` + +This class represents a component that allows users to manage a list of tasks. + +This `__panel__` method defines the appearance and behavior of the task list editor component. It consists of an input field for adding tasks, a list of tasks, and a button to remove all tasks. + +### Main Execution + +```python +if pn.state.served: + pn.extension(sizing_mode="stretch_width", design="material") + + task_list = TaskList( + value=[ + Task(value="Inspect the blades", completed=True), + Task(value="Inspect the nacelle"), + Task(value="Tighten the bolts"), + ] + ) + TaskListEditor(value=task_list).servable() +``` + +This part of the code checks if the script is being served (run as a Panel app), then initializes the task list with some example tasks and serves the `TaskListEditor` component. It sets the styling of the app to + +"material" design and ensures that components stretch to fit the width of the app. diff --git a/doc/tutorials/intermediate/index.md b/doc/tutorials/intermediate/index.md index 00df1402ae..5aac6ce1ee 100644 --- a/doc/tutorials/intermediate/index.md +++ b/doc/tutorials/intermediate/index.md @@ -39,5 +39,7 @@ Embark on a deeper exploration of supplementary topics to further hone your Pane Now that you've mastered the more advanced concepts of Panel, it's time to put your skills to the test: - **Create an Interactive Report:** Elevate the interactivity of your reports through embedding. +- **[Create a Todo App](build_todo.md):** Create a Todo App using a class based approach +- **[Test a Todo App](test_todo.md):** Learn how to test a class based Panel apps - **Serve Apps without a Server:** Explore the realm of WASM to serve your apps without traditional servers. - **Build a Streaming Dashboard:** Engineer a high-performing streaming dashboard employing a *producer/consumer* architecture. diff --git a/doc/tutorials/intermediate/test_todo.md b/doc/tutorials/intermediate/test_todo.md new file mode 100644 index 0000000000..d91a696f49 --- /dev/null +++ b/doc/tutorials/intermediate/test_todo.md @@ -0,0 +1,462 @@ +# Test Todo App + +In the previous section we built a Todo app using the `Parameterized` class based approach. In this tutorial we will show how this makes you app easily testable in Python. This ensures you app will be extensible and maintainable in the long term. + +## Run the tests + +Copy the app code above into a file `app.py` and the test code into a file `test_app.py`. + +:::{dropdown} Code: app.py + +```python +"""An app to manage tasks""" +from typing import List + +import param + +import panel as pn + +pn.extension(sizing_mode="stretch_width", design="material") + +BUTTON_WIDTH = 125 + + +class Task(pn.viewable.Viewer): + """A model of a Task""" + + value: str = param.String(doc="A description of the task") + completed: bool = param.Boolean( + doc="If True the task has been completed. Otherwise not." + ) + + def __panel__(self): + completed = pn.widgets.Checkbox.from_param( + self.param.completed, name="", align="center", sizing_mode="fixed" + ) + content = pn.pane.Markdown(object=self.param.value) + return pn.Row(completed, content, sizing_mode="stretch_width") + + +class TaskList(param.Parameterized): + """Provides methods to add and remove tasks as well as calculate summary statistics""" + + value: List[Task] = param.List(class_=Task, doc="A list of Tasks") + + total_tasks = param.Integer(doc="The number of Tasks") + has_tasks = param.Boolean(doc="Whether or not the TaskList contains Tasks") + + completed_tasks = param.Integer(doc="The number of completed tasks") + + status = param.String( + doc="A string explaining the number of completed tasks and total number of tasks." + ) + + def __init__(self, **params): + self._task_watchers = {} + + super().__init__(**params) + + self._handle_completed_changed() + + def add_task(self, task: Task): + """Adds a Task to the value list""" + self.value = [*self.value, task] + + def remove_all_tasks(self): + """Removes all tasks from the value list""" + self._task_watchers = {} + self.value = [] + + def _handle_completed_changed(self, _=None): + self.completed_tasks = sum(task.completed for task in self.value) + + @param.depends("value", watch=True, on_init=True) + def _add_task_watchers(self): + for task in self.value: + if not task in self._task_watchers: + self._task_watchers[task] = task.param.watch( + self._handle_completed_changed, "completed" + ) + + @param.depends("value", watch=True, on_init=True) + def _handle_value_changed(self): + self.total_tasks = len(self.value) + self.has_tasks = self.total_tasks > 0 + + @param.depends("total_tasks", "completed_tasks", watch=True, on_init=True) + def _update_status(self): + self.status = f"{self.completed_tasks} of {self.total_tasks} tasks completed" + + +class TaskInput(pn.viewable.Viewer): + """A Widget that provides tasks as input""" + + value: Task = param.ClassSelector(class_=Task, doc="""The Task input by the user""") + + def _no_value(self, value): + return not bool(value) + + def __panel__(self): + text_input = pn.widgets.TextInput( + name="Task", placeholder="Enter a task", sizing_mode="stretch_width" + ) + text_input_has_value = pn.rx(self._no_value)(text_input.param.value_input) + submit_task = pn.widgets.Button( + name="Add", + align="center", + button_type="primary", + width=BUTTON_WIDTH, + sizing_mode="fixed", + disabled=text_input_has_value, + ) + + @pn.depends(text_input, submit_task, watch=True) + def clear_text_input(*_): + if text_input.value: + self.value = Task(value=text_input.value) + text_input.value = text_input.value_input = "" + + return pn.Row(text_input, submit_task, sizing_mode="stretch_width") + + +class TaskRow(pn.viewable.Viewer): + """Display a task in a Row together with a Remove button""" + + value: Task = param.ClassSelector( + class_=Task, allow_None=True, doc="The Task to display" + ) + + remove: bool = param.Event( + doc="The event is triggered when the user clicks the Remove Button" + ) + + def __panel__(self): + remove_button = pn.widgets.Button.from_param( + self.param.remove, width=BUTTON_WIDTH, icon="trash", sizing_mode="fixed" + ) + return pn.Row(self.value, remove_button) + + +class TaskListEditor(pn.viewable.Viewer): + """Component that enables a user to manage a list of tasks""" + + value: TaskList = param.ClassSelector(class_=TaskList) + + @param.depends("value.value") + def _layout(self): + tasks = self.value.value + rows = [TaskRow(value=task) for task in tasks] + for row in rows: + + def remove(_, task=row.value): + self.value.value = [item for item in tasks if not item == task] + + pn.bind(remove, row.param.remove, watch=True) + + return pn.Column(*rows) + + def __panel__(self): + task_input = TaskInput() + pn.bind(self.value.add_task, task_input.param.value, watch=True) + clear = pn.widgets.Button( + name="Remove All", + button_type="primary", + button_style="outline", + width=BUTTON_WIDTH, + sizing_mode="fixed", + visible=self.value.param.has_tasks, + on_click=lambda e: self.value.remove_all_tasks(), + ) + + return pn.Column( + "## WTG Task List", + pn.pane.Markdown(self.value.param.status), + task_input, + self._layout, + pn.Row(pn.Spacer(), clear), + max_width=500, + ) + + +if pn.state.served: + task_list = TaskList( + value=[ + Task(value="Inspect the blades", completed=True), + Task(value="Inspect the nacelle"), + Task(value="Tighten the bolts"), + ] + ) + TaskListEditor(value=task_list).servable() +``` + +::: + +:::{dropdown} Code: test_app.py + +```python +"""Test of the Todo App components""" +from app import ( + Task, + TaskInput, + TaskList, + TaskListEditor, +) + + +def test_create_task(): + """We can create a Task""" + task = Task(value="Do this", completed=True) + assert task.value == "Do this" + assert task.completed + assert task.__panel__() + + +def test_can_create_task_list_without_tasks(): + """We can create a Task list with Tasks""" + task_list = TaskList() + assert task_list.value == [] + assert not task_list.has_tasks + assert task_list.total_tasks == 0 + assert task_list.status == "0 of 0 tasks completed" + + +def test_can_create_task_list_with_tasks(): + """We can create a Task list with Tasks""" + tasks = [ + Task(value="Inspect the blades", completed=True), + Task(value="Inspect the nacelle"), + Task(value="Tighten the bolts"), + ] + + task_list = TaskList(value=tasks) + assert task_list.value == tasks + assert task_list.has_tasks + assert task_list.total_tasks == 3 + assert task_list.status == "1 of 3 tasks completed" + + +def test_can_add_new_task_to_task_list(): + """We can add a new task to the task list""" + task_list = TaskList() + task = Task(value="Inspect the nacelle") + + task_list.add_task(task) + + assert task_list.value == [task] + assert task_list.has_tasks + assert task_list.total_tasks == 1 + assert task_list.status == "0 of 1 tasks completed" + + task.completed = True + assert task_list.status == "1 of 1 tasks completed" + + +def test_can_replace_tasks(): + """We can replace the list of tasks""" + task_list = TaskList() + task = Task(value="Inspect the nacelle") + + task_list.value = [task] + + assert task_list.value == [task] + assert task_list.has_tasks + assert task_list.total_tasks == 1 + assert task_list.status == "0 of 1 tasks completed" + + task.completed = True + assert task_list.status == "1 of 1 tasks completed" + + +def test_create_task_input(): + """We can create a TaskInput widget""" + task_input = TaskInput() + assert not task_input.value + + +def test_enter_text_into_task_input(): + """When we enter text into a TaskInput a Task is created""" + task_input = TaskInput() + text_input, _ = task_input.__panel__() + + text_input.value = "some value" + assert task_input.value + assert task_input.value.value == "some value" + assert text_input.value == "" + + +def test_can_create_task_list_editor(): + """We can create a TaskListEditor""" + tasks = [ + Task(value="Inspect the blades", completed=True), + Task(value="Inspect the nacelle"), + Task(value="Tighten the bolts"), + ] + + task_list = TaskList(value=tasks) + task_list_editor = TaskListEditor(value=task_list) + assert task_list_editor.__panel__() +``` + +::: + +Run the tests with `pytest test_app.py`. It should look like + +```bash +$ pytest test_app.py +============================================ test session starts ============================================= +platform linux -- Python 3.10.13, pytest-8.1.1, pluggy-1.4.0 -- /home/jovyan/panel/.venv/bin/python +cachedir: .pytest_cache +rootdir: /home/jovyan/panel +configfile: pyproject.toml +plugins: dash-2.14.2, anyio-3.7.1 +collected 8 items + +test_app.py::test_create_task PASSED [ 12%] +test_app.py::test_can_create_task_list_without_tasks PASSED [ 25%] +test_app.py::test_can_create_task_list_with_tasks PASSED [ 37%] +test_app.py::test_can_add_new_task_to_task_list PASSED [ 50%] +test_app.py::test_can_replace_tasks PASSED [ 62%] +test_app.py::test_create_task_input PASSED [ 75%] +test_app.py::test_enter_text_into_task_input PASSED [ 87%] +test_app.py::test_can_create_task_list_editor PASSED [100%] + +============================================== warnings summary ============================================== +... +======================================= 8 passed, 2 warnings in 1.43s ======================================== +``` + +## Explanation + +### `test_create_task` + +```python +def test_create_task(): + """We can create a Task""" + task = Task(value="Do this", completed=True) + assert task.value == "Do this" + assert task.completed + assert task.__panel__() +``` + +This test verifies if we can create a `Task` instance successfully. It initializes a task with a description "Do this" and marks it as completed. Then, it checks if the task's attributes are correctly set and if the `__panel__()` method returns a valid Panel component. + +### `test_can_create_task_list_without_tasks` + +```python +def test_can_create_task_list_without_tasks(): + """We can create a Task list without Tasks""" + task_list = TaskList() + assert task_list.value == [] + assert not task_list.has_tasks + assert task_list.total_tasks == 0 + assert task_list.status == "0 of 0 tasks completed" +``` + +This test validates the behavior of creating a `TaskList` instance without any tasks. It checks if the task list initializes with an empty list, and if the status attributes are correctly set reflecting no tasks. + +### 3. `test_can_create_task_list_with_tasks` + +```python +def test_can_create_task_list_with_tasks(): + """We can create a Task list with Tasks""" + tasks = [ + Task(value="Inspect the blades", completed=True), + Task(value="Inspect the nacelle"), + Task(value="Tighten the bolts"), + ] + + task_list = TaskList(value=tasks) + assert task_list.value == tasks + assert task_list.has_tasks + assert task_list.total_tasks == 3 + assert task_list.status == "1 of 3 tasks completed" +``` + +This test ensures that we can create a `TaskList` instance with a predefined list of tasks. It checks if the task list initializes with the provided tasks, calculates the total number of tasks accurately, and sets the status attribute accordingly. + +### 4. `test_can_add_new_task_to_task_list` + +```python +def test_can_add_new_task_to_task_list(): + """We can add a new task to the task list""" + task_list = TaskList() + task = Task(value="Inspect the nacelle") + + task_list.add_task(task) + + assert task_list.value == [task] + assert task_list.has_tasks + assert task_list.total_tasks == 1 + assert task_list.status == "0 of 1 tasks completed" + + task.completed = True + assert task_list.status == "1 of 1 tasks completed" +``` + +This test verifies if we can add a new task to the `TaskList` instance successfully. It adds a new task to the task list, checks if the task list reflects the addition, and updates the status attribute accordingly when the task is marked as completed. + +### 5. `test_can_replace_tasks` + +```python +def test_can_replace_tasks(): + """We can replace the list of tasks""" + task_list = TaskList() + task = Task(value="Inspect the nacelle") + + task_list.value = [task] + + assert task_list.value == [task] + assert task_list.has_tasks + assert task_list.total_tasks == 1 + assert task_list.status == "0 of 1 tasks completed" + + task.completed = True + assert task_list.status == "1 of 1 tasks completed" +``` + +This test validates if we can replace the list of tasks in the `TaskList` instance successfully. It replaces the task list with a new list containing a single task, checks if the task list reflects the replacement, and updates the status attribute accordingly when the task is marked as completed. + +### 6. `test_create_task_input` + +```python +def test_create_task_input(): + """We can create a TaskInput widget""" + task_input = TaskInput() + assert not task_input.value +``` + +This test ensures that we can create a `TaskInput` widget successfully. It checks if the initial value of the widget is `None`. + +### 7. `test_enter_text_into_task_input` + +```python +def test_enter_text_into_task_input(): + """When we enter text into a TaskInput a Task is created""" + task_input = TaskInput() + text_input, _ = task_input.__panel__() + + text_input.value = "some value" + assert task_input.value + assert task_input.value.value == "some value" + assert text_input.value == "" +``` + +This test verifies the behavior of entering text into the `TaskInput` widget. It sets a text value into the input field, checks if a task is created from the entered text, and if the task value matches the entered text. Additionally, it ensures that the input field is cleared after setting the value. + +### 8. `test_can_create_task_list_editor` + +```python +def test_can_create_task_list_editor(): + """We can create a TaskListEditor""" + tasks = [ + Task(value="Inspect the blades", completed=True), + Task(value="Inspect the nacelle"), + Task(value="Tighten the bolts"), + ] + + task_list = TaskList(value=tasks) + task_list_editor = TaskListEditor(value=task_list) + assert task_list_editor.__panel__() +``` + +This test validates if we can create a `TaskListEditor` successfully. It initializes a task list with predefined tasks and creates a task list editor from it, ensuring that the editor is correctly instantiated. From 68f85b92d5ae693e127adcbcb5a4d441a7a2d4ea Mon Sep 17 00:00:00 2001 From: MarcSkovMadsen Date: Thu, 14 Mar 2024 06:21:38 +0000 Subject: [PATCH 2/3] remove numbering --- doc/tutorials/intermediate/test_todo.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/doc/tutorials/intermediate/test_todo.md b/doc/tutorials/intermediate/test_todo.md index d91a696f49..9a6b8b4da9 100644 --- a/doc/tutorials/intermediate/test_todo.md +++ b/doc/tutorials/intermediate/test_todo.md @@ -354,7 +354,7 @@ def test_can_create_task_list_without_tasks(): This test validates the behavior of creating a `TaskList` instance without any tasks. It checks if the task list initializes with an empty list, and if the status attributes are correctly set reflecting no tasks. -### 3. `test_can_create_task_list_with_tasks` +### `test_can_create_task_list_with_tasks` ```python def test_can_create_task_list_with_tasks(): @@ -374,7 +374,7 @@ def test_can_create_task_list_with_tasks(): This test ensures that we can create a `TaskList` instance with a predefined list of tasks. It checks if the task list initializes with the provided tasks, calculates the total number of tasks accurately, and sets the status attribute accordingly. -### 4. `test_can_add_new_task_to_task_list` +### `test_can_add_new_task_to_task_list` ```python def test_can_add_new_task_to_task_list(): @@ -395,7 +395,7 @@ def test_can_add_new_task_to_task_list(): This test verifies if we can add a new task to the `TaskList` instance successfully. It adds a new task to the task list, checks if the task list reflects the addition, and updates the status attribute accordingly when the task is marked as completed. -### 5. `test_can_replace_tasks` +### `test_can_replace_tasks` ```python def test_can_replace_tasks(): @@ -416,7 +416,7 @@ def test_can_replace_tasks(): This test validates if we can replace the list of tasks in the `TaskList` instance successfully. It replaces the task list with a new list containing a single task, checks if the task list reflects the replacement, and updates the status attribute accordingly when the task is marked as completed. -### 6. `test_create_task_input` +### `test_create_task_input` ```python def test_create_task_input(): @@ -427,7 +427,7 @@ def test_create_task_input(): This test ensures that we can create a `TaskInput` widget successfully. It checks if the initial value of the widget is `None`. -### 7. `test_enter_text_into_task_input` +### `test_enter_text_into_task_input` ```python def test_enter_text_into_task_input(): @@ -443,7 +443,7 @@ def test_enter_text_into_task_input(): This test verifies the behavior of entering text into the `TaskInput` widget. It sets a text value into the input field, checks if a task is created from the entered text, and if the task value matches the entered text. Additionally, it ensures that the input field is cleared after setting the value. -### 8. `test_can_create_task_list_editor` +### `test_can_create_task_list_editor` ```python def test_can_create_task_list_editor(): From b9da67b0f1eab5e2c7aaa4c8117c283399dfa07a Mon Sep 17 00:00:00 2001 From: MarcSkovMadsen Date: Thu, 14 Mar 2024 19:37:04 +0000 Subject: [PATCH 3/3] review --- doc/tutorials/intermediate/build_todo.md | 82 ++++++++++++------------ doc/tutorials/intermediate/test_todo.md | 26 ++++++-- 2 files changed, 61 insertions(+), 47 deletions(-) diff --git a/doc/tutorials/intermediate/build_todo.md b/doc/tutorials/intermediate/build_todo.md index b6187291e8..26f37cd498 100644 --- a/doc/tutorials/intermediate/build_todo.md +++ b/doc/tutorials/intermediate/build_todo.md @@ -1,19 +1,15 @@ # Build a Todo App -In this section, we will work on building a *Todo App* together so that our wind turbine technicians can keep track of their tasks. As a team, we will collaborate to create an app that provides the following functionality: +Welcome to the "Build a Todo App" tutorial! In this section, we're going to create a dynamic *Todo App* together. Imagine our wind turbine technicians being able to manage their tasks efficiently with this application. We'll collaborate to develop an app with features like: -- Adding, removing, and clearing all tasks -- Marking a task as solved -- Keeping track of the number of completed tasks -- Disabling or hiding buttons when necessary +- Adding, removing, and clearing tasks +- Marking tasks as completed +- Tracking the number of completed tasks +- Dynamically disabling or hiding buttons -In the basic tutorials we built a [basic todo app](../basic/build_todo.md) using a *function based approach*. This time we will be using a Parameterized class based approach which will make the todo app more extensible and maintainable in the long term. +Previously, we built a [basic todo app](../basic/build_todo.md) using a function-based approach. This time, we'll employ a more sophisticated `Parameterized` class-based approach. This method will enhance the extensibility and maintainability of our todo app in the long run. -:::{note} -When we ask everyone to *run the code* in the sections below, you may either execute the code directly in the Panel docs via the green *run* button, in a cell in a notebook, or in a file `app.py` that is served with `panel serve app.py --autoreload`. -::: - - + :::{dropdown} Requirements @@ -210,9 +206,11 @@ if pn.state.served: ## Explanation +Let's break down the todo app. + ### Import Necessary Libraries -```python +```{pyodide} """An app to manage tasks""" from typing import List import param @@ -221,11 +219,11 @@ import panel as pn pn.extension(sizing_mode="stretch_width", design="material") ``` -Here, we import the required libraries for our task management app. We use `List` from `typing` module to define a list of tasks, `param` for declaring parameters, and `panel` for creating the user interface. +Here, we import the required libraries for our task management app. We use `List` from the `typing` module to define a list of tasks, [`param`](https://param.holoviz.org) for declaring parameters, and `panel` for creating the user interface. We configure the `"material"` design to give the todo app a modern look and feel. ### Define Constants -```python +```{pyodide} BUTTON_WIDTH = 125 ``` @@ -233,7 +231,7 @@ We set a constant `BUTTON_WIDTH` to control the width of buttons in our app. ### Define Task Model -```python +```{pyodide} class Task(pn.viewable.Viewer): """A model of a Task""" @@ -248,13 +246,15 @@ class Task(pn.viewable.Viewer): ) content = pn.pane.Markdown(object=self.param.value) return pn.Row(completed, content, sizing_mode="stretch_width") + +Task(value="Inspect the blades") ``` This class defines the model of a task. It has two attributes: `value` (description of the task) and `completed` (whether the task is completed or not). The `__panel__` method renders the task as a row containing a checkbox for completion status and the task description. ### Define TaskList Class -```python +```{pyodide} class TaskList(param.Parameterized): """Provides methods to add and remove tasks as well as calculate summary statistics""" @@ -270,12 +270,10 @@ class TaskList(param.Parameterized): ) def __init__(self, **params): - # Initialize task watchers self._task_watchers = {} super().__init__(**params) - # Update completed tasks count self._handle_completed_changed() def add_task(self, task: Task): @@ -288,15 +286,22 @@ class TaskList(param.Parameterized): self.value = [] def _handle_completed_changed(self, _=None): - # Update completed tasks count when tasks are marked as completed self.completed_tasks = sum(task.completed for task in self.value) + +TaskList(value=[Task(value="Inspect the blades")]) ``` This class represents a list of tasks. It provides methods to add and remove tasks, as well as calculate summary statistics such as the total number of tasks, the number of completed tasks, and a status message. +:::{note} + +The `TaskList` and the rest of the todo app follows the *DataStore design pattern* introduced in [Structure with a DataStore](structure_data_store.md). + +::: + ### Define TaskInput Class -```python +```{pyodide} class TaskInput(pn.viewable.Viewer): """A Widget that provides tasks as input""" @@ -323,6 +328,8 @@ class TaskInput(pn.viewable.Viewer): text_input.value = text_input.value_input = "" return pn.Row(text_input, submit_task, sizing_mode="stretch_width") + +TaskInput() ``` This class represents a widget for users to input tasks. @@ -331,7 +338,7 @@ The `__panel__` method defines the appearance and behavior of the task input wid ### Define TaskRow Class -```python +```{pyodide} class TaskRow(pn.viewable.Viewer): """Display a task in a Row together with a Remove button""" @@ -348,13 +355,15 @@ class TaskRow(pn.viewable.Viewer): self.param.remove, width=BUTTON_WIDTH, icon="trash", sizing_mode="fixed" ) return pn.Row(self.value, remove_button) + +TaskRow(value=Task(value="Inspect the blades")) ``` This method defines the appearance of the task row, which consists of the task description and a button to remove the task. ### Define TaskListEditor Class -```python +```{pyodide} class TaskListEditor(pn.viewable.Viewer): """Component that enables a user to manage a list of tasks""" @@ -381,28 +390,21 @@ class TaskListEditor(pn.viewable.Viewer): pn.Row(pn.Spacer(), clear), max_width=500, ) + +task_list = TaskList( + value=[ + Task(value="Inspect the blades", completed=True), + Task(value="Inspect the nacelle"), + Task(value="Tighten the bolts"), + ] +) +TaskListEditor(value=task_list).servable() ``` This class represents a component that allows users to manage a list of tasks. This `__panel__` method defines the appearance and behavior of the task list editor component. It consists of an input field for adding tasks, a list of tasks, and a button to remove all tasks. -### Main Execution - -```python -if pn.state.served: - pn.extension(sizing_mode="stretch_width", design="material") - - task_list = TaskList( - value=[ - Task(value="Inspect the blades", completed=True), - Task(value="Inspect the nacelle"), - Task(value="Tighten the bolts"), - ] - ) - TaskListEditor(value=task_list).servable() -``` - -This part of the code checks if the script is being served (run as a Panel app), then initializes the task list with some example tasks and serves the `TaskListEditor` component. It sets the styling of the app to +## Recap -"material" design and ensures that components stretch to fit the width of the app. +We've built a todo app using a `Parameterized` class-based approach and the *DataStore design pattern*. Now, our wind turbine technicians can manage their tasks efficiently and collaboratively. diff --git a/doc/tutorials/intermediate/test_todo.md b/doc/tutorials/intermediate/test_todo.md index 9a6b8b4da9..4b01190d2d 100644 --- a/doc/tutorials/intermediate/test_todo.md +++ b/doc/tutorials/intermediate/test_todo.md @@ -1,10 +1,12 @@ -# Test Todo App +# Testing the Todo App -In the previous section we built a Todo app using the `Parameterized` class based approach. In this tutorial we will show how this makes you app easily testable in Python. This ensures you app will be extensible and maintainable in the long term. +In the previous section, we constructed a Todo app using the `Parameterized` class-based approach. Now, we'll delve into how this approach enables easy testing of your app in Python. Ensuring your app's testability guarantees its extensibility and maintainability over time. -## Run the tests + -Copy the app code above into a file `app.py` and the test code into a file `test_app.py`. +## Running the Tests + +First, copy the app code above into a file named `app.py`, and the test code into a file named `test_app.py`. :::{dropdown} Code: app.py @@ -299,7 +301,7 @@ def test_can_create_task_list_editor(): ::: -Run the tests with `pytest test_app.py`. It should look like +Run the tests with `pytest test_app.py`. It should look like this: ```bash $ pytest test_app.py @@ -338,7 +340,7 @@ def test_create_task(): assert task.__panel__() ``` -This test verifies if we can create a `Task` instance successfully. It initializes a task with a description "Do this" and marks it as completed. Then, it checks if the task's attributes are correctly set and if the `__panel__()` method returns a valid Panel component. +This test ensures that a `Task` instance can be created successfully. It initializes a task with the description "Do this" and marks it as completed. Then, it checks if the task's attributes are correctly set and if the `__panel__()` method returns a valid Panel component. ### `test_can_create_task_list_without_tasks` @@ -352,7 +354,7 @@ def test_can_create_task_list_without_tasks(): assert task_list.status == "0 of 0 tasks completed" ``` -This test validates the behavior of creating a `TaskList` instance without any tasks. It checks if the task list initializes with an empty list, and if the status attributes are correctly set reflecting no tasks. +This test validates the behavior of creating a `TaskList` instance without any tasks. It checks if the task list initializes with an empty list and if the status attributes are correctly set to reflect no tasks. ### `test_can_create_task_list_with_tasks` @@ -460,3 +462,13 @@ def test_can_create_task_list_editor(): ``` This test validates if we can create a `TaskListEditor` successfully. It initializes a task list with predefined tasks and creates a task list editor from it, ensuring that the editor is correctly instantiated. + +## Recap + +In this tutorial, you have learned how to test Panel apps built using the class-based approach. + +We believe that a straightforward and easy way of testing Panel components represents a competitive advantage over most other data app frameworks. + +## Further Learning + +To dive deeper into testing Panel apps, explore the [Testing How-To Guides](../../how_to/test/index.md) and [Panel's own tests](https://github.com/holoviz/panel/tree/main/panel/tests).