diff --git a/tests/evals/operation_app.py b/tests/evals/operation_app.py index 02f5d0d5..93b4d3ab 100644 --- a/tests/evals/operation_app.py +++ b/tests/evals/operation_app.py @@ -725,7 +725,25 @@ def _apply_intervention(self) -> None: f"{self._journey.id}: dialog_intervention replacement did not apply" ) + async def _await_dialog_composed(self, pilot: Any) -> None: + """Wait until a current approval dialog has its widgets, or closes. + + Textual makes a pushed screen current before it composes the screen's + widgets, and a loaded runner can poll in between (#478): reading the + title then would find none. A dialog that closes first is left to + `handle`, which already treats it as closed before a decision. + """ + screen = self._app.screen + if not isinstance(screen, ConfirmScreen): + return + await until( + pilot, + lambda: self._app.screen is not screen or bool(screen.query(".confirm-title")), + label="approval dialog composed", + ) + async def handle(self, pilot: Any) -> None: + await self._await_dialog_composed(pilot) screen = self._app.screen if not isinstance(screen, ConfirmScreen): if self._remaining <= 0 or not self._dialog_seen: diff --git a/tests/evals/test_operation_journeys.py b/tests/evals/test_operation_journeys.py index 54e97d94..7ba9d569 100644 --- a/tests/evals/test_operation_journeys.py +++ b/tests/evals/test_operation_journeys.py @@ -8,6 +8,7 @@ from __future__ import annotations +import asyncio import json import os from collections.abc import AsyncIterator, Callable @@ -31,6 +32,7 @@ from korvid.evals.scripted import ScriptedProvider from korvid.tools.executor import RecordedExecution, ToolOutcome from korvid.tools.structured import dump_yaml +from korvid.ui.widgets.confirm_screen import ConfirmScreen from . import operation_app from .operation_app import ( @@ -1331,6 +1333,25 @@ def polls_that_miss_every_dialog( assert run.audit == () +async def test_a_dialog_still_composing_is_read_once_it_has_its_widgets( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Textual makes a pushed screen current before it composes the screen's + widgets, and a loaded runner can poll in between (#478). The driver must + wait for the dialog's title instead of reading a dialog that has none.""" + original = ConfirmScreen._compose + + async def slow_compose(self: ConfirmScreen) -> None: + await asyncio.sleep(0.3) + await original(self) + + monkeypatch.setattr(ConfirmScreen, "_compose", slow_compose) + run = await run_scripted_journey("scale-deployment-up", tmp_path) + observed = [entry for entry in run.journal if entry["event"] == "dialog_observed"] + assert [entry["result"] for entry in observed] == ["matched"] + assert run.grade.hard_failures == () + + def test_the_replacement_journey_declares_its_intervention_in_the_fixture() -> None: """B1: the mid-dialog replacement is fixture data, not test code, so `tests.evals.operation_campaign` runs the identical journey."""