fix: 多地址面板选择默认地址 (#187)
This commit is contained in:
@@ -202,6 +202,68 @@ def _clickable_text_targets(root: ET.Element, exact_text: str) -> list[Bounds]:
|
|||||||
return sorted(targets)
|
return sorted(targets)
|
||||||
|
|
||||||
|
|
||||||
|
def _node_package(
|
||||||
|
node: ET.Element, parents: Mapping[ET.Element, ET.Element]
|
||||||
|
) -> str:
|
||||||
|
"""返回节点自身或最近祖先声明的包名。"""
|
||||||
|
|
||||||
|
current: Optional[ET.Element] = node
|
||||||
|
while current is not None:
|
||||||
|
if package_name := current.get("package", "").strip():
|
||||||
|
return package_name
|
||||||
|
current = parents.get(current)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _pdd_text_targets(root: ET.Element, exact_text: str) -> list[Bounds]:
|
||||||
|
"""只返回 PDD 页面中指定文字的最近点击容器。"""
|
||||||
|
|
||||||
|
parents = _parent_nodes(root)
|
||||||
|
targets = {
|
||||||
|
bounds
|
||||||
|
for node in root.iter("node")
|
||||||
|
if exact_text
|
||||||
|
in {
|
||||||
|
node.get("text", "").strip(),
|
||||||
|
node.get("content-desc", "").strip(),
|
||||||
|
}
|
||||||
|
if _node_package(node, parents) == PDD_PACKAGE_NAME
|
||||||
|
if (bounds := _nearest_clickable_bounds(node, parents)) is not None
|
||||||
|
}
|
||||||
|
return sorted(targets)
|
||||||
|
|
||||||
|
|
||||||
|
def _address_modify_targets(root: ET.Element) -> list[Bounds]:
|
||||||
|
"""多地址时只返回唯一“已设默认”卡片内的修改按钮。"""
|
||||||
|
|
||||||
|
address_cards = [
|
||||||
|
card
|
||||||
|
for recycler in root.iter("node")
|
||||||
|
if recycler.get("class") == "androidx.recyclerview.widget.RecyclerView"
|
||||||
|
for card in list(recycler)
|
||||||
|
if _pdd_text_targets(card, "修改")
|
||||||
|
]
|
||||||
|
if len(address_cards) <= 1:
|
||||||
|
return _pdd_text_targets(root, "修改")
|
||||||
|
|
||||||
|
default_cards = [
|
||||||
|
card for card in address_cards if "已设默认" in _labels(card)
|
||||||
|
]
|
||||||
|
if len(default_cards) != 1:
|
||||||
|
return []
|
||||||
|
return _pdd_text_targets(default_cards[0], "修改")
|
||||||
|
|
||||||
|
|
||||||
|
def _pdd_title_back_targets(root: ET.Element) -> list[Bounds]:
|
||||||
|
"""只返回 PDD 地址页标题栏左侧的返回目标。"""
|
||||||
|
|
||||||
|
return [
|
||||||
|
bounds
|
||||||
|
for bounds in _pdd_text_targets(root, "返回")
|
||||||
|
if bounds[0] <= 240 and bounds[1] <= 320
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _address_entry_targets(root: ET.Element) -> list[Bounds]:
|
def _address_entry_targets(root: ET.Element) -> list[Bounds]:
|
||||||
"""通过脱敏手机号结构定位规格面板顶部的收货地址卡片。"""
|
"""通过脱敏手机号结构定位规格面板顶部的收货地址卡片。"""
|
||||||
|
|
||||||
@@ -266,9 +328,9 @@ def _is_reliable_address_panel(
|
|||||||
labels = _labels(root)
|
labels = _labels(root)
|
||||||
if "收货地址" not in labels:
|
if "收货地址" not in labels:
|
||||||
return False
|
return False
|
||||||
if len(_clickable_text_targets(root, "修改")) != 1:
|
if len(_address_modify_targets(root)) != 1:
|
||||||
return False
|
return False
|
||||||
if len(_clickable_text_targets(root, "返回")) != 1:
|
if len(_pdd_title_back_targets(root)) != 1:
|
||||||
return False
|
return False
|
||||||
if expected_address and any(expected_address in label for label in labels):
|
if expected_address and any(expected_address in label for label in labels):
|
||||||
return True
|
return True
|
||||||
@@ -1344,7 +1406,7 @@ class U2PddLivePurchaseAdapter(U2PddPurchaseAdapter, PddLivePurchaseAdapter):
|
|||||||
saved_root, saved_page = self._wait_after_address_save(new_address)
|
saved_root, saved_page = self._wait_after_address_save(new_address)
|
||||||
if saved_page == "confirmation":
|
if saved_page == "confirmation":
|
||||||
return
|
return
|
||||||
back_targets = _clickable_text_targets(saved_root, "返回")
|
back_targets = _pdd_title_back_targets(saved_root)
|
||||||
self._click_unique_address_target(
|
self._click_unique_address_target(
|
||||||
back_targets,
|
back_targets,
|
||||||
"PURCHASE_ADDRESS_BACK_AMBIGUOUS",
|
"PURCHASE_ADDRESS_BACK_AMBIGUOUS",
|
||||||
@@ -1394,7 +1456,7 @@ class U2PddLivePurchaseAdapter(U2PddPurchaseAdapter, PddLivePurchaseAdapter):
|
|||||||
return root
|
return root
|
||||||
|
|
||||||
if observed_page == "panel":
|
if observed_page == "panel":
|
||||||
targets = _clickable_text_targets(root, "修改")
|
targets = _address_modify_targets(root)
|
||||||
candidate_count = len(targets)
|
candidate_count = len(targets)
|
||||||
if candidate_count != 1:
|
if candidate_count != 1:
|
||||||
raise PddPurchaseError(
|
raise PddPurchaseError(
|
||||||
|
|||||||
@@ -294,6 +294,56 @@ def address_panel_xml(address: str) -> str:
|
|||||||
</hierarchy>"""
|
</hierarchy>"""
|
||||||
|
|
||||||
|
|
||||||
|
def multiple_address_panel_xml(
|
||||||
|
address: str, *, default_indexes=(0,)
|
||||||
|
) -> str:
|
||||||
|
"""按真机多地址结构构造不含个人数据的最小控件树。"""
|
||||||
|
|
||||||
|
cards = []
|
||||||
|
for index, (top, bottom) in enumerate(((226, 654), (654, 1083))):
|
||||||
|
modify_top = 544 if index == 0 else 973
|
||||||
|
marker = "已设默认" if index in default_indexes else "使用"
|
||||||
|
marker_top = 547 if index == 0 else 817
|
||||||
|
address_text = address if index in default_indexes else "备用测试地址"
|
||||||
|
cards.append(f"""
|
||||||
|
<node package="com.xunmeng.pinduoduo"
|
||||||
|
class="android.view.ViewGroup" bounds="[0,{top}][1020,{bottom}]">
|
||||||
|
<node package="com.xunmeng.pinduoduo" text="{address_text}"
|
||||||
|
class="android.widget.TextView" bounds="[32,{top + 120}][825,{top + 240}]"/>
|
||||||
|
<node package="com.xunmeng.pinduoduo" clickable="true"
|
||||||
|
bounds="[32,{marker_top - 20}][249,{marker_top + 75}]">
|
||||||
|
<node package="com.xunmeng.pinduoduo" text="{marker}"
|
||||||
|
class="android.widget.TextView"
|
||||||
|
bounds="[89,{marker_top}][249,{marker_top + 48}]"/>
|
||||||
|
</node>
|
||||||
|
<node package="com.xunmeng.pinduoduo" clickable="true"
|
||||||
|
bounds="[908,{modify_top}][988,{modify_top + 54}]">
|
||||||
|
<node package="com.xunmeng.pinduoduo" text="修改"
|
||||||
|
class="android.widget.TextView"
|
||||||
|
bounds="[908,{modify_top}][988,{modify_top + 54}]"/>
|
||||||
|
</node>
|
||||||
|
</node>""")
|
||||||
|
return f"""<hierarchy>
|
||||||
|
<node package="com.xunmeng.pinduoduo" bounds="[0,0][1080,2376]">
|
||||||
|
<node package="com.xunmeng.pinduoduo" clickable="true"
|
||||||
|
bounds="[0,102][217,226]">
|
||||||
|
<node package="com.xunmeng.pinduoduo" text="返回"
|
||||||
|
class="android.widget.TextView" bounds="[38,132][98,195]"/>
|
||||||
|
</node>
|
||||||
|
<node package="com.xunmeng.pinduoduo" text="收货地址"
|
||||||
|
class="android.widget.TextView" bounds="[418,102][602,226]"/>
|
||||||
|
<node package="com.xunmeng.pinduoduo"
|
||||||
|
class="androidx.recyclerview.widget.RecyclerView"
|
||||||
|
bounds="[0,226][1020,2140]">
|
||||||
|
{''.join(cards)}
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
<node package="com.android.systemui" content-desc="返回"
|
||||||
|
class="android.widget.ImageView" clickable="true"
|
||||||
|
bounds="[640,2140][802,2250]"/>
|
||||||
|
</hierarchy>"""
|
||||||
|
|
||||||
|
|
||||||
def address_edit_xml(address: str) -> str:
|
def address_edit_xml(address: str) -> str:
|
||||||
return f"""<hierarchy>
|
return f"""<hierarchy>
|
||||||
<node package="com.xunmeng.pinduoduo" bounds="[0,0][1080,2376]">
|
<node package="com.xunmeng.pinduoduo" bounds="[0,0][1080,2376]">
|
||||||
@@ -377,6 +427,75 @@ class AddressFlowDevice(FakeDevice):
|
|||||||
return super().__call__(**selector)
|
return super().__call__(**selector)
|
||||||
|
|
||||||
|
|
||||||
|
class MultipleAddressFlowDevice(AddressFlowDevice):
|
||||||
|
"""模拟多地址面板,并记录是否点击默认卡片和 PDD 标题栏返回。"""
|
||||||
|
|
||||||
|
def __init__(self, default_indexes=(0,)) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.default_indexes = default_indexes
|
||||||
|
self.address_modify_clicks = []
|
||||||
|
self.address_back_clicks = []
|
||||||
|
|
||||||
|
def dump_hierarchy(self):
|
||||||
|
if self.mode in {"address_panel", "address_saved"}:
|
||||||
|
return multiple_address_panel_xml(
|
||||||
|
self.address, default_indexes=self.default_indexes
|
||||||
|
)
|
||||||
|
return super().dump_hierarchy()
|
||||||
|
|
||||||
|
def click(self, x, y):
|
||||||
|
if self.mode == "address_panel":
|
||||||
|
self.clicks.append((x, y))
|
||||||
|
self.address_modify_clicks.append((x, y))
|
||||||
|
expected_y = 571 if self.default_indexes == (0,) else 1000
|
||||||
|
if (x, y) == (948, expected_y):
|
||||||
|
self.mode = "address_edit"
|
||||||
|
return
|
||||||
|
if self.mode == "address_saved":
|
||||||
|
self.clicks.append((x, y))
|
||||||
|
self.address_back_clicks.append((x, y))
|
||||||
|
if x <= 240 and y <= 320:
|
||||||
|
self.mode = "panel"
|
||||||
|
return
|
||||||
|
super().click(x, y)
|
||||||
|
|
||||||
|
|
||||||
|
class AmbiguousDefaultAddressDevice(MultipleAddressFlowDevice):
|
||||||
|
"""模拟默认地址卡片内部出现两个修改目标。"""
|
||||||
|
|
||||||
|
def dump_hierarchy(self):
|
||||||
|
xml_data = super().dump_hierarchy()
|
||||||
|
if self.mode != "address_panel":
|
||||||
|
return xml_data
|
||||||
|
root = ET.fromstring(xml_data)
|
||||||
|
recycler = next(
|
||||||
|
node
|
||||||
|
for node in root.iter("node")
|
||||||
|
if node.get("class")
|
||||||
|
== "androidx.recyclerview.widget.RecyclerView"
|
||||||
|
)
|
||||||
|
default_card = list(recycler)[0]
|
||||||
|
extra = ET.SubElement(
|
||||||
|
default_card,
|
||||||
|
"node",
|
||||||
|
{
|
||||||
|
"package": "com.xunmeng.pinduoduo",
|
||||||
|
"clickable": "true",
|
||||||
|
"bounds": "[800,544][880,598]",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
ET.SubElement(
|
||||||
|
extra,
|
||||||
|
"node",
|
||||||
|
{
|
||||||
|
"package": "com.xunmeng.pinduoduo",
|
||||||
|
"text": "修改",
|
||||||
|
"bounds": "[800,544][880,598]",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return ET.tostring(root, encoding="unicode")
|
||||||
|
|
||||||
|
|
||||||
class AddressSaveOverlayDevice(AddressFlowDevice):
|
class AddressSaveOverlayDevice(AddressFlowDevice):
|
||||||
"""模拟保存后出现 ColorOS 密码本浮层。"""
|
"""模拟保存后出现 ColorOS 密码本浮层。"""
|
||||||
|
|
||||||
@@ -730,6 +849,77 @@ class U2PddPurchaseAdapterTest(unittest.TestCase):
|
|||||||
self.assertEqual(state.price_cent, 503)
|
self.assertEqual(state.price_cent, 503)
|
||||||
adapter.close()
|
adapter.close()
|
||||||
|
|
||||||
|
def test_multiple_addresses_modify_only_default_card(self):
|
||||||
|
device = MultipleAddressFlowDevice(default_indexes=(0,))
|
||||||
|
adapter = self._live_adapter(device, [])
|
||||||
|
adapter.open_goods(GOODS_URL)
|
||||||
|
adapter.select_options(
|
||||||
|
{"color": "黑色", "size": "3XL【140-165斤】"}
|
||||||
|
)
|
||||||
|
|
||||||
|
adapter.update_shipping_address("cg8")
|
||||||
|
|
||||||
|
self.assertEqual(device.address_modify_clicks, [(948, 571)])
|
||||||
|
self.assertEqual(device.address_back_clicks, [(108, 164)])
|
||||||
|
self.assertEqual(device.mode, "panel")
|
||||||
|
adapter.close()
|
||||||
|
|
||||||
|
def test_default_address_can_be_second_card(self):
|
||||||
|
device = MultipleAddressFlowDevice(default_indexes=(1,))
|
||||||
|
adapter = self._live_adapter(device, [])
|
||||||
|
adapter.open_goods(GOODS_URL)
|
||||||
|
adapter.select_options(
|
||||||
|
{"color": "黑色", "size": "3XL【140-165斤】"}
|
||||||
|
)
|
||||||
|
|
||||||
|
adapter.update_shipping_address("cg8")
|
||||||
|
|
||||||
|
self.assertEqual(device.address_modify_clicks, [(948, 1000)])
|
||||||
|
self.assertEqual(device.address_back_clicks, [(108, 164)])
|
||||||
|
self.assertEqual(device.mode, "panel")
|
||||||
|
adapter.close()
|
||||||
|
|
||||||
|
def test_multiple_addresses_without_unique_default_stop_safely(self):
|
||||||
|
for default_indexes in ((), (0, 1)):
|
||||||
|
with self.subTest(default_indexes=default_indexes):
|
||||||
|
device = MultipleAddressFlowDevice(
|
||||||
|
default_indexes=default_indexes
|
||||||
|
)
|
||||||
|
adapter = self._live_adapter(device, [])
|
||||||
|
adapter.open_goods(GOODS_URL)
|
||||||
|
adapter.select_options(
|
||||||
|
{"color": "黑色", "size": "3XL【140-165斤】"}
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(PddPurchaseError) as raised:
|
||||||
|
adapter.update_shipping_address("cg8")
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
raised.exception.code,
|
||||||
|
"PURCHASE_ADDRESS_EDIT_AMBIGUOUS",
|
||||||
|
)
|
||||||
|
self.assertEqual(device.address_modify_clicks, [])
|
||||||
|
self.assertEqual(device.address_back_clicks, [])
|
||||||
|
adapter.close()
|
||||||
|
|
||||||
|
def test_default_address_with_multiple_modify_targets_stops_safely(self):
|
||||||
|
device = AmbiguousDefaultAddressDevice()
|
||||||
|
adapter = self._live_adapter(device, [])
|
||||||
|
adapter.open_goods(GOODS_URL)
|
||||||
|
adapter.select_options(
|
||||||
|
{"color": "黑色", "size": "3XL【140-165斤】"}
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(PddPurchaseError) as raised:
|
||||||
|
adapter.update_shipping_address("cg8")
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
raised.exception.code, "PURCHASE_ADDRESS_EDIT_AMBIGUOUS"
|
||||||
|
)
|
||||||
|
self.assertEqual(raised.exception.diagnostics["candidate_count"], 2)
|
||||||
|
self.assertEqual(device.address_modify_clicks, [])
|
||||||
|
adapter.close()
|
||||||
|
|
||||||
def test_saved_address_panel_can_hide_full_address(self):
|
def test_saved_address_panel_can_hide_full_address(self):
|
||||||
device = HiddenSavedAddressDevice()
|
device = HiddenSavedAddressDevice()
|
||||||
adapter = self._live_adapter(device, [])
|
adapter = self._live_adapter(device, [])
|
||||||
|
|||||||
Reference in New Issue
Block a user