diff --git a/client/src/pdd_u2_purchase_adapter.py b/client/src/pdd_u2_purchase_adapter.py index da8f509..2f5c8a9 100644 --- a/client/src/pdd_u2_purchase_adapter.py +++ b/client/src/pdd_u2_purchase_adapter.py @@ -202,6 +202,68 @@ def _clickable_text_targets(root: ET.Element, exact_text: str) -> list[Bounds]: 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]: """通过脱敏手机号结构定位规格面板顶部的收货地址卡片。""" @@ -266,9 +328,9 @@ def _is_reliable_address_panel( labels = _labels(root) if "收货地址" not in labels: return False - if len(_clickable_text_targets(root, "修改")) != 1: + if len(_address_modify_targets(root)) != 1: return False - if len(_clickable_text_targets(root, "返回")) != 1: + if len(_pdd_title_back_targets(root)) != 1: return False if expected_address and any(expected_address in label for label in labels): return True @@ -1344,7 +1406,7 @@ class U2PddLivePurchaseAdapter(U2PddPurchaseAdapter, PddLivePurchaseAdapter): saved_root, saved_page = self._wait_after_address_save(new_address) if saved_page == "confirmation": return - back_targets = _clickable_text_targets(saved_root, "返回") + back_targets = _pdd_title_back_targets(saved_root) self._click_unique_address_target( back_targets, "PURCHASE_ADDRESS_BACK_AMBIGUOUS", @@ -1394,7 +1456,7 @@ class U2PddLivePurchaseAdapter(U2PddPurchaseAdapter, PddLivePurchaseAdapter): return root if observed_page == "panel": - targets = _clickable_text_targets(root, "修改") + targets = _address_modify_targets(root) candidate_count = len(targets) if candidate_count != 1: raise PddPurchaseError( diff --git a/client/test/test_pdd_u2_purchase_adapter.py b/client/test/test_pdd_u2_purchase_adapter.py index c0ecb13..7ba1bba 100644 --- a/client/test/test_pdd_u2_purchase_adapter.py +++ b/client/test/test_pdd_u2_purchase_adapter.py @@ -294,6 +294,56 @@ def address_panel_xml(address: str) -> str: """ +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""" + + + + + + + + + """) + return f""" + + + + + + + {''.join(cards)} + + + + """ + + def address_edit_xml(address: str) -> str: return f""" @@ -377,6 +427,75 @@ class AddressFlowDevice(FakeDevice): 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): """模拟保存后出现 ColorOS 密码本浮层。""" @@ -730,6 +849,77 @@ class U2PddPurchaseAdapterTest(unittest.TestCase): self.assertEqual(state.price_cent, 503) 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): device = HiddenSavedAddressDevice() adapter = self._live_adapter(device, [])