From b182effa7e8f46edcc43110dab19dae7f7b31a7e Mon Sep 17 00:00:00 2001 From: chengma Date: Mon, 10 Aug 2026 18:52:36 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=AE=89=E5=85=A8=E5=A4=84=E7=90=86?= =?UTF-8?q?=E9=87=87=E8=B4=AD=E6=95=B0=E9=87=8F=E8=BE=93=E5=85=A5=E6=B3=95?= =?UTF-8?q?=20(#117)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/pdd_u2_purchase_adapter.py | 197 +++++++++++++++++++- client/test/test_pdd_u2_purchase_adapter.py | 135 +++++++++++++- docs/client/06-quality-security.md | 3 + 3 files changed, 321 insertions(+), 14 deletions(-) diff --git a/client/src/pdd_u2_purchase_adapter.py b/client/src/pdd_u2_purchase_adapter.py index 42964ac..71018d6 100644 --- a/client/src/pdd_u2_purchase_adapter.py +++ b/client/src/pdd_u2_purchase_adapter.py @@ -61,6 +61,13 @@ _PAYMENT_MARKERS = ("输入支付密码", "立即支付", "支付成功", "支 _FINAL_SUBMIT_MARKERS = ("提交订单", "现在买,仅", "确认购买") _OUT_OF_STOCK_MARKERS = ("已售罄", "暂时缺货", "库存不足", "该商品已售罄") _SUPPORTED_OPTION_KEYS = frozenset({"color", "size"}) +_MAX_QUANTITY_BUTTON_CLICKS = 5 +_KEYBOARD_STATUS_MARKERS = ( + "minputshown", + "misinputviewshown", + "minputviewshown", + "inputshown", +) _DIAGNOSTIC_MARKERS = ( "增加数量", "减少数量", @@ -196,6 +203,52 @@ def _quantity(root: ET.Element) -> int: return values[0] if len(values) == 1 else 0 +def _quantity_button_targets(root: ET.Element, description: str) -> list[Bounds]: + """返回唯一、可点击的数量加减按钮坐标候选。""" + + targets = set() + for node in root.iter("node"): + labels = { + node.get("text", "").strip(), + node.get("content-desc", "").strip(), + } + bounds = _parse_bounds(node.get("bounds", "")) + if description not in labels or bounds is None: + continue + if node.get("clickable") != "true": + continue + if node.get("enabled") == "false" or node.get("visible-to-user") == "false": + continue + targets.add(bounds) + return sorted(targets) + + +def _shell_output(response: Any) -> str: + """兼容 uiautomator2 ShellResponse 和测试中的普通字符串。""" + + output = getattr(response, "output", response) + if isinstance(output, bytes): + return output.decode("utf-8", errors="replace") + return str(output or "") + + +def _keyboard_is_shown(device: Any) -> Optional[bool]: + """读取安卓输入法状态;无法可靠判断时返回 None。""" + + try: + response = device.shell("dumpsys input_method") + except (AttributeError, OSError, RuntimeError): + return None + normalized = re.sub(r"\s+", "", _shell_output(response)).lower() + found_false = False + for marker in _KEYBOARD_STATUS_MARKERS: + if f"{marker}=true" in normalized: + return True + if f"{marker}=false" in normalized: + found_false = True + return False if found_false else None + + def _price_cent(root: ET.Element) -> int: """优先读取页面上半部的当前单价,不把划线价当成当前价。""" @@ -439,26 +492,150 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter): ) self._check_cancelled("purchase_set_quantity") try: - editor = device(className="android.widget.EditText") - if int(getattr(editor, "count", 0)) != 1: + root = _parse_xml(self._dump_hierarchy()) + current_quantity = _quantity(root) + if current_quantity <= 0: raise PddPurchaseError( "PURCHASE_QUANTITY_CONTROL_MISSING", "页面没有唯一可靠的采购数量输入框", step="purchase_set_quantity", ) - editor.set_text(str(quantity)) - self._sleep(0.2) - if _quantity(_parse_xml(self._dump_hierarchy())) != quantity: - raise PddPurchaseError( - "PURCHASE_QUANTITY_MISMATCH", - "PDD 页面没有保存目标采购数量", - step="purchase_set_quantity", - ) + if current_quantity == quantity: + return + + difference = quantity - current_quantity + if abs(difference) <= _MAX_QUANTITY_BUTTON_CLICKS: + description = "增加数量" if difference > 0 else "减少数量" + targets = _quantity_button_targets(root, description) + if len(targets) == 1: + self._set_quantity_with_buttons( + current_quantity, quantity, description + ) + return + + self._set_quantity_with_editor(quantity) except PddPurchaseError: raise except Exception as exc: self._raise_device_or_page_error(exc, "purchase_set_quantity") + def _set_quantity_with_buttons( + self, current_quantity: int, target_quantity: int, description: str + ) -> None: + """逐次点击加减按钮,每次都用最新控件树确认数量。""" + + device = self._require_device() + step = 1 if target_quantity > current_quantity else -1 + expected = current_quantity + while expected != target_quantity: + self._check_cancelled("purchase_set_quantity") + root = _parse_xml(self._dump_hierarchy()) + targets = _quantity_button_targets(root, description) + if len(targets) != 1: + raise PddPurchaseError( + "PURCHASE_QUANTITY_CONTROL_MISSING", + f"页面没有唯一可靠的“{description}”按钮", + step="purchase_set_quantity", + ) + left, top, right, bottom = targets[0] + device.click((left + right) // 2, (top + bottom) // 2) + expected += step + self._sleep(0.2) + actual = _quantity(_parse_xml(self._dump_hierarchy())) + if actual != expected: + raise PddPurchaseError( + "PURCHASE_QUANTITY_MISMATCH", + f"调整采购数量后期望为 {expected},页面实际为 {actual or '未知'}", + step="purchase_set_quantity", + ) + + def _set_quantity_with_editor(self, quantity: int) -> None: + """输入框兜底;只在确认输入法显示后按一次返回键。""" + + device = self._require_device() + editor = device(className="android.widget.EditText") + if int(getattr(editor, "count", 0)) != 1: + raise PddPurchaseError( + "PURCHASE_QUANTITY_CONTROL_MISSING", + "页面没有唯一可靠的采购数量输入框", + step="purchase_set_quantity", + ) + editor.set_text(str(quantity)) + self._sleep(0.2) + if _quantity(_parse_xml(self._dump_hierarchy())) != quantity: + raise PddPurchaseError( + "PURCHASE_QUANTITY_MISMATCH", + "PDD 页面没有保存目标采购数量", + step="purchase_set_quantity", + ) + + keyboard_shown = _keyboard_is_shown(device) + if keyboard_shown is None: + raise PddPurchaseError( + "PURCHASE_KEYBOARD_DISMISS_FAILED", + "无法确认安卓输入法状态,已停止采购以避免误按返回键", + step="purchase_set_quantity", + retryable=True, + ) + if keyboard_shown: + device.press("back") + for _attempt in range(25): + self._check_cancelled("purchase_set_quantity") + self._sleep(0.2) + keyboard_shown = _keyboard_is_shown(device) + if keyboard_shown is False: + break + if keyboard_shown is not False: + raise PddPurchaseError( + "PURCHASE_KEYBOARD_DISMISS_FAILED", + "安卓输入法没有在规定时间内关闭,已停止采购", + step="purchase_set_quantity", + retryable=True, + ) + + self._validate_panel_after_keyboard(quantity) + + def _validate_panel_after_keyboard(self, quantity: int) -> None: + """输入框操作后重新确认规格面板和最终提交前状态。""" + + root = _parse_xml(self._dump_hierarchy()) + if _page_kind(root, "") != "order_confirmation": + raise PddPurchaseError( + "PURCHASE_PANEL_LOST_AFTER_KEYBOARD", + "关闭输入法后规格面板已经消失,已停止采购", + step="purchase_set_quantity", + retryable=True, + ) + if _quantity(root) != quantity: + raise PddPurchaseError( + "PURCHASE_QUANTITY_MISMATCH", + "关闭输入法后采购数量发生变化", + step="purchase_set_quantity", + ) + missing_options = [ + value + for value in self._requested_options.values() + if not _selected(root, value) + ] + if missing_options: + raise PddPurchaseError( + "PURCHASE_OPTIONS_MISMATCH", + "关闭输入法后已选颜色或尺码发生变化", + step="purchase_set_quantity", + ) + if _price_cent(root) <= 0: + raise PddPurchaseError( + "PURCHASE_PRICE_MISSING", + "关闭输入法后没有识别到有效价格", + step="purchase_set_quantity", + ) + if len(_final_submit_targets(root)) != 1: + raise PddPurchaseError( + "PURCHASE_SUBMIT_TARGET_AMBIGUOUS", + "关闭输入法后没有唯一可靠的下单按钮", + step="purchase_set_quantity", + ) + def enter_confirmation(self) -> None: """只确认已到最终提交前页面,不点击底部提交按钮。""" diff --git a/client/test/test_pdd_u2_purchase_adapter.py b/client/test/test_pdd_u2_purchase_adapter.py index fe773f4..9ddf7f8 100644 --- a/client/test/test_pdd_u2_purchase_adapter.py +++ b/client/test/test_pdd_u2_purchase_adapter.py @@ -65,7 +65,10 @@ class FakeEditor: self.count = 1 def set_text(self, value: str) -> None: + self.device.editor_values.append(value) self.device.quantity = int(value) + if self.device.show_keyboard_on_edit: + self.device.keyboard_shown = True class FakeDevice: @@ -78,6 +81,13 @@ class FakeDevice: self.app_wait_calls = 0 self.app_current_calls = 0 self.has_opened = False + self.editor_values = [] + self.show_keyboard_on_edit = True + self.keyboard_shown = False + self.keyboard_status_known = True + self.keyboard_can_dismiss = True + self.close_panel_on_back = False + self.presses = [] def app_current(self): self.app_current_calls += 1 @@ -103,8 +113,28 @@ class FakeDevice: def click(self, x, y): self.clicks.append((x, y)) + if self.mode == "panel" and 570 <= x <= 650 and 700 <= y <= 780: + self.quantity += 1 + return + if self.mode == "panel" and 390 <= x <= 470 and 700 <= y <= 780: + self.quantity -= 1 + return self.mode = "panel" + def shell(self, command): + self.asserted_shell_command = command + if not self.keyboard_status_known: + return "input method state unavailable" + value = "true" if self.keyboard_shown else "false" + return f"mInputShown={value} mIsInputViewShown={value}" + + def press(self, key): + self.presses.append(key) + if key == "back" and self.keyboard_can_dismiss: + self.keyboard_shown = False + if self.close_panel_on_back: + self.mode = "home" + def __call__(self, **selector): if selector == {"className": "android.widget.EditText"}: return FakeEditor(self) @@ -216,10 +246,107 @@ class U2PddPurchaseAdapterTest(unittest.TestCase): self.assertEqual(confirmation.price_cent, 503) self.assertEqual(confirmation.page_kind, "order_confirmation") self.assertEqual(calls, [("color", "黑色"), ("size", "3XL【140-165斤】")]) - # 只点击一次商品页采购入口;最终“提交订单”从不点击。 - self.assertEqual(len(device.clicks), 1) + # 点击一次商品页采购入口和一次加号;最终“提交订单”从不点击。 + self.assertEqual(len(device.clicks), 2) + self.assertEqual(device.editor_values, []) self.assertEqual(device.app_wait_calls, 0) + def test_same_quantity_does_not_focus_editor(self): + device = FakeDevice() + adapter = self._adapter(device, []) + adapter.open_goods(GOODS_URL) + adapter.select_options({"color": "黑色", "size": "3XL【140-165斤】"}) + + adapter.set_quantity(1) + + self.assertEqual(device.editor_values, []) + self.assertEqual(device.presses, []) + self.assertEqual(len(device.clicks), 1) + adapter.close() + + def test_small_quantity_difference_uses_buttons(self): + device = FakeDevice() + adapter = self._adapter(device, []) + adapter.open_goods(GOODS_URL) + adapter.select_options({"color": "黑色", "size": "3XL【140-165斤】"}) + + adapter.set_quantity(4) + + self.assertEqual(device.quantity, 4) + self.assertEqual(device.editor_values, []) + self.assertEqual(device.presses, []) + # 采购入口一次,加号三次。 + self.assertEqual(len(device.clicks), 4) + adapter.close() + + def test_large_quantity_difference_closes_visible_keyboard_once(self): + device = FakeDevice() + adapter = self._adapter(device, []) + adapter.open_goods(GOODS_URL) + adapter.select_options({"color": "黑色", "size": "3XL【140-165斤】"}) + + adapter.set_quantity(7) + + self.assertEqual(device.editor_values, ["7"]) + self.assertEqual(device.presses, ["back"]) + self.assertFalse(device.keyboard_shown) + adapter.close() + + def test_editor_does_not_press_back_when_keyboard_is_already_hidden(self): + device = FakeDevice() + device.show_keyboard_on_edit = False + adapter = self._adapter(device, []) + adapter.open_goods(GOODS_URL) + adapter.select_options({"color": "黑色", "size": "3XL【140-165斤】"}) + + adapter.set_quantity(7) + + self.assertEqual(device.editor_values, ["7"]) + self.assertEqual(device.presses, []) + adapter.close() + + def test_editor_stops_when_keyboard_cannot_be_closed(self): + device = FakeDevice() + device.keyboard_can_dismiss = False + adapter = self._adapter(device, []) + adapter.open_goods(GOODS_URL) + adapter.select_options({"color": "黑色", "size": "3XL【140-165斤】"}) + + with self.assertRaises(PddPurchaseError) as raised: + adapter.set_quantity(7) + + self.assertEqual(raised.exception.code, "PURCHASE_KEYBOARD_DISMISS_FAILED") + self.assertEqual(device.presses, ["back"]) + adapter.close() + + def test_editor_does_not_press_back_when_keyboard_state_is_unknown(self): + device = FakeDevice() + device.keyboard_status_known = False + adapter = self._adapter(device, []) + adapter.open_goods(GOODS_URL) + adapter.select_options({"color": "黑色", "size": "3XL【140-165斤】"}) + + with self.assertRaises(PddPurchaseError) as raised: + adapter.set_quantity(7) + + self.assertEqual(raised.exception.code, "PURCHASE_KEYBOARD_DISMISS_FAILED") + self.assertEqual(device.presses, []) + adapter.close() + + def test_editor_stops_when_back_closes_specification_panel(self): + device = FakeDevice() + device.close_panel_on_back = True + adapter = self._adapter(device, []) + adapter.open_goods(GOODS_URL) + adapter.select_options({"color": "黑色", "size": "3XL【140-165斤】"}) + + with self.assertRaises(PddPurchaseError) as raised: + adapter.set_quantity(7) + + self.assertEqual(raised.exception.code, "PURCHASE_PANEL_LOST_AFTER_KEYBOARD") + self.assertEqual(device.presses, ["back"]) + adapter.close() + def test_unsupported_dynamic_option_stops_before_click(self): device = FakeDevice() adapter = self._adapter(device, []) @@ -396,8 +523,8 @@ class U2PddPurchaseAdapterTest(unittest.TestCase): self.assertEqual(state.submit_candidate_count, 1) self.assertEqual(raised.exception.code, "PURCHASE_SUBMIT_ALREADY_ATTEMPTED") - # 第一次点击采购入口,第二次且仅一次点击最终提交。 - self.assertEqual(len(device.clicks), 2) + # 依次点击采购入口、数量加号和最终提交。 + self.assertEqual(len(device.clicks), 3) adapter.close() def test_live_adapter_rejects_multiple_submit_targets_without_click(self): diff --git a/docs/client/06-quality-security.md b/docs/client/06-quality-security.md index c4d735d..2e36c87 100644 --- a/docs/client/06-quality-security.md +++ b/docs/client/06-quality-security.md @@ -106,6 +106,9 @@ Client 不保存或读取 `purchase.live_*` 手工授权设置。`purchase_mode` - 点击前必须确认包名、页面类型、目标语义和坐标边界。 - 高风险点击应在最新控件树上重新定位,不使用长时间缓存的坐标。 - 规格选择后读取最新控件树并验证选中状态。 +- 设置采购数量时先读取当前值;数量相同不聚焦输入框,小差值优先使用加减按钮。 + 只有输入框兜底路径确认输入法已经显示时才允许按一次返回键;输入法关闭后必须 + 重新核对规格、数量、价格和唯一提交目标,规格面板丢失时立即停止。 - 最终下单前重新读取实际价格、数量和规格。 - 页面出现验证码、登录、支付、权限请求或未知模态层时停止并转人工处理。 - uiautomator2 连接由单一工作线程独占,任务间清理临时状态。