diff --git a/client/src/pdd_u2_purchase_adapter.py b/client/src/pdd_u2_purchase_adapter.py index 307bc28..8d2f1c8 100644 --- a/client/src/pdd_u2_purchase_adapter.py +++ b/client/src/pdd_u2_purchase_adapter.py @@ -60,10 +60,8 @@ Bounds = tuple[int, int, int, int] _BOUNDS_PATTERN = re.compile(r"^\[(\d+),(\d+)\]\[(\d+),(\d+)\]$") _PRICE_PATTERN = re.compile(r"[¥¥]\s*(\d+(?:\.\d{1,2})?)") _MASKED_PHONE_PATTERN = re.compile(r"(? str: return "unknown" +def _is_reliable_address_panel( + root: ET.Element, expected_address: str = "" +) -> bool: + """用标题和唯一导航目标识别地址面板,地址全文只作增强证据。""" + + labels = _labels(root) + if "收货地址" not in labels: + return False + if len(_clickable_text_targets(root, "修改")) != 1: + return False + if len(_clickable_text_targets(root, "返回")) != 1: + return False + if expected_address and any(expected_address in label for label in labels): + return True + return True + + +def _has_coloros_password_overlay(root: ET.Element) -> bool: + """只识别已知 ColorOS 密码本的可见焦点浮层。""" + + return any( + node.get("package") == _COLOROS_PASSWORD_PACKAGE + and node.get("visible-to-user", "true") != "false" + and node.get("focused", "false") == "true" + for node in root.iter("node") + ) + + def _tagged_shipping_address(address: str, purchase_number: str) -> str: - """只替换程序生成的末尾标记,保留完整真实地址主体。""" + """按首个连字符或下划线截取地址主体,再追加采购编号。""" checked_address = str(address or "").strip() checked_number = str(purchase_number or "").strip() @@ -269,7 +295,9 @@ def _tagged_shipping_address(address: str, purchase_number: str) -> str: raise ValueError("地址和采购编号不能为空") if any(character in checked_number for character in "\r\n\t"): raise ValueError("采购编号不能包含控制字符") - address_body = _PROGRAM_ADDRESS_SUFFIX_PATTERN.sub("", checked_address) + address_body = _ADDRESS_SEPARATOR_PATTERN.split( + checked_address, maxsplit=1 + )[0].strip() if not address_body: raise ValueError("地址主体不能为空") return f"{address_body}_{checked_number}" @@ -1309,9 +1337,9 @@ class U2PddLivePurchaseAdapter(U2PddPurchaseAdapter, PddLivePurchaseAdapter): "修改页没有唯一可靠的保存按钮", ) - saved_root = self._wait_for_address_page( - "saved", expected_address=new_address - ) + saved_root, saved_page = self._wait_after_address_save(new_address) + if saved_page == "confirmation": + return back_targets = _clickable_text_targets(saved_root, "返回") self._click_unique_address_target( back_targets, @@ -1472,6 +1500,56 @@ class U2PddLivePurchaseAdapter(U2PddPurchaseAdapter, PddLivePurchaseAdapter): diagnostics=diagnostics, ) + def _wait_after_address_save( + self, expected_address: str + ) -> tuple[ET.Element, str]: + """等待保存后的地址面板;已知密码本浮层最多按一次返回。""" + + deadline = self._monotonic() + self._panel_timeout + observed_page = "unknown" + overlay_seen = False + overlay_stable_reads = 0 + system_back_attempts = 0 + + while self._monotonic() < deadline: + self._check_cancelled("purchase_update_shipping_address") + root = _parse_xml(self._dump_hierarchy()) + if _has_coloros_password_overlay(root): + observed_page = "coloros_password_overlay" + overlay_seen = True + overlay_stable_reads += 1 + if overlay_stable_reads >= 3 and system_back_attempts == 0: + # 只关闭已知系统浮层;保存按钮和 PDD 页面都不重试点击。 + self._require_device().press("back") + system_back_attempts = 1 + overlay_stable_reads = 0 + self._sleep(0.2) + continue + + overlay_stable_reads = 0 + if _is_reliable_address_panel(root, expected_address): + return root, "saved" + if _page_kind(root, "") == "order_confirmation": + return root, "confirmation" + observed_page = _address_page_kind(root) + self._sleep(0.2) + + diagnostics: dict[str, Any] = { + "expected_page": "saved", + "observed_page": observed_page, + "coloros_password_overlay_seen": overlay_seen, + "system_back_attempts": system_back_attempts, + } + artifact = self._save_last_xml("purchase-address-saved-timeout") + if artifact is not None: + diagnostics["artifacts"] = [artifact] + raise PddPurchaseError( + "PURCHASE_ADDRESS_PAGE_TIMEOUT", + "收货地址页面切换或保存确认超时,未进入不可逆下单阶段", + step="purchase_update_shipping_address", + diagnostics=diagnostics, + ) + def submit_order_once(self) -> None: if self._submit_attempted: raise PddPurchaseError( diff --git a/client/test/test_pdd_u2_purchase_adapter.py b/client/test/test_pdd_u2_purchase_adapter.py index d990f78..c0ecb13 100644 --- a/client/test/test_pdd_u2_purchase_adapter.py +++ b/client/test/test_pdd_u2_purchase_adapter.py @@ -314,6 +314,20 @@ def address_edit_xml(address: str) -> str: """ +def coloros_password_overlay_xml() -> str: + return """ + + + + + """ + + class FakeAddressEditor: def __init__(self, device, matched: bool) -> None: self.device = device @@ -363,6 +377,82 @@ class AddressFlowDevice(FakeDevice): return super().__call__(**selector) +class AddressSaveOverlayDevice(AddressFlowDevice): + """模拟保存后出现 ColorOS 密码本浮层。""" + + def __init__(self, clear_after_reads=None) -> None: + super().__init__() + self.clear_after_reads = clear_after_reads + self.overlay_reads = 0 + self.save_clicks = 0 + + def dump_hierarchy(self): + if self.mode == "address_overlay": + self.overlay_reads += 1 + if ( + self.clear_after_reads is not None + and self.overlay_reads > self.clear_after_reads + ): + self.mode = "address_saved" + return super().dump_hierarchy() + return coloros_password_overlay_xml() + return super().dump_hierarchy() + + def click(self, x, y): + if self.mode == "address_edit": + self.clicks.append((x, y)) + self.save_clicks += 1 + self.mode = "address_overlay" + return + super().click(x, y) + + def press(self, key): + self.presses.append(key) + if self.mode == "address_overlay" and key == "back": + self.mode = "address_saved" + + +class PersistentAddressSaveOverlayDevice(AddressSaveOverlayDevice): + """模拟系统返回键也没有关闭密码本浮层。""" + + def press(self, key): + self.presses.append(key) + + +class UnknownAfterAddressSaveDevice(AddressFlowDevice): + """模拟保存后进入非白名单未知浮层。""" + + def __init__(self) -> None: + super().__init__() + self.save_clicks = 0 + + def dump_hierarchy(self): + if self.mode == "address_save_unknown": + return """ + + """ + return super().dump_hierarchy() + + def click(self, x, y): + if self.mode == "address_edit": + self.clicks.append((x, y)) + self.save_clicks += 1 + self.mode = "address_save_unknown" + return + super().click(x, y) + + +class HiddenSavedAddressDevice(AddressFlowDevice): + """模拟地址面板不在控件树中暴露完整地址。""" + + def dump_hierarchy(self): + if self.mode == "address_saved": + return address_panel_xml("") + return super().dump_hierarchy() + + class LostFirstModifyClickAddressDevice(AddressFlowDevice): """模拟第一次“修改”点击被页面吞掉。""" @@ -593,7 +683,7 @@ class U2PddPurchaseAdapterTest(unittest.TestCase): self.assertEqual(device.clicks[-1], (540, 2140)) adapter.close() - def test_program_address_suffix_is_replaced_without_cutting_body(self): + def test_address_is_split_at_first_separator_before_tagging(self): self.assertEqual( _tagged_shipping_address( "测试省测试市测试区测试路1号-1dd212", "cg2" @@ -610,7 +700,13 @@ class U2PddPurchaseAdapterTest(unittest.TestCase): _tagged_shipping_address( "测试省-开发区测试路1号", "cg4" ), - "测试省-开发区测试路1号_cg4", + "测试省_cg4", + ) + self.assertEqual( + _tagged_shipping_address( + "测试省测试市测试路1号_旧采购编号", "cg4" + ), + "测试省测试市测试路1号_cg4", ) def test_live_adapter_updates_address_and_returns_to_confirmation(self): @@ -634,6 +730,105 @@ class U2PddPurchaseAdapterTest(unittest.TestCase): self.assertEqual(state.price_cent, 503) adapter.close() + def test_saved_address_panel_can_hide_full_address(self): + device = HiddenSavedAddressDevice() + adapter = self._live_adapter(device, []) + adapter.open_goods(GOODS_URL) + adapter.select_options( + {"color": "黑色", "size": "3XL【140-165斤】"} + ) + + adapter.update_shipping_address("cg2") + + self.assertEqual(device.mode, "panel") + self.assertEqual(device.presses, []) + adapter.close() + + def test_coloros_overlay_can_disappear_without_system_back(self): + device = AddressSaveOverlayDevice(clear_after_reads=2) + adapter = self._live_adapter(device, []) + adapter.open_goods(GOODS_URL) + adapter.select_options( + {"color": "黑色", "size": "3XL【140-165斤】"} + ) + + adapter.update_shipping_address("cg2") + + self.assertEqual(device.save_clicks, 1) + self.assertEqual(device.presses, []) + self.assertEqual(device.mode, "panel") + adapter.close() + + def test_persistent_coloros_overlay_is_dismissed_with_one_system_back(self): + device = AddressSaveOverlayDevice() + adapter = self._live_adapter(device, []) + adapter.open_goods(GOODS_URL) + adapter.select_options( + {"color": "黑色", "size": "3XL【140-165斤】"} + ) + + adapter.update_shipping_address("cg2") + + self.assertEqual(device.save_clicks, 1) + self.assertEqual(device.presses, ["back"]) + self.assertEqual(device.mode, "panel") + adapter.close() + + def test_coloros_overlay_is_never_dismissed_more_than_once(self): + clock = FakeClock() + device = PersistentAddressSaveOverlayDevice() + adapter = self._live_adapter( + device, + [], + sleeper=clock.sleep, + monotonic=clock.monotonic, + panel_timeout=1.0, + ) + adapter.open_goods(GOODS_URL) + adapter.select_options( + {"color": "黑色", "size": "3XL【140-165斤】"} + ) + + with self.assertRaises(PddPurchaseError) as raised: + adapter.update_shipping_address("cg2") + + self.assertEqual(raised.exception.code, "PURCHASE_ADDRESS_PAGE_TIMEOUT") + self.assertTrue( + raised.exception.diagnostics["coloros_password_overlay_seen"] + ) + self.assertEqual(raised.exception.diagnostics["system_back_attempts"], 1) + self.assertEqual(device.save_clicks, 1) + self.assertEqual(device.presses, ["back"]) + adapter.close() + + def test_unknown_page_after_save_does_not_press_back_or_click_again(self): + clock = FakeClock() + device = UnknownAfterAddressSaveDevice() + adapter = self._live_adapter( + device, + [], + sleeper=clock.sleep, + monotonic=clock.monotonic, + panel_timeout=1.0, + ) + adapter.open_goods(GOODS_URL) + adapter.select_options( + {"color": "黑色", "size": "3XL【140-165斤】"} + ) + + with self.assertRaises(PddPurchaseError) as raised: + adapter.update_shipping_address("cg2") + + self.assertEqual(raised.exception.code, "PURCHASE_ADDRESS_PAGE_TIMEOUT") + self.assertEqual(raised.exception.diagnostics["observed_page"], "unknown") + self.assertFalse( + raised.exception.diagnostics["coloros_password_overlay_seen"] + ) + self.assertEqual(raised.exception.diagnostics["system_back_attempts"], 0) + self.assertEqual(device.save_clicks, 1) + self.assertEqual(device.presses, []) + adapter.close() + def test_address_edit_retries_once_when_first_modify_click_is_lost(self): device = LostFirstModifyClickAddressDevice() adapter = self._live_adapter(device, [])