fix(client): bind SKU entry to verified parent chain
This commit is contained in:
@@ -17,6 +17,9 @@ EXPECTED_UNIT_PRICE = "12.88"
|
|||||||
TASK_TO_UI_SELECTION = {("黑色CHA(纯棉)", "M(建议100-115)"): ("黑色 CHA (纯棉)", "M(建议100-115)")}
|
TASK_TO_UI_SELECTION = {("黑色CHA(纯棉)", "M(建议100-115)"): ("黑色 CHA (纯棉)", "M(建议100-115)")}
|
||||||
_TARGET_COLOR_UI, _TARGET_SIZE_UI = next(iter(TASK_TO_UI_SELECTION.values()))
|
_TARGET_COLOR_UI, _TARGET_SIZE_UI = next(iter(TASK_TO_UI_SELECTION.values()))
|
||||||
_ENTRY = "快要抢光"
|
_ENTRY = "快要抢光"
|
||||||
|
_ENTRY_TEXT_BOUNDS = "[900,1312][1056,1355]"
|
||||||
|
_ENTRY_INNER_BOUNDS = "[712,1312][1056,1355]"
|
||||||
|
_ENTRY_ACTION_BOUNDS = "[0,1256][1080,1355]"
|
||||||
_SIZE = "尺码"
|
_SIZE = "尺码"
|
||||||
_W, _H = 1080, 2376
|
_W, _H = 1080, 2376
|
||||||
_PRICE_PARENT = "[396,498][895,570]"
|
_PRICE_PARENT = "[396,498][895,570]"
|
||||||
@@ -352,9 +355,55 @@ def _choice(node: _Node) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def _eligible_entries(nodes: list[_Node]) -> list[_Node]:
|
def _eligible_entries(nodes: list[_Node]) -> list[_Node]:
|
||||||
# 面板与底部硬拒绝区绝不能被误作商品页入口;入口仍只按经取证精确文案、包、状态判定。
|
# 入口文本本身不可点击:必须逐层证明它仍位于已取证的唯一可点击祖先中,但动作坐标继续
|
||||||
|
# 使用文本子节点的窄 bounds,避免把同一祖先内未知区域变成坐标兜底。“免拼购买”等底部
|
||||||
|
# 容器既不属于这条祖先链,也绝不能作为替代入口。
|
||||||
if any(node.bounds == _PRICE_PARENT for node in nodes): return []
|
if any(node.bounds == _PRICE_PARENT for node in nodes): return []
|
||||||
return [node for node in nodes if _live(node) and node.element.get("class") == "android.widget.TextView" and node.text == _ENTRY]
|
entry_labels = [
|
||||||
|
node for node in nodes
|
||||||
|
if node.text == _ENTRY
|
||||||
|
and node.element.get("package") == PDD_PACKAGE
|
||||||
|
and node.element.get("class") == "android.widget.TextView"
|
||||||
|
]
|
||||||
|
if len(entry_labels) != 1:
|
||||||
|
return entry_labels
|
||||||
|
action_ancestors = [
|
||||||
|
node for node in nodes
|
||||||
|
if _exact_entry_node(node, "android.view.ViewGroup", _ENTRY_ACTION_BOUNDS, "true")
|
||||||
|
]
|
||||||
|
if len(action_ancestors) != 1:
|
||||||
|
return []
|
||||||
|
action_ancestor = action_ancestors[0]
|
||||||
|
entries: list[_Node] = []
|
||||||
|
for node in entry_labels:
|
||||||
|
if not _exact_entry_node(node, "android.widget.TextView", _ENTRY_TEXT_BOUNDS, "false"):
|
||||||
|
return []
|
||||||
|
inner = node.parent
|
||||||
|
switcher = inner.parent if inner is not None else None
|
||||||
|
frame = switcher.parent if switcher is not None else None
|
||||||
|
ancestor = frame.parent if frame is not None else None
|
||||||
|
if (
|
||||||
|
inner is not None
|
||||||
|
and _exact_entry_node(inner, "android.view.ViewGroup", _ENTRY_INNER_BOUNDS, "false")
|
||||||
|
and switcher is not None
|
||||||
|
and _exact_entry_node(switcher, "android.widget.ViewSwitcher", _ENTRY_INNER_BOUNDS, "false")
|
||||||
|
and frame is not None
|
||||||
|
and _exact_entry_node(frame, "android.widget.FrameLayout", _ENTRY_INNER_BOUNDS, "false")
|
||||||
|
and ancestor is action_ancestor
|
||||||
|
):
|
||||||
|
entries.append(node)
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def _exact_entry_node(node: _Node, class_name: str, bounds: str, clickable: str) -> bool:
|
||||||
|
return (
|
||||||
|
node.element.get("package") == PDD_PACKAGE
|
||||||
|
and node.element.get("class") == class_name
|
||||||
|
and node.bounds == bounds
|
||||||
|
and node.element.get("clickable") == clickable
|
||||||
|
and node.element.get("enabled") == "true"
|
||||||
|
and node.element.get("visible-to-user") == "true"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _action_bounds(bounds: str) -> tuple[int, int, int, int]:
|
def _action_bounds(bounds: str) -> tuple[int, int, int, int]:
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<hierarchy>
|
||||||
|
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,1256][1080,1355]" clickable="true" enabled="true" visible-to-user="true">
|
||||||
|
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[712,1312][1056,1355]" clickable="false" enabled="true" visible-to-user="true">
|
||||||
|
<node package="com.xunmeng.pinduoduo" class="android.widget.ViewSwitcher" bounds="[712,1312][1056,1355]" clickable="false" enabled="true" visible-to-user="true">
|
||||||
|
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[712,1312][1056,1355]" clickable="false" enabled="true" visible-to-user="true">
|
||||||
|
<node text="快要抢光" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[900,1312][1056,1355]" clickable="false" enabled="true" visible-to-user="true" />
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[446,2166][1080,2328]" clickable="true" enabled="true" visible-to-user="true">
|
||||||
|
<node text="免拼购买" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[688,2256][856,2305]" clickable="false" enabled="true" visible-to-user="true" />
|
||||||
|
</node>
|
||||||
|
</hierarchy>
|
||||||
@@ -27,12 +27,11 @@ from cmbuyer_client.pdd.sku_selection_runner import (
|
|||||||
|
|
||||||
|
|
||||||
_FIXTURE = Path(__file__).with_name("fixtures") / "sku_panel_8_17_0.xml"
|
_FIXTURE = Path(__file__).with_name("fixtures") / "sku_panel_8_17_0.xml"
|
||||||
|
_ENTRY_FIXTURE = Path(__file__).with_name("fixtures") / "product_entry_8_17_0.xml"
|
||||||
_TARGET_URL = "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375"
|
_TARGET_URL = "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375"
|
||||||
_TASK_COLOR = "黑色CHA(纯棉)"
|
_TASK_COLOR = "黑色CHA(纯棉)"
|
||||||
_TASK_SIZE = "M(建议100-115)"
|
_TASK_SIZE = "M(建议100-115)"
|
||||||
_PRODUCT_PAGE = """<hierarchy><node text="快要抢光" package="com.xunmeng.pinduoduo"
|
_PRODUCT_PAGE = _ENTRY_FIXTURE.read_text(encoding="utf-8")
|
||||||
class="android.widget.TextView" clickable="true" enabled="true" visible-to-user="true"
|
|
||||||
bounds="[20,800][320,900]" /></hierarchy>"""
|
|
||||||
|
|
||||||
|
|
||||||
def _png_base64() -> str:
|
def _png_base64() -> str:
|
||||||
@@ -115,6 +114,34 @@ def _center(bounds: str) -> tuple[int, int]:
|
|||||||
return left + (right - left) // 2, top + (bottom - top) // 2
|
return left + (right - left) // 2, top + (bottom - top) // 2
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_chain(root: ElementTree.Element) -> list[ElementTree.Element]:
|
||||||
|
parents = {child: parent for parent in root.iter() for child in parent}
|
||||||
|
child = next(node for node in root.iter("node") if node.get("text") == "快要抢光")
|
||||||
|
chain = [child]
|
||||||
|
for _ in range(4):
|
||||||
|
chain.append(parents[chain[-1]])
|
||||||
|
return chain
|
||||||
|
|
||||||
|
|
||||||
|
def _mutate_entry(depth: int, attribute: str, value: str) -> str:
|
||||||
|
root = ElementTree.fromstring(_PRODUCT_PAGE)
|
||||||
|
_entry_chain(root)[depth].set(attribute, value)
|
||||||
|
return ElementTree.tostring(root, encoding="unicode")
|
||||||
|
|
||||||
|
|
||||||
|
def _without_entry() -> str:
|
||||||
|
root = ElementTree.fromstring(_PRODUCT_PAGE)
|
||||||
|
root.remove(_entry_chain(root)[4])
|
||||||
|
return ElementTree.tostring(root, encoding="unicode")
|
||||||
|
|
||||||
|
|
||||||
|
def _duplicate_entry() -> str:
|
||||||
|
root = ElementTree.fromstring(_PRODUCT_PAGE)
|
||||||
|
entry_root = _entry_chain(root)[4]
|
||||||
|
root.append(ElementTree.fromstring(ElementTree.tostring(entry_root, encoding="unicode")))
|
||||||
|
return ElementTree.tostring(root, encoding="unicode")
|
||||||
|
|
||||||
|
|
||||||
def _actions(device: _RawDevice, method: str) -> list[tuple[object, ...]]:
|
def _actions(device: _RawDevice, method: str) -> list[tuple[object, ...]]:
|
||||||
return [call for call in device.calls if call[0] == "jsonrpc" and call[1] == method]
|
return [call for call in device.calls if call[0] == "jsonrpc" and call[1] == method]
|
||||||
|
|
||||||
@@ -141,6 +168,20 @@ class _FakeAdb:
|
|||||||
|
|
||||||
|
|
||||||
class SkuSelectionFlowTests(unittest.TestCase):
|
class SkuSelectionFlowTests(unittest.TestCase):
|
||||||
|
def _assert_entry_rejected_without_click(self, hierarchy: str) -> None:
|
||||||
|
now = [0.0]
|
||||||
|
device = _RawDevice(hierarchy)
|
||||||
|
flow = SkuSelectionFlow(
|
||||||
|
UiautomatorSkuPanelAdapter(device, 10),
|
||||||
|
0.01,
|
||||||
|
0.01,
|
||||||
|
lambda: now[0],
|
||||||
|
lambda seconds: now.__setitem__(0, now[0] + seconds),
|
||||||
|
)
|
||||||
|
with self.assertRaises(SkuSelectionError):
|
||||||
|
flow.open_sku_panel(_TARGET_URL)
|
||||||
|
self.assertEqual(_actions(device, "click"), [])
|
||||||
|
|
||||||
def test_target_mapping_is_exact_and_success_path_restores_target(self) -> None:
|
def test_target_mapping_is_exact_and_success_path_restores_target(self) -> None:
|
||||||
device = _RawDevice()
|
device = _RawDevice()
|
||||||
adapter = UiautomatorSkuPanelAdapter(device, 10)
|
adapter = UiautomatorSkuPanelAdapter(device, 10)
|
||||||
@@ -151,9 +192,35 @@ class SkuSelectionFlowTests(unittest.TestCase):
|
|||||||
self.assertEqual(flow.read_sku_unit_price(), "12.88")
|
self.assertEqual(flow.read_sku_unit_price(), "12.88")
|
||||||
flow.exit_sku_panel_safely()
|
flow.exit_sku_panel_safely()
|
||||||
|
|
||||||
self.assertEqual(_tap_centers(device), [(170, 850)])
|
self.assertEqual(_tap_centers(device), [(978, 1333)])
|
||||||
self.assertEqual(_actions(device, "pressKey"), [("jsonrpc", "pressKey", ["back"], 10)])
|
self.assertEqual(_actions(device, "pressKey"), [("jsonrpc", "pressKey", ["back"], 10)])
|
||||||
|
|
||||||
|
def test_full_verified_entry_structure_taps_exact_text_child_once(self) -> None:
|
||||||
|
device = _RawDevice()
|
||||||
|
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(_TARGET_URL)
|
||||||
|
|
||||||
|
self.assertEqual(_tap_centers(device), [(978, 1333)])
|
||||||
|
|
||||||
|
def test_entry_child_and_every_ancestor_attribute_drift_never_clicks(self) -> None:
|
||||||
|
expected_clickable = ("false", "false", "false", "false", "true")
|
||||||
|
for depth in range(5):
|
||||||
|
changes = {
|
||||||
|
"package": "other.package",
|
||||||
|
"class": "android.widget.Button",
|
||||||
|
"bounds": "[1,1][2,2]",
|
||||||
|
"clickable": "true" if expected_clickable[depth] == "false" else "false",
|
||||||
|
"enabled": "false",
|
||||||
|
"visible-to-user": "false",
|
||||||
|
}
|
||||||
|
for attribute, value in changes.items():
|
||||||
|
with self.subTest(depth=depth, attribute=attribute):
|
||||||
|
self._assert_entry_rejected_without_click(_mutate_entry(depth, attribute, value))
|
||||||
|
|
||||||
|
def test_duplicate_entry_and_forbidden_sibling_entry_never_click(self) -> None:
|
||||||
|
self._assert_entry_rejected_without_click(_duplicate_entry())
|
||||||
|
self._assert_entry_rejected_without_click(_without_entry())
|
||||||
|
self._assert_entry_rejected_without_click(_mutate_entry(0, "clickable", "true"))
|
||||||
|
|
||||||
def test_unknown_task_or_ui_variants_are_rejected_without_action(self) -> None:
|
def test_unknown_task_or_ui_variants_are_rejected_without_action(self) -> None:
|
||||||
for color, size in (("黑色 CHA (纯棉)", _TASK_SIZE), (_TASK_COLOR, "M(建议100-115)"), ("黑色CHA(纯棉)", _TASK_SIZE)):
|
for color, size in (("黑色 CHA (纯棉)", _TASK_SIZE), (_TASK_COLOR, "M(建议100-115)"), ("黑色CHA(纯棉)", _TASK_SIZE)):
|
||||||
with self.subTest(color=color, size=size), self.assertRaises(SkuSelectionError):
|
with self.subTest(color=color, size=size), self.assertRaises(SkuSelectionError):
|
||||||
@@ -183,7 +250,7 @@ class SkuSelectionFlowTests(unittest.TestCase):
|
|||||||
with self.subTest(bounds=bounds), self.assertRaises(SkuSelectionError):
|
with self.subTest(bounds=bounds), self.assertRaises(SkuSelectionError):
|
||||||
_action_bounds(bounds)
|
_action_bounds(bounds)
|
||||||
|
|
||||||
device = _RawDevice(_PRODUCT_PAGE.replace("[20,800][320,900]", "[0,0][1081,1]"))
|
device = _RawDevice(_PRODUCT_PAGE.replace("[900,1312][1056,1355]", "[0,0][1081,1]"))
|
||||||
with self.assertRaises(SkuSelectionError):
|
with self.assertRaises(SkuSelectionError):
|
||||||
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(_TARGET_URL)
|
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(_TARGET_URL)
|
||||||
self.assertEqual(_actions(device, "click"), [])
|
self.assertEqual(_actions(device, "click"), [])
|
||||||
@@ -196,7 +263,7 @@ class SkuSelectionFlowTests(unittest.TestCase):
|
|||||||
device.select_alternates()
|
device.select_alternates()
|
||||||
with self.assertRaises(SkuSelectionError):
|
with self.assertRaises(SkuSelectionError):
|
||||||
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
|
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
|
||||||
self.assertEqual(_tap_centers(device), [(170, 850), (282, 1086)])
|
self.assertEqual(_tap_centers(device), [(978, 1333), (282, 1086)])
|
||||||
|
|
||||||
def test_non_target_selection_restores_each_dimension_once(self) -> None:
|
def test_non_target_selection_restores_each_dimension_once(self) -> None:
|
||||||
device = _RawDevice()
|
device = _RawDevice()
|
||||||
@@ -206,7 +273,7 @@ class SkuSelectionFlowTests(unittest.TestCase):
|
|||||||
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
|
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
_tap_centers(device),
|
_tap_centers(device),
|
||||||
[(170, 850), (282, 1086), (635, 1772)],
|
[(978, 1333), (282, 1086), (635, 1772)],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_price_rejects_coupon_prefix_extra_amount_and_bottom_action(self) -> None:
|
def test_price_rejects_coupon_prefix_extra_amount_and_bottom_action(self) -> None:
|
||||||
@@ -268,19 +335,22 @@ class SkuSelectionFlowTests(unittest.TestCase):
|
|||||||
device = NoPanelAfterEntry()
|
device = NoPanelAfterEntry()
|
||||||
with self.assertRaises(SkuSelectionError):
|
with self.assertRaises(SkuSelectionError):
|
||||||
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(_TARGET_URL)
|
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(_TARGET_URL)
|
||||||
self.assertEqual(_tap_centers(device), [(170, 850)])
|
self.assertEqual(_tap_centers(device), [(978, 1333)])
|
||||||
|
|
||||||
duplicate = _PRODUCT_PAGE.replace("</hierarchy>", _PRODUCT_PAGE.removeprefix("<hierarchy>"))
|
duplicate = _duplicate_entry()
|
||||||
device = _RawDevice(duplicate)
|
device = _RawDevice(duplicate)
|
||||||
with self.assertRaises(SkuSelectionError):
|
with self.assertRaises(SkuSelectionError):
|
||||||
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(_TARGET_URL)
|
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(_TARGET_URL)
|
||||||
self.assertEqual(_actions(device, "click"), [])
|
self.assertEqual(_actions(device, "click"), [])
|
||||||
|
|
||||||
def test_fixture_contains_no_address_phone_or_payment_credentials(self) -> None:
|
def test_fixture_contains_no_address_phone_or_payment_credentials(self) -> None:
|
||||||
|
for fixture in (_FIXTURE, _ENTRY_FIXTURE):
|
||||||
|
content = fixture.read_text(encoding="utf-8")
|
||||||
|
with self.subTest(fixture=fixture.name):
|
||||||
|
self.assertNotRegex(content, r"1[3-9]\d{9}")
|
||||||
|
for forbidden in ("地址", "收货", "支付", "银行卡", "身份证"):
|
||||||
|
self.assertNotIn(forbidden, content)
|
||||||
content = _FIXTURE.read_text(encoding="utf-8")
|
content = _FIXTURE.read_text(encoding="utf-8")
|
||||||
self.assertNotRegex(content, r"1[3-9]\d{9}")
|
|
||||||
for forbidden in ("地址", "收货", "支付", "银行卡", "身份证"):
|
|
||||||
self.assertNotIn(forbidden, content)
|
|
||||||
root = ElementTree.fromstring(content)
|
root = ElementTree.fromstring(content)
|
||||||
leaf = next(node for node in root.iter("node") if node.get("text") == "提交订单 ¥12.88")
|
leaf = next(node for node in root.iter("node") if node.get("text") == "提交订单 ¥12.88")
|
||||||
self.assertEqual(leaf.get("clickable"), "false")
|
self.assertEqual(leaf.get("clickable"), "false")
|
||||||
@@ -442,9 +512,9 @@ class SkuSelectionRunnerTests(unittest.TestCase):
|
|||||||
|
|
||||||
adapter = UiautomatorSkuPanelAdapter(TimeoutTapDevice(), 10)
|
adapter = UiautomatorSkuPanelAdapter(TimeoutTapDevice(), 10)
|
||||||
with self.assertRaises(SkuSelectionRunError):
|
with self.assertRaises(SkuSelectionRunError):
|
||||||
adapter.tap_sku_entry("[20,800][320,900]")
|
adapter.tap_sku_entry("[900,1312][1056,1355]")
|
||||||
self.assertTrue(adapter.entry_was_tapped)
|
self.assertTrue(adapter.entry_was_tapped)
|
||||||
self.assertEqual(_actions(adapter._device, "click"), [("jsonrpc", "click", [170, 850], 10)])
|
self.assertEqual(_actions(adapter._device, "click"), [("jsonrpc", "click", [978, 1333], 10)])
|
||||||
|
|
||||||
def test_entry_stability_interruptions_never_click(self) -> None:
|
def test_entry_stability_interruptions_never_click(self) -> None:
|
||||||
now = [0.0]
|
now = [0.0]
|
||||||
|
|||||||
Reference in New Issue
Block a user