Skip to content

Commit 2194bc2

Browse files
authored
Target from parent (#75)
* adding parent locator support * tests for target parents * still have to support 3.9 unions * repr needs to include parentage * importing WebElement directly from it's own module. * adding documentation for Target.inside * making inside not mutate the Target * fixing import example. * adding alias variation tests * making `inside` work with subclassed Target * stylizing method for better clarity. * better links * linking directly to the method * adding note about inside not mutating Target * trying to used the semantic linebreak method * documentation feedback * better wording for `inside`
1 parent 64e3da0 commit 2194bc2

3 files changed

Lines changed: 135 additions & 14 deletions

File tree

docs/targets.rst

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ Targets
33
=======
44

55
The blocking of the screenplay!
6-
The Target tells the Actors
6+
7+
The :class:`~screenpy_selenium.Target` tells the
8+
:external+screenpy:class:`~screenpy.Actor`
79
what part of the website
810
they are to interact with.
911

1012
Stripping away the metaphor,
11-
the :ref:`target` combines a locator
13+
the :class:`~screenpy_selenium.Target` combines a locator
1214
with a human-readable string.
1315
The human-readable part
1416
is what gets read out
@@ -41,7 +43,7 @@ by passing them to Actions::
4143

4244
from example_test.ui.login_page import (
4345
PASSWORD_FIELD,
44-
SIGN_IN_BUTTON
46+
SIGN_IN_BUTTON,
4547
USERNAME_FIELD,
4648
)
4749

@@ -60,9 +62,9 @@ The resulting log:
6062
| Webster enters "[CENSORED]" into the password field.
6163
| Websert clicks on the "Sign In" button.
6264
63-
By default the :ref:`target` will use the locator string as a human-readable
64-
``target_name`` in the absence of providing one. This can be convenient if your
65-
locators are self-describing::
65+
By default the :class:`~screenpy_selenium.Target` will use the locator string
66+
as a human-readable ``target_name`` in the absence of providing one.
67+
This can be convenient if your locators are self-describing::
6668

6769
from screenpy_selenium import Target
6870
from selenium.webdriver.common.by import By
@@ -81,5 +83,40 @@ The resulting log:
8183

8284
| Webster enters "foo" into the username-field.
8385
| Webster enters "[CENSORED]" into the password-field.
84-
| Websert clicks on the sign-in-button.
86+
| Webster clicks on the sign-in-button.
87+
88+
89+
Target in Target
90+
----------------
91+
92+
A :class:`~screenpy_selenium.Target` (as seen above)
93+
will typically have Selenium do a search
94+
for the locator starting at the root of the DOM::
95+
96+
# These are equivalent
97+
98+
web_element = USERNAME_FIELD.found_by(Webster)
99+
100+
web_element = driver.find_element(*USERNAME_FIELD)
101+
102+
Selenium also has the ability
103+
to search for a child WebElement
104+
starting from an already-found parent WebElement.
105+
:class:`~screenpy_selenium.Target` can do the same
106+
by utilizing the method :meth:`~screenpy.target.Target.inside`::
107+
108+
# These three are equivalent
109+
110+
elem1 = USERNAME_FIELD.inside(LOGIN_FORM).found_by(Webster)
111+
112+
form_elem = driver.find_element(*LOGIN_FORM)
113+
elem2 = form_elem.find_element(*USERNAME_FIELD)
114+
115+
elem3 = driver.find_element(*LOGIN_FORM).find_element(*USERNAME_FIELD)
116+
117+
118+
.. note::
119+
120+
:meth:`~screenpy.target.Target.inside` does not mutate :class:`~screenpy_selenium.Target`.
121+
This is done purposefully so the existing `Target` can be reused.
85122

screenpy_selenium/target.py

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88

99
from __future__ import annotations
1010

11-
from typing import TYPE_CHECKING
11+
import copy
12+
from typing import TYPE_CHECKING, Union
1213

1314
from selenium.common.exceptions import WebDriverException
1415
from selenium.webdriver.common.by import By
@@ -20,9 +21,12 @@
2021
from collections.abc import Iterator
2122

2223
from screenpy.actor import Actor
23-
from selenium.webdriver.remote.webdriver import WebElement
24+
from selenium.webdriver.remote.webdriver import WebDriver
25+
from selenium.webdriver.remote.webelement import WebElement
2426
from typing_extensions import Self
2527

28+
WebDriverOrWebElement = Union[WebDriver, WebElement]
29+
2630

2731
class Target:
2832
"""Describe an element with a human-readable string and a locator.
@@ -41,6 +45,7 @@ class Target:
4145

4246
_description: str | None = None
4347
locator: tuple[str, str] | None = None
48+
parent_target: Target | None = None
4449

4550
@property
4651
def target_name(self) -> str | None:
@@ -112,24 +117,53 @@ def get_locator(self) -> tuple[str, str]:
112117

113118
def found_by(self, the_actor: Actor) -> WebElement:
114119
"""Retrieve the |WebElement| as viewed by the Actor."""
115-
browser = the_actor.ability_to(BrowseTheWeb).browser
120+
driver_or_element: WebDriverOrWebElement
121+
if self.parent_target:
122+
driver_or_element = self.parent_target.found_by(the_actor)
123+
else:
124+
driver_or_element = the_actor.ability_to(BrowseTheWeb).browser
125+
116126
try:
117-
return browser.find_element(*self)
127+
return driver_or_element.find_element(*self)
118128
except WebDriverException as e:
119129
msg = f"{e} raised while trying to find {self}."
120130
raise TargetingError(msg) from e
121131

122132
def all_found_by(self, the_actor: Actor) -> list[WebElement]:
123133
"""Retrieve a list of |WebElement| objects as viewed by the Actor."""
124-
browser = the_actor.ability_to(BrowseTheWeb).browser
134+
driver_or_element: WebDriverOrWebElement
135+
if self.parent_target:
136+
driver_or_element = self.parent_target.found_by(the_actor)
137+
else:
138+
driver_or_element = the_actor.ability_to(BrowseTheWeb).browser
139+
125140
try:
126-
return browser.find_elements(*self)
141+
return driver_or_element.find_elements(*self)
127142
except WebDriverException as e:
128143
msg = f"{e} raised while trying to find {self}."
129144
raise TargetingError(msg) from e
130145

146+
def inside(self, parent_target: Target) -> Self:
147+
"""Set the containing parent element for this Target.
148+
149+
Creates a new Target instance.
150+
"""
151+
new_target = copy.copy(self)
152+
new_target.parent_target = parent_target
153+
return new_target
154+
155+
def inside_of(self, parent_target: Target) -> Self:
156+
"""Alias for :meth:`~screenpy_selenium.Target.inside`."""
157+
return self.inside(parent_target)
158+
159+
def within(self, parent_target: Target) -> Self:
160+
"""Alias for :meth:`~screenpy_selenium.Target.inside`."""
161+
return self.inside(parent_target)
162+
131163
def __repr__(self) -> str:
132164
"""A Target is represented by its name."""
165+
if self.parent_target:
166+
return f"{self.target_name} in {self.parent_target}"
133167
return f"{self.target_name}"
134168

135169
__str__ = __repr__

tests/test_target.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from screenpy_selenium import Target, TargetingError
1010

11-
from .useful_mocks import get_mocked_browser
11+
from .useful_mocks import get_mocked_browser, get_mocked_target_and_element
1212

1313
if TYPE_CHECKING:
1414
from screenpy import Actor
@@ -103,6 +103,26 @@ def test_locator_tuple_size() -> None:
103103
Target("test").located_by([By.ID, "foo"]) # type: ignore[arg-type]
104104

105105

106+
def test_inside() -> None:
107+
t1 = Target.the("one").located((By.ID, "one"))
108+
t2 = Target.the("two").located((By.ID, "two"))
109+
t3 = t1.inside(t2)
110+
t4 = t1.inside_of(t2)
111+
t5 = t1.within(t2)
112+
113+
assert t1 is not t3
114+
assert t1 is not t4
115+
assert t1 is not t5
116+
assert t2 is not t3
117+
assert t2 is not t4
118+
assert t2 is not t5
119+
assert t1.parent_target is None
120+
assert t2.parent_target is None
121+
assert t3.parent_target is t2
122+
assert t4.parent_target is t2
123+
assert t5.parent_target is t2
124+
125+
106126
def test_found_by(Tester: Actor) -> None:
107127
test_locator = (By.ID, "eggs")
108128
Target.the("test").located(test_locator).found_by(Tester)
@@ -121,6 +141,15 @@ def test_found_by_raises(Tester: Actor) -> None:
121141
assert test_name in str(excinfo.value)
122142

123143

144+
def test_found_by_parent(Tester: Actor) -> None:
145+
parent, mocked_element = get_mocked_target_and_element()
146+
test_locator = (By.ID, "child")
147+
148+
Target.the("test").located(test_locator).inside(parent).found_by(Tester)
149+
mocked_element.find_element.assert_called_once_with(*test_locator)
150+
parent.found_by.assert_called_once_with(Tester)
151+
152+
124153
def test_all_found_by(Tester: Actor) -> None:
125154
test_locator = (By.ID, "baked beans")
126155
Target.the("test").located(test_locator).all_found_by(Tester)
@@ -139,6 +168,15 @@ def test_all_found_by_raises(Tester: Actor) -> None:
139168
assert test_name in str(excinfo.value)
140169

141170

171+
def test_all_found_by_parent(Tester: Actor) -> None:
172+
parent, mocked_element = get_mocked_target_and_element()
173+
test_locator = (By.ID, "children")
174+
175+
Target.the("test").located(test_locator).inside_of(parent).all_found_by(Tester)
176+
mocked_element.find_elements.assert_called_once_with(*test_locator)
177+
parent.found_by.assert_called_once_with(Tester)
178+
179+
142180
def test_iterator() -> None:
143181
locator = (By.ID, "eggs")
144182
target = Target.the("test").located(locator)
@@ -160,14 +198,26 @@ def test_empty_target_iterator() -> None:
160198
def test_repr() -> None:
161199
t1 = Target()
162200
t2 = Target("foo")
201+
t3 = Target("bar").inside(Target("baz"))
202+
t4 = Target("abc").inside(Target("def").inside(Target("ghi")))
203+
t5 = Target().located((By.ID, "bla")).inside(Target().located((By.XPATH, "//div")))
163204

164205
assert repr(t1) == "None"
165206
assert repr(t2) == "foo"
207+
assert repr(t3) == "bar in baz"
208+
assert repr(t4) == "abc in def in ghi"
209+
assert repr(t5) == "bla in //div"
166210

167211

168212
def test_str() -> None:
169213
t1 = Target()
170214
t2 = Target("foo")
215+
t3 = Target("bar").inside(Target("baz"))
216+
t4 = Target("abc").inside(Target("def").inside(Target("ghi")))
217+
t5 = Target().located((By.ID, "bla")).inside(Target().located((By.XPATH, "//div")))
171218

172219
assert str(t1) == "None"
173220
assert str(t2) == "foo"
221+
assert str(t3) == "bar in baz"
222+
assert str(t4) == "abc in def in ghi"
223+
assert str(t5) == "bla in //div"

0 commit comments

Comments
 (0)