diff --git a/CHANGELOG.md b/CHANGELOG.md index cc21d0d4..a9f5f8b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -210,7 +210,9 @@ should we even refactor out them from Condition and move to Match only? ### TODO: rename all conditions inside match.py so match can be fully used instead be + have #530 -### TODO: should we make ignorecase optionally default for all conditions supporting it? +### TODO: Rename condition__collection__get*_test.py tests + +### TODO: is text + exact_text naming still relevant for match.* case over have.*? ### Deprecated conditions @@ -440,7 +442,17 @@ browser.all('li').first.should(have.text_matching(r'.*one.*').where_flags(re.IGN ``` -Same for attribute related conditions like `have.value` and `have.attribute(name).value*` will be a part of [#537](https://github.com/yashaka/selene/issues/537) +Same can be achieved via `_ignore_case` config option (yet marked as experimental): + +```python +from selene import browser, have +... +browser.all('li').first.with_(_ignore_case=True).should(have.exact_text('1) one!!!')) + +# and so on for other conditions that support ignore_case as property... +... + +``` ### Shadow DOM support via query.js.shadow_root(s) diff --git a/selene/common/_typing_functions.py b/selene/common/_typing_functions.py index 315523fd..4b70fc0b 100644 --- a/selene/common/_typing_functions.py +++ b/selene/common/_typing_functions.py @@ -77,7 +77,7 @@ def _name_for(self, entity: E) -> str: return self._name(entity) if callable(self._name) else self._name @staticmethod - def full_name_for( + def _full_name_for( callable_: Optional[Callable], _entity: E | None = None, ) -> str | None: @@ -116,11 +116,11 @@ def full_name_for( # todo: would not human_readable_name_for be a better name for this helper? @staticmethod - def full_description_for( + def _full_description_for( callable_: Optional[Callable], _entity: E | None = None, ) -> str | None: - full_name = Query.full_name_for(callable_, _entity) + full_name = Query._full_name_for(callable_, _entity) return ( thread_last( full_name, @@ -135,6 +135,21 @@ def full_description_for( else None ) + @staticmethod + def _full_description_or( + alternative: str, + /, + *, + for_: Optional[Callable], + _with_prefix: str = '', + _entity: E | None = None, + ) -> str: + return ( + _with_prefix + desc + if (desc := Query._full_description_for(for_, _entity)) + else alternative + ) + @staticmethod @overload def _inverted( diff --git a/selene/core/_browser.pyi b/selene/core/_browser.pyi index aad3cd3e..a9fa7965 100644 --- a/selene/core/_browser.pyi +++ b/selene/core/_browser.pyi @@ -103,6 +103,7 @@ class Browser(WaitingEntity['Browser']): wait_for_no_overlap_found_by_js: bool = False, _match_only_visible_elements_texts: bool = True, _match_only_visible_elements_size: bool = False, + _ignore_case: bool = False, # Etc. _build_wait_strategy: Callable[[Config], Callable[[E], Wait[E]]] = ..., ) -> Browser: ... diff --git a/selene/core/condition.py b/selene/core/condition.py index 5aa525db..b4d8297a 100644 --- a/selene/core/condition.py +++ b/selene/core/condition.py @@ -1016,11 +1016,11 @@ def __str__(self): # - condition.repr_for(entity) # + correlate with __repr__ over __str__; + concise; - weird shortcut # - condition.for(entity) # - looks like a builder # # to remember entity so condition can be .__call__() later without passing entity - def _name_for(self, _entity: E | None = None): + def _name_for(self, entity: E | None = None) -> str: return ( - self.__describe(_entity) + self.__describe(entity) if not self.__inverted - else self.__describe_inverted(_entity) + else self.__describe_inverted(entity) ) # todo: we already have entity.matching for Callable[[E], bool] @@ -1064,6 +1064,19 @@ def _name_for(self, _entity: E | None = None): # > browser.element('input').clear() # > expect.blank(browser.element('input')) #->❤️ # TODO: at least, we have to document – the #->❤️-marked recipes... + # hm... but what if condition has param: + # > expect.value('foo')(browser.element('input')) + # – now it looks less natural + # let's compare other options: + # > expect.value('foo').test(browser.element('input')) + # > expect.value('foo').match(browser.element('input')) + # > assert_.value('foo').test(browser.element('input')) + # > assert_.value('foo').match(browser.element('input')) + # > match.value('foo').match(browser.element('input')) + # > match.value('foo').test(browser.element('input')) + # what if we add on property returning just self? + # or as an method alias to __call__() + # > match.value('foo').on(browser.element('input')) 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__ @@ -1499,14 +1512,14 @@ def __init__x(self, *args, **kwargs): else: actual = None by = args[0] - if not (by_name := Query.full_description_for(by)): + if not (by_name := Query._full_description_for(by)): raise ValueError( 'either provide name or ensure that at least by predicate' 'has __qualname__ (defined as regular named function)' 'or custom __str__ implementation ' '(like lambda wrapped in Query object)' ) - actual_desc = Query.full_description_for(actual) + actual_desc = Query._full_description_for(actual) name = ((str(actual_desc) + ' ') if actual_desc else '') + str( by_name ) # noqa @@ -1586,14 +1599,14 @@ def __init__( 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 name and not (by_name := Query.full_description_for(by)): + if not name and not (by_name := Query._full_description_for(by)): raise ValueError( 'either provide a name or ensure that at least by predicate ' 'has __qualname__ (defined as regular named function) ' 'or custom __str__ implementation ' '(like lambda wrapped in Query object)' ) - actual_name = Query.full_description_for(actual) + actual_name = Query._full_description_for(actual) name = name or ( ((str(actual_name) + ' ') if actual_name else '') + str(by_name) # noqa ) diff --git a/selene/core/configuration.py b/selene/core/configuration.py index bf90bc78..98efbe2e 100644 --- a/selene/core/configuration.py +++ b/selene/core/configuration.py @@ -1152,6 +1152,14 @@ def _executor(self): It is set to False by default for backward compatibility reasons. """ # todo: document example + # todo: decide on naming: ignore_case vs match_ignoring_case? + # ignore_case conciser but + # - not consistent with other match_* options + # - does not explicitly tell that relates only to matching + # * but... it should be kind of obvious that can be used only in context of matching + # * and actually it's consistent with have.*.ignore_case conditions + # todo: consider documenting the major list of conditions that are affected by this option + _ignore_case: bool = False # TODO: better name? now technically it's not a decorator but decorator_builder... # or decorator_factory... diff --git a/selene/core/configuration.pyi b/selene/core/configuration.pyi index f788b64d..7f245bc0 100644 --- a/selene/core/configuration.pyi +++ b/selene/core/configuration.pyi @@ -100,6 +100,7 @@ class Config: wait_for_no_overlap_found_by_js: bool = False _match_only_visible_elements_texts: bool = True _match_only_visible_elements_size: bool = False + _ignore_case: bool = False # Etc. _build_wait_strategy: Callable[[Config], Callable[[E], Wait[E]]] = ... _executor: _DriverStrategiesExecutor = ... @@ -156,6 +157,7 @@ class Config: wait_for_no_overlap_found_by_js: bool = False, _match_only_visible_elements_texts: bool = True, _match_only_visible_elements_size: bool = False, + _ignore_case: bool = False, # Etc. _build_wait_strategy: Callable[[Config], Callable[[E], Wait[E]]] = ..., ): ... @@ -211,6 +213,7 @@ class Config: wait_for_no_overlap_found_by_js: bool = False, _match_only_visible_elements_texts: bool = True, _match_only_visible_elements_size: bool = False, + _ignore_case: bool = False, # Etc. _build_wait_strategy: Callable[[Config], Callable[[E], Wait[E]]] = ..., ): ... diff --git a/selene/core/entity.pyi b/selene/core/entity.pyi index fda032f3..a617ab81 100644 --- a/selene/core/entity.pyi +++ b/selene/core/entity.pyi @@ -87,6 +87,7 @@ class Element(WaitingEntity['Element']): wait_for_no_overlap_found_by_js: bool = False, _match_only_visible_elements_texts: bool = True, _match_only_visible_elements_size: bool = False, + _ignore_case: bool = False, # Etc. _build_wait_strategy: Callable[[Config], Callable[[E], Wait[E]]] = ..., ) -> Element: ... @@ -151,6 +152,7 @@ class Collection(WaitingEntity['Collection'], Iterable[Element]): wait_for_no_overlap_found_by_js: bool = False, _match_only_visible_elements_texts: bool = True, _match_only_visible_elements_size: bool = False, + _ignore_case: bool = False, # Etc. _build_wait_strategy: Callable[[Config], Callable[[E], Wait[E]]] = ..., ) -> Collection: ... diff --git a/selene/core/exceptions.py b/selene/core/exceptions.py index 7e0ef1ce..012aea59 100644 --- a/selene/core/exceptions.py +++ b/selene/core/exceptions.py @@ -153,11 +153,8 @@ def wrapped(entity: E) -> None: def describe_not_match(actual_value): describe_actual_result = _describe_actual_result or ( lambda value: ( - 'actual' - + ( - f' {actual_name}' - if (actual_name := Query.full_description_for(actual)) - else '' + Query._full_description_or( + 'actual', for_=actual, _with_prefix='actual ' ) + f': {value}' ) @@ -169,7 +166,7 @@ def describe_not_match(actual_value): ( ( (f'not ({name})' if _inverted else name) - if (name := Query.full_description_for(by)) + if (name := Query._full_description_for(by)) else '' ) or "condition" diff --git a/selene/core/match.py b/selene/core/match.py index 3c108cf5..bd7353fd 100644 --- a/selene/core/match.py +++ b/selene/core/match.py @@ -42,21 +42,24 @@ Type, cast, Optional, + TypeVar, ) from selene.common import predicate, helpers, appium_tools +from selene.common._typing_functions import Query from selene.core import query -from selene.core.condition import Condition, Match, E +from selene.core.condition import Condition, Match from selene.core.conditions import ( ElementCondition, CollectionCondition, BrowserCondition, ) -from selene.core.entity import Collection, Element +from selene.core.entity import Collection, Element, Configured from selene.core._browser import Browser # GENERAL CONDITION BUILDERS ------------------------------------------------- # +E = TypeVar('E', bound=Configured) class _EntityHasSomethingSupportingIgnoreCase(Match[E]): @@ -80,17 +83,34 @@ def __init__( self.__falsy_exceptions = _falsy_exceptions super().__init__( - ( - f'{name}{" ignoring case:" if _ignore_case else ""} \'{expected}\'' + lambda maybe_entity: ( + name + + ( + ' ignoring case:' + if (maybe_entity is not None and maybe_entity.config._ignore_case) + or _ignore_case + else '' + ) + + f' \'{expected}\'' # todo: refactor to and change tests correspondingly: # f'{" ignoring case:" if _ignore_case else ":"} «{expected}»' ), - actual=actual, - by=lambda actual: ( + actual=lambda entity: (entity, actual(entity)), + by=lambda entity_and_actual: ( by(str(expected).lower())(str(actual).lower()) - if _ignore_case + if ( + entity_and_actual[0], + actual := entity_and_actual[1], + )[0].config._ignore_case + or _ignore_case else by(str(expected))(str(actual)) ), + _describe_actual_result=lambda entity_and_actual: ( + Query._full_description_or( + 'actual', for_=actual, _with_prefix='actual ' + ) + + f': {entity_and_actual[1]}' + ), _inverted=_inverted, _falsy_exceptions=_falsy_exceptions, ) @@ -128,29 +148,40 @@ def __init__( self.__inverted = _inverted self.__falsy_exceptions = _falsy_exceptions - # TODO: should we store flattened version in self? + # todo: should we store flattened version in self? # how should we render nested expected in error? # should we transform actual to same un-flattened structure as expected? # (when rendering, of course) - def compare(actual: Iterable) -> bool: + def compare(entity_and_actual: Tuple[Collection, Iterable]) -> bool: + entity, actual = entity_and_actual expected_flattened = helpers.flatten(expected) str_lower = lambda some: str(some).lower() return ( by(map(str_lower, expected_flattened))(map(str_lower, actual)) - if _ignore_case + if entity.config._ignore_case or _ignore_case else by(map(str, expected_flattened))(map(str, actual)) ) super().__init__( - ( - f'{name}{" ignoring case:" if _ignore_case else ""}' - f' {list(expected)}' - # todo: refactor to and change tests correspondingly: - # f'{" ignoring case:" if _ignore_case else ":"} {expected}' + lambda maybe_entity: ( + name + + ( + ' ignoring case:' + if (maybe_entity is not None and maybe_entity.config._ignore_case) + or _ignore_case + else '' + ) + + f' {list(expected)}' ), - actual=actual, + actual=lambda entity: (entity, actual(entity)), by=compare, + _describe_actual_result=lambda entity_and_actual: ( + Query._full_description_or( + 'actual', for_=actual, _with_prefix='actual ' + ) + + f': {entity_and_actual[1]}' + ), _inverted=_inverted, _falsy_exceptions=_falsy_exceptions, ) @@ -360,6 +391,17 @@ def text(expected: str | int | float, _ignore_case=False, _inverted=False): ) +# TODO: is text + exact_text naming still relevant for match.* case over have.*? +# let's compare examples: +# > element.should(have.exact_text('full of partial!')) # 👍🏻 +# > element.should(have.text('partial')) # 👍🏻 +# > element.should(match.exact_text('full of partial!')) # 👍🏻 +# > element.should(match.text('partial')) # 🤨 +# > element.should(match.text_containing('partial')) # 🤔 +# > element.should(match.partial_text('partial')) # 👍🏻 +# > match.text('partial')(element) # 🤨 +# > match.text_containing('partial')(element) # ?? +# > match.partial_text('partial')(element) # 👍🏻 def exact_text(expected: str | int | float, _ignore_case=False, _inverted=False): return _EntityHasSomethingSupportingIgnoreCase( 'has exact text', @@ -379,10 +421,35 @@ def __init__(self, expected: str, _flags=0, _inverted=False): self.__inverted = _inverted super().__init__( - f'has text matching{f" (with flags {_flags}):" if _flags else ""}' - f' {expected}', - actual=query.text, - by=predicate.matches(expected, _flags), + lambda maybe_entity: ( + f'has text matching' + + ( + f' (with flags {flags}):' + if ( + flags := ( + _flags | re.IGNORECASE + if maybe_entity is not None + and maybe_entity.config._ignore_case + else _flags + ) + ) + else '' + ) + + f' {expected}' + ), + actual=lambda entity: (entity, query.text(entity)), + by=lambda entity_and_actual: predicate.matches( + expected, + ( + _flags | re.IGNORECASE + if entity_and_actual[0] is not None + and entity_and_actual[0].config._ignore_case + else _flags + ), + )(entity_and_actual[1]), + _describe_actual_result=lambda entity_and_actual: ( + f'actual text: {entity_and_actual[1]}' + ), _inverted=_inverted, ) @@ -510,6 +577,7 @@ def values_containing( class attribute(Condition[Element]): + # todo: the raw attribute condition does not support ignore_case, should it? def __init__(self, name: str, _inverted=False): self.__expected = name # self.__ignore_case = _ignore_case @@ -736,8 +804,8 @@ class size(Match[Union[Collection, Browser, Element]]): def __init__( self, expected: int | dict, - _name=lambda entity: ( - 'have size' if isinstance(entity, Collection) else 'has size' + _name=lambda maybe_entity: ( + 'have size' if isinstance(maybe_entity, Collection) else 'has size' ), # todo: should we also tune actual rendering based on # config._match_only_visible_elements_size? @@ -746,7 +814,7 @@ def __init__( _inverted=False, ): self.__expected = expected - self.__name = lambda entity: f'{_name(entity)} {expected}' + self.__name = lambda maybe_entity: f'{_name(maybe_entity)} {expected}' self.__by = _by self.__inverted = _inverted @@ -1117,7 +1185,15 @@ def __call__(self, entity: Collection): regex_invalid_error: re.error | None = None try: - answer = re.match(expected_pattern, actual_to_match, self._flags) + answer = re.match( + expected_pattern, + actual_to_match, + ( + self._flags | re.IGNORECASE + if entity.config._ignore_case + else self._flags + ), + ) except re.error as error: # going to re-raise it below as AssertionError on `not answer` regex_invalid_error = error @@ -1130,12 +1206,18 @@ def describe_not_match(): # for item in self._expected # ] return ( - f'actual visible texts:\n {actual_to_render}\n' - '\n' + f'actual ' + + ( + 'visible ' + if entity.config._match_only_visible_elements_texts + else '' + ) + + f'texts:\n {actual_to_render}\n' + + '\n' # f'Pattern explained:\n {pattern_explained}\n' - f'Pattern used for matching:\n {expected_pattern}\n' + + f'Pattern used for matching:\n {expected_pattern}\n' # TODO: consider renaming to Actual merged text for match - f'Actual text used to match:\n {actual_to_match}' + + f'Actual text used to match:\n {actual_to_match}' ) if regex_invalid_error: @@ -1159,6 +1241,8 @@ def __str__(self): # TODO: after previous, we can implement shortcut "ignoring case" # for " (flags: re.IGNORECASE)" return ( + # todo: consider wrapping negated part into () like in other conditions + # todo: consider square brackets arround list of texts like in other conditions f'{self._name_prefix} {"no " if self._inverted else ""}{self._name}' + (f' (flags: {self._flags})' if self._flags else '') + ':' @@ -1181,6 +1265,13 @@ def __str__(self): ) ) + def _name_for(self, entity: Collection | None = None) -> str: + return ( + self.ignore_case.__str__() + if entity is not None and entity.config._ignore_case + else self.__str__() + ) + # TODO: will other methods like or_, and_ – do work? o_O @@ -1291,7 +1382,8 @@ def __init__( self._globs = () # TODO: consider refactoring so this attribute is not even inherited - def where(self): + @override + def where(self, **kwargs): """Just a placeholder. This attribute is not supported for this condition""" raise AttributeError('.where(**) is not supported on text_patterns condition') diff --git a/selene/core/wait.py b/selene/core/wait.py index 9cf4cc44..d8d1fec1 100644 --- a/selene/core/wait.py +++ b/selene/core/wait.py @@ -119,7 +119,7 @@ def logic(fn: Callable[[E], R]) -> R: # TODO: consider using Query.full_description_for(fn) # TODO: consider customizing what to use on __init__ - fn_name = Query.full_name_for(fn, entity) or str(fn) + fn_name = Query._full_name_for(fn, entity) or str(fn) failure = TimeoutException( f'\n' diff --git a/selene/support/conditions/have.py b/selene/support/conditions/have.py index 45d77031..ae293850 100644 --- a/selene/support/conditions/have.py +++ b/selene/support/conditions/have.py @@ -40,6 +40,14 @@ def text(partial_value: str | int | float): return match.text(partial_value) +# todo: why not exact_text_matching o_O? +# is match.exact_text still ok, when it's match not have? +# let's compare: +# > element.should(have.exact_text('full of partial!')) # 👍🏻 +# > element.should(have.text('partial')) # hm, seems like 👍🏻 +# > element.should(have.text_matching('partial').not_) # hm, seems like 👍🏻 +# > element.should(have.text_matching('.*partial.*')) # 👍🏻 +# – seems like no need in exact_ prefix... def text_matching(regex_pattern: str): return match.text_pattern(regex_pattern) @@ -86,21 +94,19 @@ def attribute(name: str, value: Optional[str] = None): return match.attribute(name) -def value(text: str | int | float) -> Condition[Element]: +def value(text: str | int | float): return match.value(text) -def values(*texts: str | int | float | Iterable[str]) -> Condition[Collection]: +def values(*texts: str | int | float | Iterable[str]): return match.values(*texts) -def value_containing(partial_text: str | int | float) -> Condition[Element]: +def value_containing(partial_text: str | int | float): return match.value_containing(partial_text) -def values_containing( - *partial_texts: str | int | float | Iterable[str], -) -> Condition[Collection]: +def values_containing(*partial_texts: str | int | float | Iterable[str]): return match.values_containing(*partial_texts) @@ -108,11 +114,11 @@ def css_class(name): return match.css_class(name) -def tag(name: str) -> Condition[Element]: +def tag(name: str): return match.tag(name) -def tag_containing(name: str) -> Condition[Element]: +def tag_containing(name: str): return match.tag_containing(name) diff --git a/tests/integration/condition__browser__have_url_test.py b/tests/integration/condition__browser__have_url_test.py index 6f00904f..08cc7f04 100644 --- a/tests/integration/condition__browser__have_url_test.py +++ b/tests/integration/condition__browser__have_url_test.py @@ -43,19 +43,46 @@ def test_have_url_containing__ignore_case(session_browser): browser = session_browser.with_(timeout=0.1) GivenPage(browser.driver).opened_empty() browser.should(have.url_containing('EMPTY.html').ignore_case) + try: + browser.should(have.url_containing('NONEMPTY.html').ignore_case) + pytest.fail('expect mismatch') + except AssertionError as error: + assert ( + "browser.has url containing ignoring case: 'NONEMPTY.html'\n" + '\n' + 'Reason: ConditionMismatch: actual url: ' + ) in str(error) + assert re.findall(r'file:.*empty\.html\n', str(error)) + try: + browser.with_(_ignore_case=True).should(have.url_containing('NONEMPTY.html')) + pytest.fail('expect mismatch') + except AssertionError as error: + assert ( + "browser.has url containing ignoring case: 'NONEMPTY.html'\n" + '\n' + 'Reason: ConditionMismatch: actual url: ' + ) in str(error) + assert re.findall(r'file:.*empty\.html\n', str(error)) try: browser.should(have.no.url_containing('EMPTY.html').ignore_case) pytest.fail('expect mismatch') except AssertionError as error: - assert re.findall( - re.escape( - "browser.has no (url containing ignoring case: 'EMPTY.html')\n" - '\n' - 'Reason: ConditionMismatch: actual url: ' - ) - + r'file:.*empty\.html\n', - str(error), - ) + assert ( + "browser.has no (url containing ignoring case: 'EMPTY.html')\n" + '\n' + 'Reason: ConditionMismatch: actual url: ' + ) in str(error) + assert re.findall(r'file:.*empty\.html\n', str(error)) + try: + browser.with_(_ignore_case=True).should(have.no.url_containing('EMPTY.html')) + pytest.fail('expect mismatch') + except AssertionError as error: + assert ( + "browser.has no (url containing ignoring case: 'EMPTY.html')\n" + '\n' + 'Reason: ConditionMismatch: actual url: ' + ) in str(error) + assert re.findall(r'file:.*empty\.html\n', str(error)) browser.should(have.no.url_containing('start_page.xhtml')) browser.should(have.no.url_containing('start_page.xhtml').ignore_case) diff --git a/tests/integration/condition__element__have_exact_text_test.py b/tests/integration/condition__element__have_exact_text_test.py index 844c14cd..b56dc9d7 100644 --- a/tests/integration/condition__element__have_exact_text_test.py +++ b/tests/integration/condition__element__have_exact_text_test.py @@ -118,6 +118,121 @@ def test_should_have_exact_text__passed_and_failed__with_text_to_trim(session_br ) in str(error) +def test_should_have_exact_text__passed_and_failed__with_text_to_trim__ignore_case( + session_browser, +): + s = lambda selector: session_browser.with_(timeout=0.1).element(selector) + GivenPage(session_browser.driver).opened_with_body( + ''' +