fix(client): target verified bottom sku entry
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
<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">
|
||||
@@ -8,7 +9,9 @@
|
||||
</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 content-desc="快要抢光¥12.88" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[446,2166][1080,2328]" clickable="true" enabled="true" visible-to-user="true">
|
||||
<node text="快要抢光 ¥ 12.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[688,2184][1042,2253]" clickable="false" 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>
|
||||
|
||||
@@ -77,7 +77,11 @@ class _RawDevice:
|
||||
raise AssertionError(method)
|
||||
|
||||
def _apply_tap(self, x: int, y: int) -> None:
|
||||
if "快要抢光" in self.hierarchy and "[396,498][895,570]" not in self.hierarchy:
|
||||
if (
|
||||
(x, y) == (865, 2218)
|
||||
and "快要抢光 ¥ 12.88" in self.hierarchy
|
||||
and "[396,498][895,570]" not in self.hierarchy
|
||||
):
|
||||
self.hierarchy = self.panel_hierarchy
|
||||
return
|
||||
root = ElementTree.fromstring(self.hierarchy)
|
||||
@@ -117,11 +121,12 @@ def _center(bounds: str) -> tuple[int, int]:
|
||||
|
||||
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
|
||||
child = next(node for node in root.iter("node") if node.get("text") == "快要抢光 ¥ 12.88")
|
||||
return [child, parents[child]]
|
||||
|
||||
|
||||
def _entry_sibling(root: ElementTree.Element) -> ElementTree.Element:
|
||||
return next(node for node in root.iter("node") if node.get("text") == "免拼购买")
|
||||
|
||||
|
||||
def _mutate_entry(depth: int, attribute: str, value: str) -> str:
|
||||
@@ -132,20 +137,20 @@ def _mutate_entry(depth: int, attribute: str, value: str) -> str:
|
||||
|
||||
def _without_entry() -> str:
|
||||
root = ElementTree.fromstring(_PRODUCT_PAGE)
|
||||
root.remove(_entry_chain(root)[4])
|
||||
root.remove(_entry_chain(root)[1])
|
||||
return ElementTree.tostring(root, encoding="unicode")
|
||||
|
||||
|
||||
def _duplicate_entry() -> str:
|
||||
root = ElementTree.fromstring(_PRODUCT_PAGE)
|
||||
entry_root = _entry_chain(root)[4]
|
||||
entry_root = _entry_chain(root)[1]
|
||||
root.append(ElementTree.fromstring(ElementTree.tostring(entry_root, encoding="unicode")))
|
||||
return ElementTree.tostring(root, encoding="unicode")
|
||||
|
||||
|
||||
def _extra_entry_action_ancestor() -> str:
|
||||
root = ElementTree.fromstring(_PRODUCT_PAGE)
|
||||
action = _entry_chain(root)[4]
|
||||
action = _entry_chain(root)[1]
|
||||
root.append(ElementTree.Element("node", dict(action.attrib)))
|
||||
return ElementTree.tostring(root, encoding="unicode")
|
||||
|
||||
@@ -159,7 +164,7 @@ def _entry_with_panel_price_marker() -> str:
|
||||
def _dynamic_product_page(value: str, action_desc: str = "") -> str:
|
||||
root = ElementTree.fromstring(_PRODUCT_PAGE)
|
||||
root.set("dynamic-page-value", value)
|
||||
_entry_chain(root)[4].set("content-desc", action_desc)
|
||||
_entry_chain(root)[1].set("content-desc", action_desc or "快要抢光¥12.88")
|
||||
return ElementTree.tostring(root, encoding="unicode")
|
||||
|
||||
|
||||
@@ -184,10 +189,10 @@ def _with_overlapping_clickable(package: str, class_name: str, bounds: str) -> s
|
||||
def _with_action_subtree_child(
|
||||
text: str,
|
||||
*,
|
||||
bounds: str = "[10,1260][100,1300]",
|
||||
bounds: str = "[500,2170][600,2200]",
|
||||
) -> str:
|
||||
root = ElementTree.fromstring(_PRODUCT_PAGE)
|
||||
action = _entry_chain(root)[4]
|
||||
action = _entry_chain(root)[1]
|
||||
ElementTree.SubElement(
|
||||
action,
|
||||
"node",
|
||||
@@ -204,6 +209,24 @@ def _with_action_subtree_child(
|
||||
return ElementTree.tostring(root, encoding="unicode")
|
||||
|
||||
|
||||
def _mutate_entry_sibling(attribute: str, value: str) -> str:
|
||||
root = ElementTree.fromstring(_PRODUCT_PAGE)
|
||||
_entry_sibling(root).set(attribute, value)
|
||||
return ElementTree.tostring(root, encoding="unicode")
|
||||
|
||||
|
||||
def _without_entry_sibling() -> str:
|
||||
root = ElementTree.fromstring(_PRODUCT_PAGE)
|
||||
_entry_chain(root)[1].remove(_entry_sibling(root))
|
||||
return ElementTree.tostring(root, encoding="unicode")
|
||||
|
||||
|
||||
def _entry_sibling_elsewhere() -> str:
|
||||
root = ElementTree.fromstring(_PRODUCT_PAGE)
|
||||
root.append(ElementTree.fromstring(ElementTree.tostring(_entry_sibling(root), encoding="unicode")))
|
||||
return ElementTree.tostring(root, encoding="unicode")
|
||||
|
||||
|
||||
def _home_with_unverified_entry_labels() -> str:
|
||||
root = ElementTree.Element("hierarchy")
|
||||
for index in range(2):
|
||||
@@ -273,14 +296,16 @@ class SkuSelectionFlowTests(unittest.TestCase):
|
||||
self.assertEqual(flow.read_sku_unit_price(), "12.88")
|
||||
flow.exit_sku_panel_safely()
|
||||
|
||||
self.assertEqual(_tap_centers(device), [(978, 1333)])
|
||||
self.assertEqual(_tap_centers(device), [(865, 2218)])
|
||||
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)])
|
||||
self.assertEqual(_tap_centers(device), [(865, 2218)])
|
||||
self.assertNotIn((763, 2247), _tap_centers(device)) # 父容器中心不是获准目标。
|
||||
self.assertNotIn((772, 2280), _tap_centers(device)) # 第二行“免拼购买”永不点击。
|
||||
|
||||
def test_entry_stability_uses_verified_chain_projection_not_whole_xml(self) -> None:
|
||||
class DynamicProductDevice(_RawDevice):
|
||||
@@ -291,7 +316,7 @@ class SkuSelectionFlowTests(unittest.TestCase):
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
if method == "dumpWindowHierarchy" and "快要抢光" in self.hierarchy:
|
||||
self.frame += 1
|
||||
self.hierarchy = _dynamic_product_page(str(self.frame), "活动剩余快要抢光")
|
||||
self.hierarchy = _dynamic_product_page(str(self.frame))
|
||||
return super().jsonrpc_call(method, params, timeout)
|
||||
|
||||
now = [0.0]
|
||||
@@ -307,7 +332,7 @@ class SkuSelectionFlowTests(unittest.TestCase):
|
||||
flow.open_sku_panel(_TARGET_URL, "<hierarchy />")
|
||||
|
||||
self.assertEqual(device.frame, 2)
|
||||
self.assertEqual(_tap_centers(device), [(978, 1333)])
|
||||
self.assertEqual(_tap_centers(device), [(865, 2218)])
|
||||
|
||||
def test_entry_action_description_must_be_stable_across_frames(self) -> None:
|
||||
class ChangingActionDescriptionDevice(_RawDevice):
|
||||
@@ -347,11 +372,11 @@ class SkuSelectionFlowTests(unittest.TestCase):
|
||||
_home_with_unverified_entry_labels(),
|
||||
)
|
||||
|
||||
self.assertEqual(_tap_centers(device), [(978, 1333)])
|
||||
self.assertEqual(_tap_centers(device), [(865, 2218)])
|
||||
|
||||
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):
|
||||
expected_clickable = ("false", "true")
|
||||
for depth in range(2):
|
||||
changes = {
|
||||
"package": "other.package",
|
||||
"class": "android.widget.Button",
|
||||
@@ -366,13 +391,12 @@ class SkuSelectionFlowTests(unittest.TestCase):
|
||||
|
||||
def test_entry_chain_text_and_description_drift_never_clicks(self) -> None:
|
||||
cases = [
|
||||
_mutate_entry(depth, "text", "祖先文字漂移") for depth in range(1, 5)
|
||||
_mutate_entry(1, "text", "祖先文字漂移")
|
||||
] + [
|
||||
_mutate_entry(depth, "content-desc", "祖先描述漂移") for depth in range(0, 4)
|
||||
_mutate_entry(0, "content-desc", "祖先描述漂移"),
|
||||
_mutate_entry(1, "content-desc", "祖先描述漂移"),
|
||||
] + [
|
||||
_mutate_entry(4, "content-desc", "祖先描述漂移")
|
||||
] + [
|
||||
_mutate_entry(4, "content-desc", forbidden + "快要抢光")
|
||||
_mutate_entry(1, "content-desc", forbidden + "快要抢光")
|
||||
for forbidden in (
|
||||
"免拼购买", "单独购买", "直接拼成", "提交订单", "支付", "先用后付", "0元下单",
|
||||
"立即购买", "确认下单", "立即付款", "订单详情",
|
||||
@@ -387,12 +411,12 @@ class SkuSelectionFlowTests(unittest.TestCase):
|
||||
_with_overlapping_clickable(
|
||||
"com.xunmeng.pinduoduo",
|
||||
"android.widget.Button",
|
||||
"[950,1300][1000,1340]",
|
||||
"[850,2200][900,2230]",
|
||||
),
|
||||
_with_overlapping_clickable(
|
||||
"com.android.systemui",
|
||||
"android.view.ViewGroup",
|
||||
"[936,1208][1080,1352]",
|
||||
"[800,2100][1000,2300]",
|
||||
),
|
||||
_with_overlapping_clickable(
|
||||
"com.android.systemui",
|
||||
@@ -408,7 +432,7 @@ class SkuSelectionFlowTests(unittest.TestCase):
|
||||
for old_page in (
|
||||
_extra_entry_action_ancestor(),
|
||||
_entry_with_panel_price_marker(),
|
||||
_mutate_entry(4, "content-desc", "立即购买快要抢光"),
|
||||
_mutate_entry(1, "content-desc", "立即购买快要抢光"),
|
||||
):
|
||||
with self.subTest():
|
||||
device = _RawDevice()
|
||||
@@ -435,6 +459,30 @@ class SkuSelectionFlowTests(unittest.TestCase):
|
||||
self._assert_entry_rejected_without_click(_without_entry())
|
||||
self._assert_entry_rejected_without_click(_mutate_entry(0, "clickable", "true"))
|
||||
|
||||
def test_header_promotion_label_alone_never_becomes_entry(self) -> None:
|
||||
self._assert_entry_rejected_without_click(_without_entry())
|
||||
|
||||
def test_entry_leaf_and_parent_amount_identity_drift_never_clicks(self) -> None:
|
||||
for hierarchy in (
|
||||
_PRODUCT_PAGE.replace('text="快要抢光 ¥ 12.88"', 'text="快要抢光 ¥ 13.88"'),
|
||||
_mutate_entry(1, "content-desc", "快要抢光¥13.88"),
|
||||
):
|
||||
with self.subTest():
|
||||
self._assert_entry_rejected_without_click(hierarchy)
|
||||
|
||||
def test_entry_sibling_is_exact_inert_unique_and_in_same_parent(self) -> None:
|
||||
cases = (
|
||||
_without_entry_sibling(),
|
||||
_mutate_entry_sibling("text", "免拼购买 "),
|
||||
_mutate_entry_sibling("bounds", "[688,2255][856,2305]"),
|
||||
_mutate_entry_sibling("clickable", "true"),
|
||||
_with_action_subtree_child("免拼购买", bounds="[500,2260][650,2300]"),
|
||||
_entry_sibling_elsewhere(),
|
||||
)
|
||||
for hierarchy in cases:
|
||||
with self.subTest():
|
||||
self._assert_entry_rejected_without_click(hierarchy)
|
||||
|
||||
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)):
|
||||
with self.subTest(color=color, size=size), self.assertRaises(SkuSelectionError):
|
||||
@@ -464,7 +512,7 @@ class SkuSelectionFlowTests(unittest.TestCase):
|
||||
with self.subTest(bounds=bounds), self.assertRaises(SkuSelectionError):
|
||||
_action_bounds(bounds)
|
||||
|
||||
device = _RawDevice(_PRODUCT_PAGE.replace("[900,1312][1056,1355]", "[0,0][1081,1]"))
|
||||
device = _RawDevice(_PRODUCT_PAGE.replace("[688,2184][1042,2253]", "[0,0][1081,1]"))
|
||||
with self.assertRaises(SkuSelectionError):
|
||||
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(_TARGET_URL)
|
||||
self.assertEqual(_actions(device, "click"), [])
|
||||
@@ -477,7 +525,7 @@ class SkuSelectionFlowTests(unittest.TestCase):
|
||||
device.select_alternates()
|
||||
with self.assertRaises(SkuSelectionError):
|
||||
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
|
||||
self.assertEqual(_tap_centers(device), [(978, 1333), (282, 1086)])
|
||||
self.assertEqual(_tap_centers(device), [(865, 2218), (282, 1086)])
|
||||
|
||||
def test_non_target_selection_restores_each_dimension_once(self) -> None:
|
||||
device = _RawDevice()
|
||||
@@ -487,7 +535,7 @@ class SkuSelectionFlowTests(unittest.TestCase):
|
||||
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
|
||||
self.assertEqual(
|
||||
_tap_centers(device),
|
||||
[(978, 1333), (282, 1086), (635, 1772)],
|
||||
[(865, 2218), (282, 1086), (635, 1772)],
|
||||
)
|
||||
|
||||
def test_price_rejects_coupon_prefix_extra_amount_and_bottom_action(self) -> None:
|
||||
@@ -549,7 +597,7 @@ class SkuSelectionFlowTests(unittest.TestCase):
|
||||
device = NoPanelAfterEntry()
|
||||
with self.assertRaises(SkuSelectionError):
|
||||
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(_TARGET_URL)
|
||||
self.assertEqual(_tap_centers(device), [(978, 1333)])
|
||||
self.assertEqual(_tap_centers(device), [(865, 2218)])
|
||||
|
||||
duplicate = _duplicate_entry()
|
||||
device = _RawDevice(duplicate)
|
||||
@@ -802,9 +850,9 @@ class SkuSelectionRunnerTests(unittest.TestCase):
|
||||
|
||||
adapter = UiautomatorSkuPanelAdapter(TimeoutTapDevice(), 10)
|
||||
with self.assertRaises(SkuSelectionRunError):
|
||||
adapter.tap_sku_entry("[900,1312][1056,1355]")
|
||||
adapter.tap_sku_entry("[688,2184][1042,2253]")
|
||||
self.assertTrue(adapter.entry_was_tapped)
|
||||
self.assertEqual(_actions(adapter._device, "click"), [("jsonrpc", "click", [978, 1333], 10)])
|
||||
self.assertEqual(_actions(adapter._device, "click"), [("jsonrpc", "click", [865, 2218], 10)])
|
||||
|
||||
def test_entry_stability_interruptions_never_click(self) -> None:
|
||||
now = [0.0]
|
||||
|
||||
Reference in New Issue
Block a user