Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) 工具查看

Expand All @@ -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`。

**组合定位**

Expand Down
21 changes: 21 additions & 0 deletions docs/DEVELOP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
102 changes: 98 additions & 4 deletions hmdriver2/_uiobject.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.<attr>(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.<name> + 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.<name>``. 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"
Expand All @@ -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)


Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
147 changes: 147 additions & 0 deletions tests/test_selector.py
Original file line number Diff line number Diff line change
@@ -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]},
]