Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#142: Add Stop Action. #143

Merged
merged 3 commits into from
May 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/api/actions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,13 @@ Silently
**Aliases**: ``Quietly``

.. autofunction:: Silently


Stop
----

**Aliases**: ``Stops``

.. autoclass:: Stop
:members:
:exclude-members: description_to_log, question_to_log, resolution_to_log
4 changes: 4 additions & 0 deletions screenpy/actions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from .see_all_of import SeeAllOf
from .see_any_of import SeeAnyOf
from .silently import Silently
from .stop import Stop

# Natural-language-enabling syntactic sugar
AttachFile = AttachAFile = AttachTheFile
Expand All @@ -24,6 +25,7 @@
Quietly = Silently
Sleep = Pause
Sleeps = Pauses = Pause
Stops = Stop
TakeNote = MakeNote
TakesNote = MakesNote = MakeNote
Attempts = AttemptsTo = GoesFor = Tries = TriesTo = Either
Expand Down Expand Up @@ -79,6 +81,8 @@
"Silently",
"Sleep",
"Sleeps",
"Stop",
"Stops",
"TakeNote",
"TakesNote",
"Tries",
Expand Down
99 changes: 99 additions & 0 deletions screenpy/actions/stop.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""Tell an Actor to Stop!"""

from __future__ import annotations

from typing import TYPE_CHECKING

from screenpy.configuration import settings
from screenpy.exceptions import DeliveryError
from screenpy.pacing import beat
from screenpy.speech_tools import get_additive_description

from .eventually import Eventually
from .see import See
from .silently import Silently

if TYPE_CHECKING:
from typing_extensions import Self

from screenpy import Actor
from screenpy.protocols import Answerable, Resolvable


class Stop:
"""Stop until a condition is met.

The condition could be a Question and a Resolution, or it could be you (the
test runner) pressing ``enter`` on your keyboard.

If this Action is used directly (like ``Stop()``), the Actor will stop until
you press enter to continue the test. In this way it can be used to assess
the current state of the system under test, similar to :ref:`Debug`.

If this Action is passed a Question and a Resolution, it will tell the Actor
to stop until the condition is met. This is essentially the same as
:ref:`Eventually` (:ref:`See` (...)), but does not carry the connotation of a
test assertion.

Examples::

the_actor(Stops())

the_actor.attempts_to(Stop.until_the(TotalCakesBaked(), IsEqualTo(20)))

the_actor.will(Stop.until_the(AudienceTension(), IsGreaterThan(9000)))
"""

@classmethod
def until_the(cls, question: Answerable, resolution: Resolvable) -> Self:
"""Specify the condition to wait for."""
return cls(question, resolution)

def __init__(
self, question: Answerable | None = None, resolution: Resolvable | None = None
) -> None:
self.question = question
self.resolution = resolution

def describe(self) -> str:
"""Describe the Action in present tense."""
return f"Stop until {self.description_to_log}."

@property
def question_to_log(self) -> str:
"""Represent the Question in a log-friendly way."""
return get_additive_description(self.question)

@property
def resolution_to_log(self) -> str:
"""Represent the Resolution in a log-friendly way."""
return get_additive_description(self.resolution)

@property
def description_to_log(self) -> str:
"""Represent the Action in a log-friendly way."""
if self.question is None and self.resolution is None:
return "they hear your cue"
return f"{self.question_to_log} is {self.resolution_to_log}"

@beat("{} stops until {description_to_log}.")
def perform_as(self, the_actor: Actor) -> None:
"""Direct the Actor to stop until the condition is met."""
if self.question is None or self.resolution is None:
msg = (
"\n\nThe Actor stops suddenly, waiting for your cue..."
"\n (press enter to continue): "
)
input(msg)
return

try:
the_actor.attempts_to(
Silently(Eventually(See.the(self.question, self.resolution)))
)
except DeliveryError as caught_exception:
msg = (
f"{the_actor} stopped for {settings.TIMEOUT} seconds, but"
f" {self.question_to_log} was never {self.resolution_to_log}."
)
raise DeliveryError(msg) from caught_exception
72 changes: 72 additions & 0 deletions tests/test_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
SeeAllOf,
SeeAnyOf,
Silently,
Stop,
UnableToAct,
UnableToDirect,
beat,
Expand Down Expand Up @@ -1062,6 +1063,77 @@ def perform_as(self, the_actor: Actor) -> None:
assert [r.msg for r in caplog.records] == []


class TestStop:
def test_can_be_instantiated(self) -> None:
s1 = Stop()
s2 = Stop.until_the(FakeQuestion(), FakeResolution())

assert isinstance(s1, Stop)
assert isinstance(s2, Stop)

def test_implements_protocol(self) -> None:
s = Stop()

assert isinstance(s, Performable)
assert isinstance(s, Describable)

def test_describe(self) -> None:
mock_question = FakeQuestion()
mock_question.describe.return_value = "The number of stars in the sky"
mock_resolution = FakeResolution()
mock_resolution.describe.return_value = "equal to the stars in your eyes."

s1 = Stop()
s2 = Stop.until_the(mock_question, mock_resolution)

expected_description = (
"Stop until the number of stars in the sky"
" is equal to the stars in your eyes."
)
assert s1.describe() == "Stop until they hear your cue."
assert s2.describe() == expected_description

def test_calls_input_with_no_question_and_resolution(self, Tester: Actor) -> None:
with mock.patch("builtins.input", return_value="") as mocked_input:
Stop().perform_as(Tester)

mocked_input.assert_called_once()

def test_calls_silently_with_question_and_resolution(self, Tester: Actor) -> None:
mock_question = FakeQuestion()
mock_resolution = FakeResolution()

silently_path = "screenpy.actions.stop.Silently"
with mock.patch(silently_path) as mocked_silently:
Stop.until_the(mock_question, mock_resolution).perform_as(Tester)

mocked_silently.assert_called_once()

def test_modifies_deliveryerror(self, Tester: Actor) -> None:
exc_msg = "Toooniiiiight, a-ding ding ding..."

eventually_path = "screenpy.actions.stop.Eventually"
with mock.patch(eventually_path) as mocked_eventually:
mocked_eventually.side_effect = DeliveryError(exc_msg)
with pytest.raises(DeliveryError) as actual_exception:
Stop.until_the(FakeQuestion(), FakeResolution()).perform_as(Tester)

assert exc_msg not in str(actual_exception)

def test_narration(self, Tester: Actor, caplog: pytest.LogCaptureFixture) -> None:
Question = FakeQuestion()
Resolution = FakeResolution()

with caplog.at_level(logging.INFO):
Stop.until_the(Question, Resolution).perform_as(Tester)

assert len(caplog.records) == 1
assert (
caplog.records[0].message
== "Tester stops until fakeQuestion is fakeResolution."
)


class TestEither:
settings_path = "screenpy.actions.either.settings"

Expand Down
4 changes: 4 additions & 0 deletions tests/test_namespace.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@ def test_screenpy() -> None:
"StartWith",
"StdOutAdapter",
"StdOutManager",
"Stop",
"Stops",
"TakeNote",
"TakesNote",
"the_narrator",
Expand Down Expand Up @@ -208,6 +210,8 @@ def test_actions() -> None:
"Silently",
"Sleep",
"Sleeps",
"Stop",
"Stops",
"TakeNote",
"TakesNote",
"Tries",
Expand Down