diff --git a/screenpy_playwright/questions/__init__.py b/screenpy_playwright/questions/__init__.py index 77b148e..55d6f2f 100644 --- a/screenpy_playwright/questions/__init__.py +++ b/screenpy_playwright/questions/__init__.py @@ -1,9 +1,11 @@ """Questions that can be asked using the Abilities in ScreenPy: Playwright.""" +from .attribute import Attribute from .number import Number from .text import Text __all__ = [ + "Attribute", "Number", "Text", ] diff --git a/screenpy_playwright/questions/attribute.py b/screenpy_playwright/questions/attribute.py new file mode 100644 index 0000000..30ce05c --- /dev/null +++ b/screenpy_playwright/questions/attribute.py @@ -0,0 +1,69 @@ +"""Get the value of an HTML attribute on a Target.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from screenpy import UnableToAnswer + +if TYPE_CHECKING: + from screenpy import Actor + from typing_extensions import Self + + from ..target import Target + + +class Attribute: + """Ask about the value of an HTML attribute on a Target. + + Abilities Required: + :class:`~screenpy_playwright.abilities.BrowseTheWebSynchronously` + + Examples: + the_actor.should( + See.the( + Attribute("aria-label").of_the(LOGIN_LINK), + ContainsTheText("Log in to the application.") + ) + ) + + the_actor.attempts_to( + MakeNote.of_the(Attribute("href").of_the(LOGIN_LINK).as_( + "login link" + ), + ) + """ + + target: Target | None + + def __init__(self, attribute: str) -> None: + self.attribute = attribute + self.target = None + + def describe(self) -> str: + """Describe the Question in present tense.""" + return f'The "{self.attribute}" attribute of the {self.target}.' + + def of_the(self, target: Target) -> Self: + """Supply the Target to get the attribute from. + + Args: + target: the Target to get the attribute from. + """ + self.target = target + return self + + def answered_by(self, the_actor: Actor) -> str | None: + """Ask the Actor to get the value of the attribute. + + Args: + the_actor: the Actor who will answer this Question. + + Returns: + str | None: the value of the attribute. + """ + if self.target is None: + msg = "No Target was provided! Supply one with .of_the()." + raise UnableToAnswer(msg) + + return self.target.found_by(the_actor).get_attribute(self.attribute) diff --git a/screenpy_playwright/questions/number.py b/screenpy_playwright/questions/number.py index 99d4791..884b524 100644 --- a/screenpy_playwright/questions/number.py +++ b/screenpy_playwright/questions/number.py @@ -33,7 +33,7 @@ def of(target: Target) -> Number: return Number(target) def describe(self) -> str: - """Describe the Question. + """Describe the Question in the present tense. Returns: A description of this Question. diff --git a/screenpy_playwright/questions/text.py b/screenpy_playwright/questions/text.py index 9d3e020..80611f1 100644 --- a/screenpy_playwright/questions/text.py +++ b/screenpy_playwright/questions/text.py @@ -35,7 +35,7 @@ def of_the(target: Target) -> Text: return Text(target) def describe(self) -> str: - """Describe the Question. + """Describe the Question in the present tense. Returns: A description of this Question. diff --git a/screenpy_playwright/target.py b/screenpy_playwright/target.py index d1ec328..48df6db 100644 --- a/screenpy_playwright/target.py +++ b/screenpy_playwright/target.py @@ -10,6 +10,7 @@ if TYPE_CHECKING: from playwright.sync_api import FrameLocator, Locator, Page from screenpy import Actor + from typing_extensions import Self class Target: @@ -32,10 +33,10 @@ class Target: frame_path: list[str] _description: str | None - @staticmethod - def the(name: str) -> Target: + @classmethod + def the(cls: type[Self], name: str) -> Self: """Provide a human-readable description of the target.""" - return Target(name) + return cls(name) def located_by(self, locator: str) -> Target: """Provide the Playwright locator which describes the element.""" diff --git a/tests/test_actions.py b/tests/test_actions.py index 2790135..cd7445f 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -13,50 +13,47 @@ Visit, ) -from .useful_mocks import get_mocked_target_and_element +from .useful_mocks import get_mock_target_class, get_mocked_target_and_locator + +FakeTarget = get_mock_target_class() +TARGET = FakeTarget() class TestClick: def test_can_be_instantiated(self) -> None: - target, _ = get_mocked_target_and_element() - - c1 = Click(target) - c2 = Click.on_the(target) + c1 = Click(TARGET) + c2 = Click.on_the(TARGET) assert isinstance(c1, Click) assert isinstance(c2, Click) def test_implements_protocol(self) -> None: - target, _ = get_mocked_target_and_element() - - c = Click(target) + c = Click(TARGET) assert isinstance(c, Describable) assert isinstance(c, Performable) def test_describe(self) -> None: - target, _ = get_mocked_target_and_element() + target = FakeTarget() target._description = "The Holy Hand Grenade" assert Click(target).describe() == f"Click on the {target}." def test_perform_click(self, Tester: Actor) -> None: - target, element = get_mocked_target_and_element() + target, locator = get_mocked_target_and_locator() Click.on_the(target).perform_as(Tester) target.found_by.assert_called_once_with(Tester) - element.click.assert_called_once() + locator.click.assert_called_once() class TestEnter: def test_can_be_instantiated(self) -> None: - target, _ = get_mocked_target_and_element() - e1 = Enter("") e2 = Enter.the_text("") e3 = Enter.the_secret("") - e4 = Enter.the_text("").into_the(target) + e4 = Enter.the_text("").into_the(TARGET) assert isinstance(e1, Enter) assert isinstance(e2, Enter) @@ -70,7 +67,7 @@ def test_implements_protocol(self) -> None: assert isinstance(e, Performable) def test_describe(self) -> None: - target, _ = get_mocked_target_and_element() + target = FakeTarget() target._description = "Sir Robin ran away away, brave brave Sir Robin!" text = "Sir Robin ran away!" @@ -89,13 +86,13 @@ def test_complains_for_no_target(self, Tester: Actor) -> None: Enter.the_text("").perform_as(Tester) def test_perform_enter(self, Tester: Actor) -> None: - target, element = get_mocked_target_and_element() + target, locator = get_mocked_target_and_locator() text = "I wanna be, the very best." Enter.the_text(text).into_the(target).perform_as(Tester) target.found_by.assert_called_once_with(Tester) - element.fill.assert_called_once_with(text) + locator.fill.assert_called_once_with(text) class TestSaveScreenshot: diff --git a/tests/test_namespace.py b/tests/test_namespace.py index adc4e1d..381b378 100644 --- a/tests/test_namespace.py +++ b/tests/test_namespace.py @@ -3,6 +3,7 @@ def test_screenpy_playwright() -> None: expected = [ + "Attribute", "BrowseTheWebSynchronously", "Click", "Enter", @@ -38,6 +39,7 @@ def test_actions() -> None: def test_questions() -> None: expected = [ + "Attribute", "Number", "Text", ] diff --git a/tests/test_questions.py b/tests/test_questions.py index 0795335..b11c1bf 100644 --- a/tests/test_questions.py +++ b/tests/test_questions.py @@ -1,65 +1,99 @@ -from screenpy import Actor, Answerable, Describable +"""Tests for the Questions an Actor can ask using ScreenPy Playwright.""" -from screenpy_playwright import Number, Text +from unittest import mock -from .useful_mocks import get_mocked_target_and_element +import pytest +from screenpy import Actor, Answerable, Describable, UnableToAnswer +from screenpy_playwright import Attribute, BrowseTheWebSynchronously, Number, Text -class TestNumber: +from .useful_mocks import get_mock_target_class, get_mocked_target_and_locator + +FakeTarget = get_mock_target_class() +TARGET = FakeTarget() + + +class TestAttribute: def test_can_be_instantiated(self) -> None: - target, _ = get_mocked_target_and_element() + a1 = Attribute("") + a2 = Attribute("").of_the(TARGET) + + assert isinstance(a1, Attribute) + assert isinstance(a2, Attribute) + + def test_implements_protocol(self) -> None: + a = Attribute("") + + assert isinstance(a, Answerable) + assert isinstance(a, Describable) + + def test_raises_error_if_no_target(self, Tester: Actor) -> None: + with pytest.raises(UnableToAnswer): + Attribute("").answered_by(Tester) + + def test_ask_for_attribute(self, Tester: Actor) -> None: + attr = "foo" + value = "bar" + target, locator = get_mocked_target_and_locator() + locator.get_attribute.return_value = value + mocked_btws = Tester.ability_to(BrowseTheWebSynchronously) + mocked_btws.current_page = mock.Mock() - n = Number.of(target) + assert Attribute(attr).of_the(target).answered_by(Tester) == value + target.found_by.assert_called_once_with(Tester) + locator.get_attribute.assert_called_once_with(attr) + + def test_describe(self) -> None: + assert Attribute("foo").describe() == 'The "foo" attribute of the None.' + + +class TestNumber: + def test_can_be_instantiated(self) -> None: + n = Number.of(TARGET) assert isinstance(n, Number) def test_implements_protocol(self) -> None: - target, _ = get_mocked_target_and_element() - - n = Number(target) + n = Number(TARGET) assert isinstance(n, Answerable) assert isinstance(n, Describable) def test_describe(self) -> None: - target, _ = get_mocked_target_and_element() + target = FakeTarget() target._description = "Somebody once told me" assert Number.of(target).describe() == f"The number of {target}." def test_ask_number(self, Tester: Actor) -> None: - target, element = get_mocked_target_and_element() + target, locator = get_mocked_target_and_locator() num_elements = 10 - element.count.return_value = num_elements + locator.count.return_value = num_elements assert Number.of(target).answered_by(Tester) == num_elements class TestText: def test_can_be_instantiated(self) -> None: - target, _ = get_mocked_target_and_element() - - t = Text.of_the(target) + t = Text.of_the(TARGET) assert isinstance(t, Text) def test_implements_protocol(self) -> None: - target, _ = get_mocked_target_and_element() - - t = Text(target) + t = Text(TARGET) assert isinstance(t, Answerable) assert isinstance(t, Describable) def test_describe(self) -> None: - target, _ = get_mocked_target_and_element() + target = FakeTarget() target._description = "the world is gonna roll me" assert Text.of_the(target).describe() == f"The text from the {target}." def test_ask_text(self, Tester: Actor) -> None: - target, element = get_mocked_target_and_element() + target, locator = get_mocked_target_and_locator() words = "Number 1, the larch." - element.text_content.return_value = words + locator.text_content.return_value = words assert Text.of_the(target).answered_by(Tester) == words diff --git a/tests/useful_mocks.py b/tests/useful_mocks.py index 1fb68d7..5409068 100644 --- a/tests/useful_mocks.py +++ b/tests/useful_mocks.py @@ -1,19 +1,39 @@ from __future__ import annotations +from typing import TYPE_CHECKING, cast from unittest import mock from playwright.sync_api import Browser, Locator, Playwright -from screenpy_playwright import Target +from screenpy_playwright import BrowseTheWebSynchronously, Target +if TYPE_CHECKING: + from screenpy import Actor -def get_mocked_target_and_element() -> tuple[mock.Mock, mock.Mock]: + +def get_mocked_locator() -> mock.Mock: + return mock.create_autospec(Locator, instance=True) + + +def get_mock_target_class() -> mock.Mock: + class FakeTarget(Target): + def __new__(cls, *args: object, **kwargs: object) -> FakeTarget: # noqa: ARG003 + return mock.create_autospec(FakeTarget, instance=True) + + return cast(mock.Mock, FakeTarget) + + +def get_mocked_target_and_locator() -> tuple[mock.Mock, mock.Mock]: """Get a mocked target which finds a mocked element.""" - target = mock.Mock(spec=Target) - element = mock.Mock(spec=Locator) - target.found_by.return_value = element + target = get_mock_target_class().the("test object").located_by("test locator") + locator = get_mocked_locator() + target.found_by.return_value = locator + + return target, locator + - return target, element +def get_mocked_browser(actor: Actor) -> mock.Mock: + return cast(mock.Mock, actor.ability_to(BrowseTheWebSynchronously).browser) def get_mocked_playwright_and_browser() -> tuple[mock.Mock, mock.Mock]: