diff --git a/services/dynamic-scheduler/src/simcore_service_dynamic_scheduler/api/frontend/_common/__init__.py b/services/dynamic-scheduler/src/simcore_service_dynamic_scheduler/api/frontend/_common/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/services/dynamic-scheduler/src/simcore_service_dynamic_scheduler/api/frontend/_common/_mixins.py b/services/dynamic-scheduler/src/simcore_service_dynamic_scheduler/api/frontend/_common/_mixins.py new file mode 100644 index 000000000000..3ef51f30de92 --- /dev/null +++ b/services/dynamic-scheduler/src/simcore_service_dynamic_scheduler/api/frontend/_common/_mixins.py @@ -0,0 +1,32 @@ +from abc import ABC, abstractmethod +from functools import cached_property + +from nicegui import ui +from nicegui.element import Element + + +class ParentMixin: + def __init__(self) -> None: + self._parent: Element | None = None + + @staticmethod + def _get_parent() -> Element: + """overwrite to use a different parent element""" + return ui.element() + + @cached_property + def parent(self) -> Element: + if self._parent is None: + self._parent = self._get_parent() + return self._parent + + def remove_parent(self) -> None: + if self._parent is not None: + self._parent.delete() + self._parent = None + + +class DisplayaMixin(ABC): + @abstractmethod + def display(self) -> None: + """create an ui element ad attach it to the current NiceGUI context""" diff --git a/services/dynamic-scheduler/src/simcore_service_dynamic_scheduler/api/frontend/_common/base_component.py b/services/dynamic-scheduler/src/simcore_service_dynamic_scheduler/api/frontend/_common/base_component.py new file mode 100644 index 000000000000..c82ed07c07e4 --- /dev/null +++ b/services/dynamic-scheduler/src/simcore_service_dynamic_scheduler/api/frontend/_common/base_component.py @@ -0,0 +1,23 @@ +from abc import abstractmethod +from typing import Generic, TypeVar + +from ._mixins import DisplayaMixin, ParentMixin +from .base_display_model import BaseUpdatableDisplayModel + +M = TypeVar("M", bound=BaseUpdatableDisplayModel) + + +class BaseUpdatableComponent(DisplayaMixin, ParentMixin, Generic[M]): + def __init__(self, display_model: M): + super().__init__() + + self.display_model = display_model + self.display_model.on_remove_from_ui(self.remove_parent) + + def display(self) -> None: + with self.parent: + self._draw_ui() + + @abstractmethod + def _draw_ui(self) -> None: + """creates ui elements inside the parent container""" diff --git a/services/dynamic-scheduler/src/simcore_service_dynamic_scheduler/api/frontend/_common/base_display_model.py b/services/dynamic-scheduler/src/simcore_service_dynamic_scheduler/api/frontend/_common/base_display_model.py new file mode 100644 index 000000000000..9d9cd8d75797 --- /dev/null +++ b/services/dynamic-scheduler/src/simcore_service_dynamic_scheduler/api/frontend/_common/base_display_model.py @@ -0,0 +1,83 @@ +from collections.abc import Callable +from typing import Annotated, Any, Self, TypeAlias + +from pydantic import BaseModel, NonNegativeInt, PrivateAttr + +CompleteModelDict: TypeAlias = dict[str, Any] + + +class BaseUpdatableDisplayModel(BaseModel): + _on_type_change_subscribers: Annotated[ + dict[str, Callable], PrivateAttr(default_factory=dict) + ] + _on_value_change_subscribers: Annotated[ + dict[str, Callable], PrivateAttr(default_factory=dict) + ] + _on_remove_from_ui_callback: Annotated[Callable | None, PrivateAttr(default=None)] + + def _get_on_change_callbacks_to_run(self, update_obj: Self) -> list[Callable]: + callbacks_to_run: list[Callable] = [] + + for attribute_name, callback in self._on_value_change_subscribers.items(): + if getattr(self, attribute_name) != getattr(update_obj, attribute_name): + callbacks_to_run.append(callback) + + for attribute_name, callback in self._on_type_change_subscribers.items(): + if type(getattr(self, attribute_name)) is not type( + getattr(update_obj, attribute_name) + ): + callbacks_to_run.append(callback) + + return callbacks_to_run + + def update(self, update_obj: Self) -> NonNegativeInt: + """ + updates the model with the values from update_obj + returns the number of callbacks that were run + """ + callbacks_to_run = self._get_on_change_callbacks_to_run(update_obj) + + for attribute_name, update_value in update_obj.__dict__.items(): + current_value = getattr(self, attribute_name) + if current_value != update_value: + if isinstance(update_value, BaseUpdatableDisplayModel): + if type(current_value) is type(update_value): + current_value.update(update_value) + else: + setattr(self, attribute_name, update_value) + else: + setattr(self, attribute_name, update_value) + + for callback in callbacks_to_run: + callback() + + return len(callbacks_to_run) + + def remove_from_ui(self) -> None: + """the UI will remove the component associated with this model""" + if self._on_remove_from_ui_callback: + self._on_remove_from_ui_callback() + + def _raise_if_attribute_not_declared_in_model(self, attribute: str) -> None: + if attribute not in self.__class__.model_fields: + msg = f"Attribute '{attribute}' is not part of the model fields" + raise ValueError(msg) + + def on_type_change(self, attribute: str, callback: Callable) -> None: + """subscribe callback to an attribute TYPE change""" + self._raise_if_attribute_not_declared_in_model(attribute) + + self._on_type_change_subscribers[attribute] = callback + + def on_value_change(self, attribute: str, callback: Callable) -> None: + """subscribe callback to an attribute VALUE change""" + self._raise_if_attribute_not_declared_in_model(attribute) + + self._on_value_change_subscribers[attribute] = callback + + def on_remove_from_ui(self, callback: Callable) -> None: + """ + invokes callback when object is no longer required, + allows the UI to have a clear hook to remove the component + """ + self._on_remove_from_ui_callback = callback diff --git a/services/dynamic-scheduler/src/simcore_service_dynamic_scheduler/api/frontend/_common/stack.py b/services/dynamic-scheduler/src/simcore_service_dynamic_scheduler/api/frontend/_common/stack.py new file mode 100644 index 000000000000..827cde019407 --- /dev/null +++ b/services/dynamic-scheduler/src/simcore_service_dynamic_scheduler/api/frontend/_common/stack.py @@ -0,0 +1,65 @@ +from typing import Generic, TypeAlias, TypeVar + +from ._mixins import DisplayaMixin, ParentMixin +from .base_component import BaseUpdatableComponent +from .base_display_model import BaseUpdatableDisplayModel + +M = TypeVar("M", bound=BaseUpdatableDisplayModel) + +Reference: TypeAlias = str + + +class UpdatableComponentStack(DisplayaMixin, ParentMixin, Generic[M]): + """ + Renders `BaseUpdatableComponent` models via the provided `BaseUpdatableDisplayModel` + Appends new elements to the parent container. + """ + + def __init__(self, component: type[BaseUpdatableComponent]) -> None: + super().__init__() + self.component = component + + self._added_models: dict[Reference, M] = {} + self._rendered_models: set[Reference] = set() + + def _render_to_parent(self) -> None: + with self.parent: + for reference in self._added_models: + if reference not in self._rendered_models: + self._render_component(reference) + + def _render_component(self, reference: Reference) -> None: + with self.parent: + model = self._added_models[reference] + component = self.component(model) + component.display() + self._rendered_models.add(reference) + + def display(self) -> None: + self._render_to_parent() + + def add_or_update_model(self, reference: Reference, display_model: M) -> None: + """adds or updates and existing ui element form a given model""" + if reference not in self._added_models: + self._added_models[reference] = display_model + self._render_component(reference) + else: + self._added_models[reference].update(display_model) + + def remove_model(self, reference: Reference) -> None: + """removes a model from the ui via it's given reference""" + if reference in self._added_models: + self._added_models[reference].remove_from_ui() + del self._added_models[reference] + self._rendered_models.remove(reference) + + def update_from_dict(self, models: dict[Reference, M]) -> None: + """updates UI given a new input""" + # remove models that are not in the new list + for reference in tuple(self._added_models.keys()): + if reference not in models: + self.remove_model(reference) + + # add or update existing models + for reference, model in models.items(): + self.add_or_update_model(reference, model) diff --git a/services/dynamic-scheduler/tests/unit/api_frontend/_common/test_base_display_model.py b/services/dynamic-scheduler/tests/unit/api_frontend/_common/test_base_display_model.py new file mode 100644 index 000000000000..a02ac58d2cd4 --- /dev/null +++ b/services/dynamic-scheduler/tests/unit/api_frontend/_common/test_base_display_model.py @@ -0,0 +1,178 @@ +from unittest.mock import Mock + +import pytest +from pydantic import NonNegativeInt, TypeAdapter +from simcore_service_dynamic_scheduler.api.frontend._common.base_display_model import ( + BaseUpdatableDisplayModel, + CompleteModelDict, +) + + +class Pet(BaseUpdatableDisplayModel): + name: str + species: str + + +class Friend(BaseUpdatableDisplayModel): + name: str + age: int + + +class RenderOnPropertyValueChange(BaseUpdatableDisplayModel): + name: str + age: int + companion: Pet | Friend + + +class RenderOnPropertyTypeChange(BaseUpdatableDisplayModel): + name: str + age: int + companion: Pet | Friend + + +@pytest.mark.parametrize( + "class_, initial_dict, update_dict, expected_dict, on_type_change, on_value_change", + [ + pytest.param( + Pet, + {"name": "Fluffy", "species": "cat"}, + {"name": "Fido", "species": "dog"}, + {"name": "Fido", "species": "dog"}, + {}, + {}, + id="does-not-require-rerender-without-any-render-on-declared", + ), + pytest.param( + RenderOnPropertyValueChange, + { + "name": "Alice", + "age": 30, + "companion": {"name": "Fluffy", "species": "cat"}, + }, + { + "name": "Alice", + "age": 30, + "companion": {"name": "Fido", "species": "dog"}, + }, + { + "name": "Alice", + "age": 30, + "companion": {"name": "Fido", "species": "dog"}, + }, + {}, + {"companion": 1}, + id="requires-rerender-on-property-change", + ), + pytest.param( + RenderOnPropertyValueChange, + { + "name": "Alice", + "age": 30, + "companion": {"name": "Fluffy", "species": "cat"}, + }, + { + "name": "Alice", + "age": 30, + "companion": {"name": "Fluffy", "species": "cat"}, + }, + { + "name": "Alice", + "age": 30, + "companion": {"name": "Fluffy", "species": "cat"}, + }, + {}, + {"companion": 0}, + id="do-not-require-rerender-if-same-value", + ), + pytest.param( + RenderOnPropertyTypeChange, + { + "name": "Alice", + "age": 30, + "companion": {"name": "Fluffy", "species": "cat"}, + }, + { + "name": "Alice", + "age": 31, + "companion": {"name": "Fido", "species": "dog"}, + }, + { + "name": "Alice", + "age": 31, + "companion": {"name": "Fido", "species": "dog"}, + }, + {"companion": 0}, + {}, + id="does-not-require-rerender-if-same-type-with-value-changes", + ), + pytest.param( + RenderOnPropertyTypeChange, + { + "name": "Alice", + "age": 30, + "companion": {"name": "Fluffy", "species": "cat"}, + }, + { + "name": "Alice", + "age": 30, + "companion": {"name": "Fluffy", "species": "cat"}, + }, + { + "name": "Alice", + "age": 30, + "companion": {"name": "Fluffy", "species": "cat"}, + }, + {"companion": 0}, + {}, + id="does-not-require-rerender-if-same-type-with-NO-value-changes", + ), + pytest.param( + RenderOnPropertyTypeChange, + { + "name": "Alice", + "age": 30, + "companion": {"name": "Fluffy", "species": "cat"}, + }, + {"name": "Alice", "age": 31, "companion": {"name": "Charlie", "age": 25}}, + {"name": "Alice", "age": 31, "companion": {"name": "Charlie", "age": 25}}, + {"companion": 1}, + {}, + id="requires-rerender-when-type-changes", + ), + ], +) +def test_base_updatable_display_model( + class_: type[BaseUpdatableDisplayModel], + initial_dict: CompleteModelDict, + update_dict: CompleteModelDict, + expected_dict: CompleteModelDict, + on_type_change: dict[str, NonNegativeInt], + on_value_change: dict[str, NonNegativeInt], +): + person = TypeAdapter(class_).validate_python(initial_dict) + assert person.model_dump() == initial_dict + + subscribed_on_type_changed: dict[str, Mock] = {} + for attribute in on_type_change: + mock = Mock() + person.on_type_change(attribute, mock) + subscribed_on_type_changed[attribute] = mock + + subscribed_on_value_change: dict[str, Mock] = {} + for attribute in on_value_change: + mock = Mock() + person.on_value_change(attribute, mock) + subscribed_on_value_change[attribute] = mock + + person.update(TypeAdapter(class_).validate_python(update_dict)) + assert person.model_dump() == expected_dict + + for attribute, mock in subscribed_on_type_changed.items(): + assert ( + mock.call_count == on_type_change[attribute] + ), f"wrong on_type_change count for '{attribute}'" + + for attribute, mock in subscribed_on_value_change.items(): + assert ( + mock.call_count == on_value_change[attribute] + ), f"wrong on_value_change count for '{attribute}'" diff --git a/services/dynamic-scheduler/tests/unit/api_frontend/_common/test_updatable_component.py b/services/dynamic-scheduler/tests/unit/api_frontend/_common/test_updatable_component.py new file mode 100644 index 000000000000..d7fc98f2d91f --- /dev/null +++ b/services/dynamic-scheduler/tests/unit/api_frontend/_common/test_updatable_component.py @@ -0,0 +1,379 @@ +# pylint:disable=redefined-outer-name +# pylint:disable=unused-argument + +from collections.abc import Awaitable, Callable +from functools import cached_property +from unittest.mock import Mock + +import nicegui +import pytest +from fastapi import FastAPI +from helpers import assert_contains_text, assert_not_contains_text +from nicegui import APIRouter, ui +from playwright.async_api import Page +from pydantic import NonNegativeInt +from pytest_simcore.helpers.typing_env import EnvVarsDict +from simcore_service_dynamic_scheduler.api.frontend._common.base_component import ( + BaseUpdatableComponent, +) +from simcore_service_dynamic_scheduler.api.frontend._common.base_display_model import ( + BaseUpdatableDisplayModel, +) +from simcore_service_dynamic_scheduler.api.frontend._common.stack import ( + UpdatableComponentStack, +) +from simcore_service_dynamic_scheduler.api.frontend._utils import set_parent_app + + +@pytest.fixture +def app_environment() -> EnvVarsDict: + return {} + + +@pytest.fixture +def mount_path() -> str: + return "/dynamic-scheduler/" + + +@pytest.fixture +def use_internal_scheduler() -> bool: + return True + + +class LayoutManager: + def __init__(self) -> None: + self._draw_ui: Callable[[], None] | None = None + + def set(self, draw_ui: Callable[[], None]) -> None: + self._draw_ui = draw_ui + + def draw(self) -> None: + if self._draw_ui is not None: + self._draw_ui() + + +@pytest.fixture +def layout_manager() -> LayoutManager: + return LayoutManager() + + +@pytest.fixture +def router(layout_manager: LayoutManager) -> APIRouter: + router = APIRouter() + + @ui.page("/", api_router=router) + async def index(): + ui.label("BEFORE_CORPUS") + layout_manager.draw() + ui.label("AFTER_CORPUS") + + return router + + +@pytest.fixture +def ensure_page_loaded( + async_page: Page, + server_host_port: str, + mount_path: str, + layout_manager: LayoutManager, +) -> Callable[[Callable[[], None]], Awaitable[None]]: + async def _(draw_ui: Callable[[], None]) -> None: + layout_manager.set(draw_ui) + await async_page.goto(f"{server_host_port}{mount_path}") + await _ensure_before_corpus(async_page) + await _ensure_after_corpus(async_page) + print("✅ index page loaded") + + return _ + + +@pytest.fixture +def not_initialized_app( + reset_nicegui_app: None, + app_environment: EnvVarsDict, + router: APIRouter, + mount_path: str, +) -> FastAPI: + minimal_app = FastAPI() + + mock_settings = Mock() + mock_settings.DYNAMIC_SCHEDULER_UI_MOUNT_PATH = mount_path + minimal_app.state.settings = mock_settings + + nicegui.app.include_router(router) + + nicegui.ui.run_with( + minimal_app, mount_path=mount_path, storage_secret="test-secret" # noqa: S106 + ) + set_parent_app(minimal_app) + return minimal_app + + +class Pet(BaseUpdatableDisplayModel): + name: str + species: str + + +class Friend(BaseUpdatableDisplayModel): + name: str + age: int + + +class Person(BaseUpdatableDisplayModel): + @cached_property + def rerender_on_type_change(self) -> set[str]: + return {"companion"} + + name: str + age: int + companion: Pet | Friend + + +class FriendComponent(BaseUpdatableComponent[Friend]): + def _draw_ui(self) -> None: + ui.label().bind_text_from( + self.display_model, + "name", + backward=lambda name: f"Friend Name: {name}", + ) + ui.label().bind_text_from( + self.display_model, + "age", + backward=lambda age: f"Friend Age: {age}", + ) + + +class PetComponent(BaseUpdatableComponent[Pet]): + def _draw_ui(self) -> None: + ui.label().bind_text_from( + self.display_model, + "name", + backward=lambda name: f"Pet Name: {name}", + ) + ui.label().bind_text_from( + self.display_model, + "species", + backward=lambda species: f"Pet Species: {species}", + ) + + +class PersonComponent(BaseUpdatableComponent[Person]): + def _draw_ui(self) -> None: + with ui.element().classes("border"): + # NOTE: + # There are 3 ways to bind the UI to the model changes: + # 1. using nicegui builting facilties + # 2. via model attribute VALE change + # 3. via model attribute TYPE change + # The model attribute changes allow to trigger re-rendering of subcomponents. + # This should be mainly used for chainging the UI layout based on + # the attribute's value or type. + + # 1. bind the label directly to the model's attribute + ui.label().bind_text_from( + self.display_model, + "name", + backward=lambda name: f"Name: {name}", + ) + + # 2. use refreshable and bind to the attribute's VALUE change + @ui.refreshable + def _person_age_ui() -> None: + ui.label(f"Age: {self.display_model.age}") + + _person_age_ui() + self.display_model.on_value_change("age", _person_age_ui.refresh) + + # 3. use refreshable and bind to the attribute's TYPE change + @ui.refreshable + def _friend_or_pet_ui() -> None: + if isinstance(self.display_model.companion, Friend): + FriendComponent(self.display_model.companion).display() + + elif isinstance(self.display_model.companion, Pet): + PetComponent(self.display_model.companion).display() + + _friend_or_pet_ui() + self.display_model.on_type_change("companion", _friend_or_pet_ui.refresh) + + +async def _ensure_before_corpus(async_page: Page) -> None: + await assert_contains_text(async_page, "BEFORE_CORPUS") + + +async def _ensure_person_companion(async_page: Page, companion: Pet | Friend) -> None: + if isinstance(companion, Pet): + await assert_contains_text(async_page, f"Pet Name: {companion.name}") + await assert_contains_text(async_page, f"Pet Species: {companion.species}") + elif isinstance(companion, Friend): + await assert_contains_text(async_page, f"Friend Name: {companion.name}") + await assert_contains_text(async_page, f"Friend Age: {companion.age}") + + +async def _ensure_after_corpus(async_page: Page) -> None: + await assert_contains_text(async_page, "AFTER_CORPUS") + + +async def _ensure_person_is_present(async_page: Page, person: Person) -> None: + await _ensure_before_corpus(async_page) + + await assert_contains_text(async_page, f"Name: {person.name}") + await assert_contains_text(async_page, f"Age: {person.age}") + + await _ensure_person_companion(async_page, person.companion) + + await _ensure_after_corpus(async_page) + + +async def _ensure_companion_not_present( + async_page: Page, companion: Pet | Friend +) -> None: + if isinstance(companion, Pet): + await assert_not_contains_text(async_page, f"Pet Name: {companion.name}") + await assert_not_contains_text(async_page, f"Pet Species: {companion.species}") + elif isinstance(companion, Friend): + await assert_not_contains_text(async_page, f"Friend Name: {companion.name}") + await assert_not_contains_text(async_page, f"Friend Age: {companion.age}") + + +async def _ensure_person_not_present(async_page: Page, person: Person) -> None: + await _ensure_before_corpus(async_page) + + await assert_not_contains_text(async_page, f"Name: {person.name}") + await assert_not_contains_text(async_page, f"Age: {person.age}") + + await _ensure_companion_not_present(async_page, person.companion) + + await _ensure_after_corpus(async_page) + + +def _get_updatable_display_model_ids(obj: BaseUpdatableDisplayModel) -> dict[int, str]: + result: dict[int, str] = {id(obj): obj.__class__.__name__} + for value in obj.__dict__.values(): + if isinstance(value, BaseUpdatableDisplayModel): + result[id(value)] = value.__class__.__name__ + return result + + +@pytest.mark.parametrize( + "person, person_update, expect_same_companion_object, expected_callbacks_count", + [ + pytest.param( + Person(name="Alice", age=30, companion=Pet(name="Fluffy", species="cat")), + Person(name="Alice", age=30, companion=Pet(name="Buddy", species="dog")), + True, + 0, + id="update-pet-via-attribute-biding-no-rerender", + ), + pytest.param( + Person(name="Alice", age=30, companion=Pet(name="Fluffy", species="cat")), + Person(name="Alice", age=30, companion=Friend(name="Marta", age=30)), + False, + 1, + id="update-pet-ui-via-rerednder-due-to-type-change", + ), + pytest.param( + Person(name="Alice", age=30, companion=Pet(name="Fluffy", species="cat")), + Person(name="Bob", age=30, companion=Pet(name="Fluffy", species="cat")), + True, + 0, + id="change-person-name-via-bindings", + ), + pytest.param( + Person(name="Alice", age=30, companion=Pet(name="Fluffy", species="cat")), + Person(name="Alice", age=31, companion=Pet(name="Fluffy", species="cat")), + True, + 1, + id="change-person-age-via-rerender-due-to-value-change", + ), + ], +) +async def test_updatable_component( + app_runner: None, + ensure_page_loaded: Callable[[Callable[[], None]], Awaitable[None]], + async_page: Page, + person: Person, + person_update: Person, + expect_same_companion_object: bool, + expected_callbacks_count: NonNegativeInt, +): + def _index_corpus() -> None: + PersonComponent(person).display() + + await ensure_page_loaded(_index_corpus) + + # check initial page layout + await _ensure_person_is_present(async_page, person) + + before_update = _get_updatable_display_model_ids(person) + callbacks_count = person.update(person_update) + after_update = _get_updatable_display_model_ids(person) + assert (before_update == after_update) is expect_same_companion_object + + assert callbacks_count == expected_callbacks_count + + # change layout after update + await _ensure_person_is_present(async_page, person_update) + + # REMOVE only the companion form UI + person.companion.remove_from_ui() + await _ensure_companion_not_present(async_page, person.companion) + + # REMOVE the person form UI + person.remove_from_ui() + await _ensure_person_not_present(async_page, person) + + await _ensure_before_corpus(async_page) + await _ensure_after_corpus(async_page) + + +async def test_multiple_componenets_management( + app_runner: None, + ensure_page_loaded: Callable[[Callable[[], None]], Awaitable[None]], + async_page: Page, +): + stack = UpdatableComponentStack[Person](PersonComponent) + + def _index_corpus() -> None: + stack.display() + + await ensure_page_loaded(_index_corpus) + + person_1 = Person(name="Alice", age=30, companion=Pet(name="Fluffy", species="cat")) + person_2 = Person(name="Bob", age=25, companion=Friend(name="Marta", age=28)) + + # nothing is displayed + await _ensure_person_not_present(async_page, person_1) + await _ensure_person_not_present(async_page, person_2) + + stack.add_or_update_model("person_1", person_1) + stack.add_or_update_model("person_2", person_2) + + # both persons are displayed + await _ensure_person_is_present(async_page, person_1) + await _ensure_person_is_present(async_page, person_2) + + # only person_2 is displayed + stack.remove_model("person_1") + await _ensure_person_not_present(async_page, person_1) + await _ensure_person_is_present(async_page, person_2) + + # no person is displayed + stack.remove_model("person_2") + await _ensure_person_not_present(async_page, person_2) + await _ensure_person_not_present(async_page, person_1) + + # add both persons again together + stack.update_from_dict({"person_1": person_1, "person_2": person_2}) + await _ensure_person_is_present(async_page, person_1) + await _ensure_person_is_present(async_page, person_2) + + # only person_1 is displayed + stack.update_from_dict({"person_1": person_1}) + await _ensure_person_is_present(async_page, person_1) + await _ensure_person_not_present(async_page, person_2) + + # no person is displayed + stack.update_from_dict({}) + await _ensure_person_not_present(async_page, person_1) + await _ensure_person_not_present(async_page, person_2) diff --git a/services/dynamic-scheduler/tests/unit/api_frontend/conftest.py b/services/dynamic-scheduler/tests/unit/api_frontend/conftest.py index 3aef679dfa3f..b1a630989a1c 100644 --- a/services/dynamic-scheduler/tests/unit/api_frontend/conftest.py +++ b/services/dynamic-scheduler/tests/unit/api_frontend/conftest.py @@ -9,6 +9,7 @@ import pytest import sqlalchemy as sa from fastapi import FastAPI, status +from helpers import SCREENSHOT_SUFFIX, SCREENSHOTS_PATH from httpx import AsyncClient from hypercorn.asyncio import serve from hypercorn.config import Config @@ -61,7 +62,8 @@ def server_host_port() -> str: return f"127.0.0.1:{DEFAULT_FASTAPI_PORT}" -def _reset_nicegui_app() -> None: +@pytest.fixture +def reset_nicegui_app() -> None: # forces rebuild of middleware stack on next test # below is based on nicegui.testing.general_fixtures.nicegui_reset_globals @@ -92,14 +94,21 @@ def _reset_nicegui_app() -> None: @pytest.fixture -def not_initialized_app(app_environment: EnvVarsDict) -> FastAPI: - _reset_nicegui_app() +def not_initialized_app( + reset_nicegui_app: None, app_environment: EnvVarsDict +) -> FastAPI: return create_app() +@pytest.fixture +def remove_old_screenshots() -> None: + for old_screenshot in SCREENSHOTS_PATH.glob(f"*{SCREENSHOT_SUFFIX}"): + old_screenshot.unlink() + + @pytest.fixture async def app_runner( - not_initialized_app: FastAPI, server_host_port: str + remove_old_screenshots: None, not_initialized_app: FastAPI, server_host_port: str ) -> AsyncIterable[None]: shutdown_event = asyncio.Event() diff --git a/services/dynamic-scheduler/tests/unit/api_frontend/helpers.py b/services/dynamic-scheduler/tests/unit/api_frontend/helpers.py index 79cf9ad27ebc..f45eba3f25ee 100644 --- a/services/dynamic-scheduler/tests/unit/api_frontend/helpers.py +++ b/services/dynamic-scheduler/tests/unit/api_frontend/helpers.py @@ -14,11 +14,19 @@ from pydantic import NonNegativeFloat, NonNegativeInt, TypeAdapter from tenacity import AsyncRetrying, stop_after_delay, wait_fixed -_HERE: Final[Path] = ( +SCREENSHOTS_PATH: Final[Path] = ( Path(sys.argv[0] if __name__ == "__main__" else __file__).resolve().parent ) _DEFAULT_TIMEOUT: Final[NonNegativeFloat] = 10 +SCREENSHOT_SUFFIX = ".ignore.screenshot.png" + + +async def take_screenshot(async_page: Page, prefix: str = "") -> None: + path = SCREENSHOTS_PATH / f"{prefix}{uuid4()}{SCREENSHOT_SUFFIX}" + await async_page.screenshot(path=path) + print(f"Please check :{path}") + @asynccontextmanager async def take_screenshot_on_error( @@ -28,10 +36,7 @@ async def take_screenshot_on_error( yield # allows to also capture exceptions form `with pytest.raise(...)`` except BaseException: - path = _HERE / f"{uuid4()}.ignore.png" - await async_page.screenshot(path=path) - print(f"Please check :{path}") - + await take_screenshot(async_page) raise diff --git a/services/dynamic-scheduler/tests/unit/api_frontend/_routes_internal_scheduler/conftest.py b/services/dynamic-scheduler/tests/unit/api_frontend/routes_internal_scheduler/conftest.py similarity index 100% rename from services/dynamic-scheduler/tests/unit/api_frontend/_routes_internal_scheduler/conftest.py rename to services/dynamic-scheduler/tests/unit/api_frontend/routes_internal_scheduler/conftest.py diff --git a/services/dynamic-scheduler/tests/unit/api_frontend/_routes_internal_scheduler/test__index_.py b/services/dynamic-scheduler/tests/unit/api_frontend/routes_internal_scheduler/test__index_.py similarity index 100% rename from services/dynamic-scheduler/tests/unit/api_frontend/_routes_internal_scheduler/test__index_.py rename to services/dynamic-scheduler/tests/unit/api_frontend/routes_internal_scheduler/test__index_.py