diff --git a/README.md b/README.md index 2aa2f9b..0be265e 100644 --- a/README.md +++ b/README.md @@ -553,6 +553,11 @@ d.input_text("adbcdfg") - `checkable` - `isBefore` - `isAfter` +- 模糊 / 正则别名(映射到官方 `On.*(value, MatchPattern)`,不是虚构的 `On.textContains`) + - `textContains` / `textStartsWith` / `textEndsWith` / `textMatches` / `textMatchesIcase` + - `descriptionContains` / `descriptionStartsWith` / `descriptionEndsWith` / `descriptionMatches` / `descriptionMatchesIcase` + - `idContains` / `idStartsWith` / `idEndsWith` / `idMatches` / `idMatchesIcase`(`On.id` 两参数形式,API 18+) + - `typeContains` / `typeStartsWith` / `typeEndsWith` / `typeMatches` / `typeMatchesIcase`(`On.type` 两参数形式,API 18+) Notes: 获取控件属性值可以配合 [UI inspector](https://github.com/codematrixer/ui-viewer) 工具查看 @@ -567,7 +572,22 @@ d(type="Button", index=0) ``` Notes:当同一界面有多个属性相同的元素时,`index`属性非常实用 -**模糊定位TODO** +**模糊定位 / 正则定位** + +底层调用鸿蒙 uitest 官方接口 `On.text(txt, pattern?: MatchPattern)`、`On.description(...)` 等(`@ohos.UiTest` / `@kit.TestKit` MatchPattern),**不会**调用不存在的 `On.textContains`。 + +`MatchPattern` 取值:`EQUALS=0`(默认精确)、`CONTAINS=1`、`STARTS_WITH=2`、`ENDS_WITH=3`、`REG_EXP=4`(API 18+)、`REG_EXP_ICASE=5`(API 18+)。 + +```python +d(textContains="tab") # On.text("tab", CONTAINS) +d(textStartsWith="tab") # On.text("tab", STARTS_WITH) +d(textEndsWith="recrod") # On.text("recrod", ENDS_WITH) +d(textMatches=r"^tab_.*") # On.text("^tab_.*", REG_EXP) +d(descriptionContains="icon") +d(type="Button", textContains="Toast") +``` + +不要同时传会落到同一个 `On.*` 的冲突条件(如 `text` 与 `textContains`),会抛出 `ReferenceError`。 **组合定位** diff --git a/docs/DEVELOP.md b/docs/DEVELOP.md index 7ed1c1e..90ac965 100644 --- a/docs/DEVELOP.md +++ b/docs/DEVELOP.md @@ -43,6 +43,27 @@ {"result":"On#1"} ``` +### On.text (MatchPattern) + +Official `@ohos.UiTest` two-arg form: `On.text(txt, pattern?: MatchPattern)`. +Python aliases (`textContains`, `textMatches`, …) map here — there is no `On.textContains`. + +`MatchPattern`: `0` EQUALS, `1` CONTAINS, `2` STARTS_WITH, `3` ENDS_WITH, `4` REG_EXP (API 18+), `5` REG_EXP_ICASE (API 18+). + +**send** (contains) +``` +{"module":"com.ohos.devicetest.hypiumApiHelper","method":"callHypiumApi","params":{"api":"On.text","this":"On#seed","args":["精选",1],"message_type":"hypium"},"request_id":"20240829202019513472","client":"127.0.0.1"} +``` + +**send** (regex) +``` +{"module":"com.ohos.devicetest.hypiumApiHelper","method":"callHypiumApi","params":{"api":"On.text","this":"On#seed","args":["^tab_.*",4],"message_type":"hypium"},"request_id":"20240829202019513472","client":"127.0.0.1"} +``` +**recv** +``` +{"result":"On#1"} +``` + ### On.id ### On.key ### On.type diff --git a/hmdriver2/_uiobject.py b/hmdriver2/_uiobject.py index 98dd5cb..c159e82 100644 --- a/hmdriver2/_uiobject.py +++ b/hmdriver2/_uiobject.py @@ -2,7 +2,7 @@ import enum import time -from typing import List, Union +from typing import Dict, Iterator, List, Optional, Tuple, Union from . import logger from .utils import delay @@ -11,6 +11,88 @@ from .proto import ComponentData, ByData, HypiumResponse, Point, Bounds, ElementInfo +class MatchPattern(enum.IntEnum): + """ + Official ``@ohos.UiTest`` / ``@kit.TestKit`` MatchPattern values. + + Used as the optional second argument of ``On.text`` / ``On.description`` / + ``On.id`` / ``On.type`` (see OpenHarmony js-apis-uitest). There is no native + ``On.textContains`` / ``On.textMatches`` method — those Python kwargs are + aliases that map to ``On.(value, MatchPattern)``. + """ + EQUALS = 0 + CONTAINS = 1 + STARTS_WITH = 2 + ENDS_WITH = 3 + REG_EXP = 4 # API 18+ + REG_EXP_ICASE = 5 # API 18+ + + +# User kwargs -> official On. + optional MatchPattern. +# Exact keys keep the historical single-arg On.* call (default EQUALS). +# Fuzzy / regex names are Python aliases only; the wire API is always On.text, etc. +_ON_SELECTOR: Dict[str, Tuple[str, Optional[MatchPattern]]] = { + "id": ("id", None), + "key": ("key", None), + "text": ("text", None), + "type": ("type", None), + "description": ("description", None), + "clickable": ("clickable", None), + "longClickable": ("longClickable", None), + "scrollable": ("scrollable", None), + "enabled": ("enabled", None), + "focused": ("focused", None), + "selected": ("selected", None), + "checked": ("checked", None), + "checkable": ("checkable", None), + # On.text(txt, pattern?) — API 9+ (REG_EXP / REG_EXP_ICASE: API 18+) + "textContains": ("text", MatchPattern.CONTAINS), + "textStartsWith": ("text", MatchPattern.STARTS_WITH), + "textEndsWith": ("text", MatchPattern.ENDS_WITH), + "textMatches": ("text", MatchPattern.REG_EXP), + "textMatchesIcase": ("text", MatchPattern.REG_EXP_ICASE), + # On.description(val, pattern?) — API 11+ + "descriptionContains": ("description", MatchPattern.CONTAINS), + "descriptionStartsWith": ("description", MatchPattern.STARTS_WITH), + "descriptionEndsWith": ("description", MatchPattern.ENDS_WITH), + "descriptionMatches": ("description", MatchPattern.REG_EXP), + "descriptionMatchesIcase": ("description", MatchPattern.REG_EXP_ICASE), + # On.id(id, pattern) — two-arg form API 18+ + "idContains": ("id", MatchPattern.CONTAINS), + "idStartsWith": ("id", MatchPattern.STARTS_WITH), + "idEndsWith": ("id", MatchPattern.ENDS_WITH), + "idMatches": ("id", MatchPattern.REG_EXP), + "idMatchesIcase": ("id", MatchPattern.REG_EXP_ICASE), + # On.type(tp, pattern) — two-arg form API 18+ + "typeContains": ("type", MatchPattern.CONTAINS), + "typeStartsWith": ("type", MatchPattern.STARTS_WITH), + "typeEndsWith": ("type", MatchPattern.ENDS_WITH), + "typeMatches": ("type", MatchPattern.REG_EXP), + "typeMatchesIcase": ("type", MatchPattern.REG_EXP_ICASE), +} + +ALLOWED_SELECTOR_KEYS = frozenset(_ON_SELECTOR.keys()) + + +def resolve_on_selector(key: str) -> Tuple[str, Optional[MatchPattern]]: + """Map a user selector kwarg to the official On method name and MatchPattern.""" + return _ON_SELECTOR[key] + + +def build_on_args(value, pattern: Optional[MatchPattern]) -> list: + """Build Hypium ``args`` for ``On.``. Exact match stays single-arg.""" + if pattern is None: + return [value] + return [value, int(pattern)] + + +def iter_on_calls(kwargs: dict) -> Iterator[Tuple[str, list]]: + """Yield planned ``(api, args)`` Hypium On.* invokes for the given kwargs.""" + for key, value in kwargs.items(): + on_name, pattern = resolve_on_selector(key) + yield f"On.{on_name}", build_on_args(value, pattern) + + class ByType(enum.Enum): id = "id" key = "key" @@ -30,6 +112,8 @@ class ByType(enum.Enum): @classmethod def verify(cls, value): + if value in _ON_SELECTOR: + return True return any(value == item.value for item in cls) @@ -53,9 +137,20 @@ def __str__(self) -> str: return f"UiObject [{self._raw_kwargs}" def __verify(self): + seen_on = {} for k, v in self._kwargs.items(): if not ByType.verify(k): raise ReferenceError(f"{k} is not allowed.") + on_name, _ = resolve_on_selector(k) + if on_name in seen_on: + raise ReferenceError( + f"{k} conflicts with {seen_on[on_name]}; both map to On.{on_name}." + ) + seen_on[on_name] = k + + def _selector_calls(self) -> List[Tuple[str, list]]: + """Planned Hypium On.* invokes for this selector (no device required).""" + return list(iter_on_calls(self._kwargs)) @property def count(self) -> int: @@ -105,10 +200,9 @@ def __find_components(self) -> Union[List[ComponentData], None]: return components def __get_by(self) -> ByData: - for k, v in self._kwargs.items(): - api = f"On.{k}" + for api, args in iter_on_calls(self._kwargs): this = "On#seed" - resp: HypiumResponse = self._client.invoke(api, this, args=[v]) + resp: HypiumResponse = self._client.invoke(api, this, args=args) this = resp.result if self._isBefore: diff --git a/tests/test_selector.py b/tests/test_selector.py new file mode 100644 index 0000000..7a726c8 --- /dev/null +++ b/tests/test_selector.py @@ -0,0 +1,147 @@ +# -*- coding: utf-8 -*- +"""No-device tests for selector allowlist and On.* construction (issue #53).""" + +import pytest + +from hmdriver2.proto import HypiumResponse +from hmdriver2._uiobject import ( + ALLOWED_SELECTOR_KEYS, + ByType, + MatchPattern, + UiObject, + build_on_args, + iter_on_calls, + resolve_on_selector, +) + + +class FakeClient: + def __init__(self): + self.calls = [] + self._n = 0 + + def invoke(self, api, this="Driver#0", args=None): + args = [] if args is None else args + self.calls.append({"api": api, "this": this, "args": list(args)}) + self._n += 1 + return HypiumResponse(result=f"On#{self._n}") + + +def test_allowlist_keeps_existing_exact_keys(): + for key in ( + "id", "key", "text", "type", "description", + "clickable", "longClickable", "scrollable", "enabled", + "focused", "selected", "checked", "checkable", + ): + assert ByType.verify(key) + assert key in ALLOWED_SELECTOR_KEYS + + +def test_allowlist_accepts_official_fuzzy_and_regex_aliases(): + for key in ( + "textContains", "textStartsWith", "textEndsWith", "textMatches", "textMatchesIcase", + "descriptionContains", "descriptionStartsWith", "descriptionEndsWith", + "descriptionMatches", "descriptionMatchesIcase", + "idContains", "idMatches", + "typeContains", "typeMatches", + ): + assert ByType.verify(key), key + assert key in ALLOWED_SELECTOR_KEYS + + +def test_allowlist_rejects_unknown_and_fake_on_methods(): + for key in ("textContain", "xpath", "resourceId", "className", "hint", "visible"): + assert not ByType.verify(key) + with pytest.raises(ReferenceError, match=f"{key} is not allowed"): + UiObject(None, **{key: "x"}) + + +def test_issue53_text_contains_is_allowed(): + obj = UiObject(None, textContains="同意") + assert obj._selector_calls() == [("On.text", ["同意", MatchPattern.CONTAINS])] + + +def test_text_matches_maps_to_on_text_reg_exp(): + obj = UiObject(None, textMatches=r"^ok\d+$") + assert obj._selector_calls() == [("On.text", [r"^ok\d+$", MatchPattern.REG_EXP])] + + +def test_description_contains_maps_to_on_description(): + obj = UiObject(None, descriptionContains="icon") + assert obj._selector_calls() == [("On.description", ["icon", MatchPattern.CONTAINS])] + + +def test_exact_text_stays_single_arg(): + obj = UiObject(None, text="showToast") + assert obj._selector_calls() == [("On.text", ["showToast"])] + + +def test_combine_type_and_text_contains(): + obj = UiObject(None, type="Button", textContains="Toast") + assert obj._selector_calls() == [ + ("On.type", ["Button"]), + ("On.text", ["Toast", MatchPattern.CONTAINS]), + ] + + +def test_is_after_is_not_an_on_call(): + obj = UiObject(None, textContains="a", isAfter=True) + assert obj._isAfter is True + assert obj._selector_calls() == [("On.text", ["a", MatchPattern.CONTAINS])] + + +def test_index_is_not_an_on_call(): + obj = UiObject(None, textMatches=r"tab_.*", index=2) + assert obj._index == 2 + assert obj._selector_calls() == [("On.text", [r"tab_.*", MatchPattern.REG_EXP])] + + +def test_conflict_text_and_text_contains(): + with pytest.raises(ReferenceError, match="conflicts"): + UiObject(None, text="a", textContains="b") + + +def test_matchpattern_official_int_values(): + assert int(MatchPattern.EQUALS) == 0 + assert int(MatchPattern.CONTAINS) == 1 + assert int(MatchPattern.STARTS_WITH) == 2 + assert int(MatchPattern.ENDS_WITH) == 3 + assert int(MatchPattern.REG_EXP) == 4 + assert int(MatchPattern.REG_EXP_ICASE) == 5 + + +def test_resolve_and_build_on_args(): + assert resolve_on_selector("textContains") == ("text", MatchPattern.CONTAINS) + assert resolve_on_selector("textMatches") == ("text", MatchPattern.REG_EXP) + assert resolve_on_selector("text") == ("text", None) + assert build_on_args("x", None) == ["x"] + assert build_on_args("x", MatchPattern.CONTAINS) == ["x", 1] + assert build_on_args(r"^a", MatchPattern.REG_EXP) == [r"^a", 4] + + +def test_iter_on_calls_never_emits_fake_on_textcontains(): + calls = list(iter_on_calls({"textContains": "foo", "descriptionMatches": r"bar.*"})) + apis = [api for api, _ in calls] + assert apis == ["On.text", "On.description"] + assert "On.textContains" not in apis + assert "On.textMatches" not in apis + + +def test_get_by_invokes_on_text_with_matchpattern(): + client = FakeClient() + obj = UiObject(client, textContains="Toast") + by = obj._UiObject__get_by() + assert by.value == "On#1" + assert client.calls == [ + {"api": "On.text", "this": "On#seed", "args": ["Toast", 1]}, + ] + + +def test_get_by_invokes_regex_and_exact_combination(): + client = FakeClient() + obj = UiObject(client, type="Button", textMatches=r"^show") + obj._UiObject__get_by() + assert client.calls == [ + {"api": "On.type", "this": "On#seed", "args": ["Button"]}, + {"api": "On.text", "this": "On#seed", "args": [r"^show", 4]}, + ]