diff --git a/tests/ui/test_olm_view.py b/tests/ui/test_olm_view.py index 4124f1f76..e22ec4315 100644 --- a/tests/ui/test_olm_view.py +++ b/tests/ui/test_olm_view.py @@ -27,7 +27,7 @@ from korvid.ui.widgets.resource_table import ResourceTable from tests.app_factory import build_test_app -from .waits import until +from .waits import screen_ready, until _PODS_META = ResourceMeta("Pod", "pods", "", "v1", True, ("po",)) PKG_META = ResourceMeta("PackageManifest", "packagemanifests", PACKAGES_GROUP, "v1", True) @@ -356,9 +356,7 @@ async def test_install_key_walks_wizard_confirm_and_creates_subscription( table = app.query_one(ResourceTable) await until(pilot, lambda: table.row_count == 1, label="package listed") await pilot.press("I") - await until( - pilot, lambda: isinstance(app.screen, OperatorInstallPrompt), label="wizard open" - ) + await until(pilot, lambda: screen_ready(app, OperatorInstallPrompt), label="wizard open") await pilot.press("enter") # accept defaults: operators/stable/Automatic await until(pilot, lambda: isinstance(app.screen, ConfirmScreen), label="confirm open") body = app.screen.query_one(".confirm-operation").render() @@ -595,9 +593,7 @@ async def test_install_fetches_manifest_by_canonical_view_kind(tmp_path: Path) - table = app.query_one(ResourceTable) await until(pilot, lambda: table.row_count == 1, label="package listed") await pilot.press("I") - await until( - pilot, lambda: isinstance(app.screen, OperatorInstallPrompt), label="wizard open" - ) + await until(pilot, lambda: screen_ready(app, OperatorInstallPrompt), label="wizard open") assert fetch_log == [qualified] @@ -675,9 +671,7 @@ async def test_install_wizard_prefills_default_namespace_with_zero_config( table = app.query_one(ResourceTable) await until(pilot, lambda: table.row_count == 1, label="package listed") await pilot.press("I") - await until( - pilot, lambda: isinstance(app.screen, OperatorInstallPrompt), label="wizard open" - ) + await until(pilot, lambda: screen_ready(app, OperatorInstallPrompt), label="wizard open") ns_input = app.screen.query_one("#install-namespace") assert getattr(ns_input, "value", "") == "default" @@ -729,9 +723,7 @@ async def record_check( table = app.query_one(ResourceTable) await until(pilot, lambda: table.row_count == 1, label="package listed") await pilot.press("I") - await until( - pilot, lambda: isinstance(app.screen, OperatorInstallPrompt), label="wizard open" - ) + await until(pilot, lambda: screen_ready(app, OperatorInstallPrompt), label="wizard open") await pilot.press("enter") await until(pilot, lambda: bool(checks), label="permission pre-check ran") assert checks[0] == ("create", "subscriptions", "") @@ -761,7 +753,7 @@ async def test_the_controller_routes_the_install_key_to_the_package_wizard( await app._olm.install_selected() await until( pilot, - lambda: isinstance(app.screen, OperatorInstallPrompt), + lambda: screen_ready(app, OperatorInstallPrompt), label="install wizard", ) assert rec.calls == [] diff --git a/tests/ui/test_waits.py b/tests/ui/test_waits.py index dddeb121c..8b0fbb2e6 100644 --- a/tests/ui/test_waits.py +++ b/tests/ui/test_waits.py @@ -3,8 +3,11 @@ from __future__ import annotations import pytest +from textual.app import App, ComposeResult +from textual.screen import ModalScreen +from textual.widgets import Select -from .waits import WaitTimeout, until +from .waits import WaitTimeout, screen_ready, until class _FakePilot: @@ -50,3 +53,50 @@ async def test_until_timeout_is_not_an_assertion_error() -> None: with pytest.raises(WaitTimeout, match=r"condition not met within 0\.05s") as caught: await until(pilot, lambda: False, timeout=0.05) assert not isinstance(caught.value, AssertionError) + + +class _Node: + def __init__(self, *, mounted: bool = True, children: tuple[_Node, ...] = ()) -> None: + self.is_mounted = mounted + self.children = children + + def query(self, selector: str) -> tuple[_Node, ...]: + assert selector == "*" + return self.children + + +class _Prompt(_Node): + pass + + +class _App: + def __init__(self, screen: _Node) -> None: + self.screen = screen + + +@pytest.mark.parametrize( + ("screen", "ready"), + [ + pytest.param(_Node(), False, id="another screen"), + pytest.param(_Prompt(mounted=False), False, id="screen still mounting"), + pytest.param( + _Prompt(children=(_Node(), _Node(mounted=False))), False, id="a widget still mounting" + ), + pytest.param(_Prompt(children=(_Node(), _Node())), True, id="every widget mounted"), + ], +) +def test_screen_ready_waits_for_the_screen_and_every_widget(screen: _Node, ready: bool) -> None: + assert screen_ready(_App(screen), _Prompt) is ready + + +async def test_screen_ready_holds_only_once_a_select_has_mounted() -> None: + class Prompt(ModalScreen[None]): + def compose(self) -> ComposeResult: + yield Select([("stable", "stable"), ("fast", "fast")], value="stable") + + app = App[None]() + async with app.run_test() as pilot: + app.push_screen(Prompt()) + await until(pilot, lambda: screen_ready(app, Prompt), label="prompt ready") + + assert app.screen.query_one(Select).is_mounted diff --git a/tests/ui/test_write_confirm_characterization.py b/tests/ui/test_write_confirm_characterization.py index adc331f9e..1c41b1e8d 100644 --- a/tests/ui/test_write_confirm_characterization.py +++ b/tests/ui/test_write_confirm_characterization.py @@ -83,7 +83,7 @@ from .test_resize_flow import ResizeRecorder from .test_resize_flow import make_app as make_resize_app from .test_write_ops import Recorder, _edit_fixtures, _to_view, make_app -from .waits import until +from .waits import screen_ready, until async def _row_listed(pilot, kind: str) -> None: # type: ignore[no-untyped-def] # Pilot's app type isn't exposed @@ -266,9 +266,7 @@ async def test_install_cancelled_when_catalog_entry_changes_during_approval( table = app.query_one(ResourceTable) await until(pilot, lambda: table.row_count == 1, label="package listed") await pilot.press("I") - await until( - pilot, lambda: isinstance(app.screen, OperatorInstallPrompt), label="wizard open" - ) + await until(pilot, lambda: screen_ready(app, OperatorInstallPrompt), label="wizard open") await pilot.press("enter") # accept defaults await until(pilot, lambda: isinstance(app.screen, ConfirmScreen), label="confirm open") # Same name, different incarnation lands while the dialog is open. diff --git a/tests/ui/waits.py b/tests/ui/waits.py index 11a3a4dc3..b86bb266d 100644 --- a/tests/ui/waits.py +++ b/tests/ui/waits.py @@ -54,3 +54,17 @@ async def until( if cond(): return raise WaitTimeout(f"{label} not met within {timeout}s") + + +def screen_ready(app: Any, screen_type: type) -> bool: + """Whether `app` shows a `screen_type` whose every widget has finished mounting. + + A screen is current as soon as it is pushed, before its widgets run their + Mount handlers. A test that leaves `run_test` at that point can tear a + widget down mid-handler: Textual's `Select` looks up its overlay in + `_on_mount`, and fails if the overlay is already gone (#504). + """ + if not isinstance(app.screen, screen_type): + return False + screen: Any = app.screen + return bool(screen.is_mounted) and all(widget.is_mounted for widget in screen.query("*"))