Skip to content

Commit

Permalink
[#530] REFACTOR: be.blank, have.attribute, ...
Browse files Browse the repository at this point in the history
... be.present_in_dom and be.visible (add _describe_actual_result), have.text and have.exact_text (reuse _ElementHasSomethingSupportingIgnoreCase), have.texts and have.exact_texts (reuse _CollectionHasSomethingSupportingIgnoreCase), have.tag and have.tag_containing,

* add _describe_actual_result support to Condition, Match, ...
* add some new TODOs and todos
  * see at least changelog for TODOs

NEW:
+ be._empty 4-in-1 experimental condition (over deprecated be.empty collection condition)
  • Loading branch information
yashaka committed Jul 7, 2024
1 parent 9b0b092 commit 9d99701
Show file tree
Hide file tree
Showing 29 changed files with 1,800 additions and 298 deletions.
44 changes: 30 additions & 14 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,33 +122,37 @@ seems like currently we do raise, but cover with tests

especially relevant for have.texts to turn on/off ignoring invisible elements

## 2.0.0rc10: «copy&paste, frames, shadow & texts_like» (to be released on DD.05.2024)

### TODO: decide on ... vs (...,) as one_or_more
### TODO: add `<` before driver.switch_to.* tab in iframe faq doc

### TODO: ensure no warnings
### TODO: All and AllElements aliases to Collection (maybe even deprecate Collection)

### TODO: add `<` before driver.switch_to.* tab in iframe faq doc
```python
# TODO: consider renaming or at list aliased to AllElements
# for better consistency with browser.all(selector)
# and maybe even aliased by All for nicer POM support via descriptors
class Collection(WaitingEntity['Collection'], Iterable[Element]):
```

### TODO: rename all conditions inside match.py so match can be fully used instead be + have #530
#### TODO: also check if have.size will be still consistent fully... maybe have.count?

### TODO: ENSURE ALL Condition.as_not USAGES ARE NOW CORRECT
## 2.0.0rc10: «copy&paste, frames, shadow & texts_like» (to be released on DD.05.2024)

...
### TODO: decide on ... vs (...,) as one_or_more

### TODO: ENSURE composed conditions work as expected (or/and, etc.)
### TODO: ensure no warnings

...
### TODO: rename all conditions inside match.py so match can be fully used instead be + have #530

### TODO: Consider renaming description to name for Condition, Match, Query, Command, etc.

### TODO: decide on describe_actual for Match & Condition
### TODO: check if there are no type errors on passing be._empty to should

#### TODO: finalize corresponding error messages tests for present, visible, hidden
### TODO: decide on Match `__init__` fate: allow by as positional or not?

### TODO: decide on Match fate (subclass OR subclass + match* 2 in 1)
- probably not for now (let's come back after 2.0)... actual already can be passed as positional...
- passing by as positional would not add so much benefits...

#### TODO: do we need positional actual and by args for Match?
### Consider making configurable the "filtering collection for visibility" like in texts type of conditions

### Deprecated conditions

Expand All @@ -164,6 +168,18 @@ especially relevant for have.texts to turn on/off ignoring invisible elements

Consider `be.hidden` as "hidden somewhere, maybe in DOM with "display:none", or even on frontend/backend, i.e. totally absent from the page". Then `be.hidden_in_dom` is stricter, and means "hidden in DOM, i.e. available in the page DOM, but not visible".

### Added experimental 4-in-1 be._empty over deprecated collection-condition be.empty

`be._empty` is similar to `be.blank`... But `be.blank` works only for singular elements: if they are a value-based elements (like inputs and textarea) then it checks for empty value, if they are text-based elements then it checks for empty text content.

The `be._empty` condition works same but if applied to collection
then will assert that it has size 0. Additionally, when applied to "form" element,
then it will check for all form "value-like" inputs to be empty.

Hence, the `blank` condition is more precise, while `empty` is more general. Because of its generality, the `empty` condition can be easier to remember, but can lead to some kind of confusion when applied to both element and collection in same test. Also, its behavior can be less predictable from the user perspective when applied to form with textarea, that is both "value-like" and "text-based" element. That's why it is still marked as experimental.

Please, consider using `be._empty` to test it in your context and provide a feedback under [#544](https://github.com/yashaka/selene/issues/544), so we can decide on its fate – keep it or not.

### Text related conditions now accepts int and floats as text item

`.have.exact_texts(1, 2.0, '3')` is now possible, and will be treated as `['1', '2.0', '3']`
Expand Down
29 changes: 29 additions & 0 deletions selene/common/appium_tools.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# MIT License
#
# Copyright (c) 2024 Iakiv Kramarenko
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.


def _is_mobile_element(element):
try:
from appium.webdriver import WebElement as MobileElement
except ImportError:
return False
return isinstance(element, MobileElement)
1 change: 1 addition & 0 deletions selene/common/predicate.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import re


# todo: should it be more like .is_not_none?
def is_truthy(something):
return bool(something) if not something == '' else True

Expand Down
55 changes: 50 additions & 5 deletions selene/core/condition.py
Original file line number Diff line number Diff line change
Expand Up @@ -803,6 +803,7 @@ def __init__(
*,
actual: Lambda[E, R],
by: Predicate[R],
_describe_actual_result: Lambda[R, str] | None = None,
_inverted=False,
_falsy_exceptions: Iterable[Type[Exception]] = (AssertionError,),
): ...
Expand Down Expand Up @@ -838,6 +839,7 @@ def __init__(
*,
actual: Lambda[E, R] | None = None,
by: Predicate[R] | None = None,
_describe_actual_result: Lambda[R, str] | None = None,
_inverted=False,
_falsy_exceptions: Iterable[Type[Exception]] = (AssertionError,),
):
Expand All @@ -854,11 +856,13 @@ def __init__(
'not both'
)
self.__actual = actual
self.__describe_actual_result = _describe_actual_result
self.__by = by
self.__test = (
ConditionMismatch._to_raise_if_not(
self.__by,
self.__actual,
_describe_actual_result=self.__describe_actual_result,
_falsy_exceptions=_falsy_exceptions,
)
if self.__actual
Expand All @@ -871,8 +875,7 @@ def __init__(
ConditionMismatch._to_raise_if_actual(
self.__actual,
self.__by,
# TODO: should we DI? – remove this tight coupling to WebDriverException?
# here and elsewhere
_describe_actual_result=self.__describe_actual_result,
_falsy_exceptions=_falsy_exceptions,
)
if self.__actual
Expand Down Expand Up @@ -958,6 +961,7 @@ def not_(self) -> Condition[E]:
self.__description,
actual=self.__actual, # type: ignore
by=self.__by,
_describe_actual_result=self.__describe_actual_result,
_inverted=not self.__inverted,
_falsy_exceptions=self.__falsy_exceptions,
)
Expand Down Expand Up @@ -985,18 +989,47 @@ def __describe_inverted(self) -> str:
def __str__(self):
return self.__describe() if not self.__inverted else self.__describe_inverted()

# TODO: we already have entity.matching for Callable[[E], bool]
# todo: we already have entity.matching for Callable[[E], bool]
# is it a good idea to use same term for Callable[[E], None] raising error?
# but is match vs matchING distinction clear enough?
# like "Match it!" says "execute the order!"
# and "Matching it?" says "give an answer (True/False) is it matched?"
# should we then add one more method to distinguish them? self.matching?
# or self.is_matched? (but this will contradict with entity.matching)
# still, self.match contradicts with pattern.match(string) that does not raise
# TODO: would a `test` be a better name?
# todo: would a `test` be a better name?
# kind of test term relates to testing in context of assertions...
# though naturally it does not feel like "assertion"...
# more like "predicate" returning bool (True/False), not raising exception
# TODO: given named as test (or match, etc.)... what if we allow users to do asserts in Selene
# that are kind of "classic assertions", i.e. without waiting built in...
# then it may look something like this:
# > from selene import browser
# > from selene.core import match as assert_
# > ...
# > browser.element('input').clear()
# > assert_.blank.test(browser.element('input'))
# > # OR:
# > assert_.blank.match(browser.element('input'))
# > # OR:
# > assert_.blank.matches(browser.element('input'))
# > # OR:
# > assert_.blank.matching(browser.element('input'))
# > # OR:
# > assert_.blank(browser.element('input')) #->❤️
# hm... what about simply:
# > from selene import browser
# > from selene.core import match
# > ...
# > browser.element('input').clear()
# > match.blank(browser.element('input')) #->❤️
# this looks also ok:
# > from selene import browser
# > from selene.core import match as expect
# > ...
# > browser.element('input').clear()
# > expect.blank(browser.element('input')) #->❤️
# TODO: at least, we have to document – the #->❤️-marked recipes...
def _test(self, entity: E) -> None:
# currently refactored to be alias to __call__ to be in more compliance
# with some subclasses implementations, that override __call__
Expand Down Expand Up @@ -1360,6 +1393,8 @@ def __init__x(self, actual: Lambda[E, R], by: Predicate[R]): ...

# TODO: do we really need such complicated impl in order
# to allow passing actual and by as positional arguments?
# also, take into account that currently the _describe_actual_result
# is not counted in the impl below
def __init__x(self, *args, **kwargs):
"""
Valid signatures in usage:
Expand Down Expand Up @@ -1443,6 +1478,7 @@ def __init__(
actual: Lambda[E, R],
*,
by: Predicate[R],
_describe_actual_result: Lambda[R, str] | None = None,
_inverted=False,
_falsy_exceptions: Iterable[Type[Exception]] = (AssertionError,),
): ...
Expand All @@ -1463,6 +1499,7 @@ def __init__(
*,
actual: Lambda[E, R],
by: Predicate[R],
_describe_actual_result: Lambda[R, str] | None = None,
_inverted=False,
_falsy_exceptions: Iterable[Type[Exception]] = (AssertionError,),
): ...
Expand All @@ -1484,11 +1521,12 @@ def __init__(
actual: Lambda[E, R] | None = None,
*,
by: Predicate[E] | Predicate[R],
_describe_actual_result: Lambda[R, str] | None = None,
_inverted=False,
_falsy_exceptions: Iterable[Type[Exception]] = (AssertionError,),
):
"""
The only valid signatures in usage:
The only valid and stable signatures in usage:
```python
Match(by=lambda x: x > 0)
Expand All @@ -1497,6 +1535,12 @@ def __init__(
Match('has positive decrement', actual=lambda x: x - 1, by=lambda x: x > 0)
Match(actual=lambda x: x - 1, by=lambda x: x > 0)
```
In addition to the examples above you can optionally add named
`_describe_actual_result` argument whenever you pass the `actual` argument.
You also can optionally provide _inverted and _falsy_exceptions arguments.
But keep in mind that they are marked with `_` prefix to indicate their
private and potentially "experimental" use, that can change in future versions.
"""
if not description and not (by_description := Query.full_description_for(by)):
raise ValueError(
Expand All @@ -1515,6 +1559,7 @@ def __init__(
description=description,
actual=actual, # type: ignore
by=by,
_describe_actual_result=_describe_actual_result,
_inverted=_inverted,
_falsy_exceptions=_falsy_exceptions,
)
Expand Down
3 changes: 3 additions & 0 deletions selene/core/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,9 @@ def ss(self, css_or_xpath_or_by: Union[str, Tuple[str, str]]) -> Collection:
return self.all(css_or_xpath_or_by)


# TODO: consider renaming or at list aliased to AllElements
# for better consistency with browser.all(selector)
# and maybe even aliased by All for nicer POM support via descriptors
class Collection(WaitingEntity['Collection'], Iterable[Element]):
def __init__(self, locator: Locator[typing.Sequence[WebElement]], config: Config):
self._locator = locator
Expand Down
46 changes: 39 additions & 7 deletions selene/core/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ def decremented(x) -> int:
)(2)
# ...
```
"""
""" # todo: document _describe_actual_result examples

def __init__(self, message='condition not matched'):
super().__init__(message)
Expand All @@ -123,6 +123,7 @@ def _to_raise_if_not(
by: Callable[[R], bool],
actual: Callable[[E], R] | None = None,
*,
_describe_actual_result: Callable[[E | R], str] | None = None,
_inverted: bool = False,
_falsy_exceptions: Iterable[Type[Exception]] = (AssertionError,),
): ...
Expand All @@ -134,6 +135,12 @@ def _to_raise_if_not(
by: Callable[[E | R], bool],
actual: Optional[Callable[[E], E | R]] = None,
*,
# we can't name it:
# - as `describe` because it will not be clear what are we going to describe
# is it a description of the result of the whole comparison? or what?
# - as `describe_actual` because it will not be clear:
# do we describe actual as a query? or as a result of the query being called?
_describe_actual_result: Callable[[E | R], str] | None = None,
_inverted: Optional[bool] = False,
# todo: should we rename it to _exceptions_as_truthy_on_inverted?
# or just document this in docstring?
Expand All @@ -144,11 +151,19 @@ def _to_raise_if_not(
@functools.wraps(by)
def wrapped(entity: E) -> None:
def describe_not_match(actual_value):
actual_description = (
f' {name}' if (name := Query.full_description_for(actual)) else ''
describe_actual_result = _describe_actual_result or (
lambda value: (
f'actual'
+ (
f' {actual_name}'
if (actual_name := Query.full_description_for(actual))
else ''
)
+ f': {value}'
)
)
return (
f'actual{actual_description}: {actual_value}'
describe_actual_result(actual_value)
if actual
else (
(
Expand Down Expand Up @@ -238,28 +253,45 @@ def _to_raise_if(
cls,
by: Callable[[E | R], bool],
actual: Callable[[E], R] | None = None,
*,
_describe_actual_result: Callable[[R], str] | None = None,
_falsy_exceptions: Iterable[Type[Exception]] = (AssertionError,),
):
return cls._to_raise_if_not(
by, actual, _inverted=True, _falsy_exceptions=_falsy_exceptions
by,
actual,
_describe_actual_result=_describe_actual_result,
_inverted=True,
_falsy_exceptions=_falsy_exceptions,
)

@classmethod
def _to_raise_if_not_actual(
cls,
query: Callable[[E], R],
by: Callable[[R], bool],
*,
_describe_actual_result: Callable[[R], str] | None = None,
):
return cls._to_raise_if_not(by, query)
return cls._to_raise_if_not(
by, query, _describe_actual_result=_describe_actual_result
)

@classmethod
def _to_raise_if_actual(
cls,
query: Callable[[E], R],
by: Callable[[R], bool],
*,
_describe_actual_result: Callable[[R], str] | None = None,
_falsy_exceptions: Iterable[Type[Exception]] = (AssertionError,),
):
return cls._to_raise_if(by, query, _falsy_exceptions=_falsy_exceptions)
return cls._to_raise_if(
by,
query,
_describe_actual_result=_describe_actual_result,
_falsy_exceptions=_falsy_exceptions,
)


class ConditionNotMatchedError(ConditionMismatch):
Expand Down
Loading

0 comments on commit 9d99701

Please sign in to comment.