diff --git a/client/src/pdd_u2_purchase_adapter.py b/client/src/pdd_u2_purchase_adapter.py index e55405d..7ea49b3 100644 --- a/client/src/pdd_u2_purchase_adapter.py +++ b/client/src/pdd_u2_purchase_adapter.py @@ -1410,19 +1410,9 @@ class U2PddLivePurchaseAdapter(U2PddPurchaseAdapter, PddLivePurchaseAdapter): step="purchase_update_shipping_address", ) editor.set_text(new_address) - self._sleep(0.2) - - latest_edit_root = _parse_xml(self._dump_hierarchy()) - latest_editor = _shipping_address_editor(latest_edit_root) - if latest_editor is None or latest_editor[0] != new_address: - raise PddPurchaseError( - "PURCHASE_ADDRESS_INPUT_MISMATCH", - "详细地址输入后回读不一致,未保存也未下单", - step="purchase_update_shipping_address", - ) - save_targets = _clickable_text_targets(latest_edit_root, "保存") + save_target = self._wait_for_stable_address_save(new_address) self._click_unique_address_target( - save_targets, + [save_target], "PURCHASE_ADDRESS_SAVE_AMBIGUOUS", "修改页没有唯一可靠的保存按钮", ) @@ -1552,6 +1542,90 @@ class U2PddLivePurchaseAdapter(U2PddPurchaseAdapter, PddLivePurchaseAdapter): diagnostics=diagnostics, ) + def _wait_for_stable_address_save(self, expected_address: str) -> Bounds: + """地址和唯一保存目标连续稳定后,返回最终复核过的点击位置。""" + + deadline = self._monotonic() + self._panel_timeout + stable_target: Optional[Bounds] = None + stable_reads = 0 + candidate_count = 0 + observed_page = "unknown" + address_matches = False + + while self._monotonic() < deadline: + self._check_cancelled("purchase_update_shipping_address") + root = _parse_xml(self._dump_hierarchy()) + observed_page = _address_page_kind(root) + editor = _shipping_address_editor(root) + address_matches = editor is not None and editor[0] == expected_address + save_targets = _clickable_text_targets(root, "保存") + candidate_count = len(save_targets) + + ready = ( + observed_page == "edit" + and address_matches + and candidate_count == 1 + ) + if ready and save_targets[0] == stable_target: + stable_reads += 1 + elif ready: + stable_target = save_targets[0] + stable_reads = 1 + else: + stable_target = None + stable_reads = 0 + + if stable_reads >= 2 and stable_target is not None: + # 给表单一次短暂提交准备时间,然后必须用最新控件树再验证。 + self._sleep(0.3) + latest_root = _parse_xml(self._dump_hierarchy()) + latest_editor = _shipping_address_editor(latest_root) + latest_targets = _clickable_text_targets(latest_root, "保存") + latest_ready = ( + _address_page_kind(latest_root) == "edit" + and latest_editor is not None + and latest_editor[0] == expected_address + and latest_targets == [stable_target] + ) + if latest_ready: + return stable_target + stable_target = None + stable_reads = 0 + observed_page = _address_page_kind(latest_root) + address_matches = ( + latest_editor is not None + and latest_editor[0] == expected_address + ) + candidate_count = len(latest_targets) + + self._sleep(0.2) + + diagnostics: dict[str, Any] = { + "expected_page": "edit_ready_to_save", + "observed_page": observed_page, + "address_matches": address_matches, + "save_candidate_count": candidate_count, + "stable_reads": stable_reads, + } + artifact = self._save_last_xml("purchase-address-save-not-ready") + if artifact is not None: + diagnostics["artifacts"] = [artifact] + input_mismatch = observed_page == "edit" and not address_matches + raise PddPurchaseError( + ( + "PURCHASE_ADDRESS_INPUT_MISMATCH" + if input_mismatch + else "PURCHASE_ADDRESS_SAVE_NOT_READY" + ), + ( + "详细地址输入后回读不一致,未保存也未下单" + if input_mismatch + else "地址编辑页尚未稳定,未点击保存也未下单" + ), + step="purchase_update_shipping_address", + diagnostics=diagnostics, + ) + def _wait_for_address_page( self, kind: str, *, expected_address: str = "" ) -> ET.Element: @@ -1641,9 +1715,18 @@ class U2PddLivePurchaseAdapter(U2PddPurchaseAdapter, PddLivePurchaseAdapter): artifact = self._save_last_xml("purchase-address-saved-timeout") if artifact is not None: diagnostics["artifacts"] = [artifact] + save_not_effective = observed_page == "edit" raise PddPurchaseError( - "PURCHASE_ADDRESS_PAGE_TIMEOUT", - "收货地址页面切换或保存确认超时,未进入不可逆下单阶段", + ( + "PURCHASE_ADDRESS_SAVE_NOT_EFFECTIVE" + if save_not_effective + else "PURCHASE_ADDRESS_PAGE_TIMEOUT" + ), + ( + "点击保存后仍停留在地址编辑页,未进入不可逆下单阶段" + if save_not_effective + else "收货地址页面切换或保存确认超时,未进入不可逆下单阶段" + ), step="purchase_update_shipping_address", diagnostics=diagnostics, ) diff --git a/client/test/test_pdd_u2_purchase_adapter.py b/client/test/test_pdd_u2_purchase_adapter.py index f655439..ed7426a 100644 --- a/client/test/test_pdd_u2_purchase_adapter.py +++ b/client/test/test_pdd_u2_purchase_adapter.py @@ -616,6 +616,85 @@ class HiddenSavedAddressDevice(AddressFlowDevice): return super().dump_hierarchy() +class DelayedAddressEditor(FakeAddressEditor): + """先记录输入,等待控件树刷新后才显示新地址。""" + + def set_text(self, value: str) -> None: + self.device.pending_address = value + + +class DelayedStableSaveTargetDevice(AddressFlowDevice): + """输入完成后保存目标先变化,随后才稳定。""" + + def __init__(self) -> None: + super().__init__() + self.save_form_reads = 0 + self.save_clicks = 0 + self.pending_address = None + + def dump_hierarchy(self): + if self.mode != "address_edit" or self.pending_address is None: + return super().dump_hierarchy() + self.save_form_reads += 1 + if self.save_form_reads >= 2: + self.address = self.pending_address + xml_data = super().dump_hierarchy() + if not self.address.endswith("_cg2"): + return xml_data + shift = min(self.save_form_reads, 3) * 10 + return xml_data.replace( + 'bounds="[120,1194][960,1329]"', + f'bounds="[{120 + shift},1194][{960 + shift},1329]"', + ) + + def click(self, x, y): + if self.mode == "address_edit": + self.save_clicks += 1 + super().click(x, y) + + def __call__(self, **selector): + if ( + selector.get("className") == "android.widget.EditText" + and "text" in selector + ): + return DelayedAddressEditor(self, selector["text"] == self.address) + return super().__call__(**selector) + + +class NeverStableSaveTargetDevice(DelayedStableSaveTargetDevice): + """保存目标在两个位置之间持续变化。""" + + def dump_hierarchy(self): + if self.mode != "address_edit" or self.pending_address is None: + return AddressFlowDevice.dump_hierarchy(self) + self.save_form_reads += 1 + if self.save_form_reads >= 2: + self.address = self.pending_address + xml_data = AddressFlowDevice.dump_hierarchy(self) + if not self.address.endswith("_cg2"): + return xml_data + shift = 10 if self.save_form_reads % 2 else 30 + return xml_data.replace( + 'bounds="[120,1194][960,1329]"', + f'bounds="[{120 + shift},1194][{960 + shift},1329]"', + ) + + +class LostSaveClickAddressDevice(AddressFlowDevice): + """保存按钮点击一次后页面仍停留在编辑页。""" + + def __init__(self) -> None: + super().__init__() + self.save_clicks = 0 + + def click(self, x, y): + if self.mode == "address_edit": + self.clicks.append((x, y)) + self.save_clicks += 1 + return + super().click(x, y) + + class LostFirstModifyClickAddressDevice(AddressFlowDevice): """模拟第一次“修改”点击被页面吞掉。""" @@ -1071,6 +1150,83 @@ class U2PddPurchaseAdapterTest(unittest.TestCase): self.assertEqual(device.presses, []) adapter.close() + def test_address_save_waits_for_stable_target_before_single_click(self): + clock = FakeClock() + device = DelayedStableSaveTargetDevice() + adapter = self._live_adapter( + device, + [], + sleeper=clock.sleep, + monotonic=clock.monotonic, + ) + adapter.open_goods(GOODS_URL) + adapter.select_options( + {"color": "黑色", "size": "3XL【140-165斤】"} + ) + + adapter.update_shipping_address("cg2") + + self.assertGreaterEqual(device.save_form_reads, 5) + self.assertEqual(device.save_clicks, 1) + self.assertEqual(device.mode, "panel") + self.assertGreaterEqual(clock.now, 0.7) + adapter.close() + + def test_unstable_address_save_target_is_never_clicked(self): + clock = FakeClock() + device = NeverStableSaveTargetDevice() + expected_address = "测试省测试市测试区测试路1号_cg2" + 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_SAVE_NOT_READY", + ) + self.assertEqual(device.save_clicks, 0) + self.assertNotIn(expected_address, repr(raised.exception.diagnostics)) + adapter.close() + + def test_lost_save_click_reports_not_effective_without_retry(self): + clock = FakeClock() + device = LostSaveClickAddressDevice() + expected_address = "测试省测试市测试区测试路1号_cg2" + 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_SAVE_NOT_EFFECTIVE", + ) + self.assertEqual(raised.exception.diagnostics["observed_page"], "edit") + self.assertEqual(device.save_clicks, 1) + self.assertNotIn(expected_address, repr(raised.exception.diagnostics)) + adapter.close() + def test_coloros_overlay_can_disappear_without_system_back(self): device = AddressSaveOverlayDevice(clear_after_reads=2) adapter = self._live_adapter(device, [])