diff --git a/client/src/pdd_u2_purchase_adapter.py b/client/src/pdd_u2_purchase_adapter.py index 71018d6..c3ec4c9 100644 --- a/client/src/pdd_u2_purchase_adapter.py +++ b/client/src/pdd_u2_purchase_adapter.py @@ -106,6 +106,17 @@ def _labels(root: ET.Element) -> list[str]: return [value for node in root.iter("node") if (value := _label(node))] +def _package_hint_from_tree(root: ET.Element) -> str: + """可靠控件树可以直接证明 PDD 在前台,避免慢速前台查询。""" + + pdd_node_count = sum( + 1 + for node in root.iter("node") + if node.get("package") == PDD_PACKAGE_NAME + ) + return PDD_PACKAGE_NAME if pdd_node_count >= 3 else "" + + def _sanitize_diagnostic_xml(xml_data: str) -> str: """只保留页面判断所需语义,删除商品、账号和收货相关文字。""" @@ -400,30 +411,36 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter): device = self._require_device() self._check_cancelled("purchase_page_check") try: - current = device.app_current() - xml_data = self._dump_hierarchy() - root = _parse_xml(xml_data) - kind = _page_kind(root, str(current.get("package") or "")) - labels = _labels(root) - submit_targets = _final_submit_targets(root) - selected = { - key: value - for key, value in self._requested_options.items() - if _selected(root, value) - } - return PurchasePageState( - page_kind=kind, - goods_id=self._goods_id, - selected_options=selected, - quantity=_quantity(root), - price_cent=_price_cent(root), - candidate_count=1 if kind != "unknown" else 0, - in_stock=not any( - marker in " ".join(labels) - for marker in _OUT_OF_STOCK_MARKERS - ), - submit_candidate_count=len(submit_targets), - ) + trace = current_performance_trace() + stage = trace.stage("purchase_read_state") if trace else nullcontext() + with stage: + xml_data = self._dump_hierarchy() + root = _parse_xml(xml_data) + current_package = _package_hint_from_tree(root) + if not current_package: + current = device.app_current() + current_package = str(current.get("package") or "") + kind = _page_kind(root, current_package) + labels = _labels(root) + submit_targets = _final_submit_targets(root) + selected = { + key: value + for key, value in self._requested_options.items() + if _selected(root, value) + } + return PurchasePageState( + page_kind=kind, + goods_id=self._goods_id, + selected_options=selected, + quantity=_quantity(root), + price_cent=_price_cent(root), + candidate_count=1 if kind != "unknown" else 0, + in_stock=not any( + marker in " ".join(labels) + for marker in _OUT_OF_STOCK_MARKERS + ), + submit_candidate_count=len(submit_targets), + ) except PddPurchaseError: raise except Exception as exc: @@ -458,20 +475,35 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter): panel_xml = self._wait_for_confirmation_panel() color = checked.get("color") - if color and not self._select_color( - device, panel_xml, color, action_delay=0.2 - ): - raise PddPurchaseError( - "PURCHASE_OPTIONS_MISMATCH", - f"没有精确选中颜色:{color}", - step="purchase_select_options", + if color: + trace = current_performance_trace() + stage = ( + trace.stage("purchase_select_color") + if trace else nullcontext() ) + with stage: + color_selected = self._select_color( + device, panel_xml, color, action_delay=0.2 + ) + if not color_selected: + raise PddPurchaseError( + "PURCHASE_OPTIONS_MISMATCH", + f"没有精确选中颜色:{color}", + step="purchase_select_options", + ) size = checked.get("size") if size: latest_xml = self._dump_hierarchy() - if not self._select_size( - device, latest_xml, size, action_delay=0.2 - ): + trace = current_performance_trace() + stage = ( + trace.stage("purchase_select_size") + if trace else nullcontext() + ) + with stage: + size_selected = self._select_size( + device, latest_xml, size, action_delay=0.2 + ) + if not size_selected: raise PddPurchaseError( "PURCHASE_OPTIONS_MISMATCH", f"没有精确选中尺码:{size}", diff --git a/client/test/test_pdd_u2_purchase_adapter.py b/client/test/test_pdd_u2_purchase_adapter.py index 9ddf7f8..592a050 100644 --- a/client/test/test_pdd_u2_purchase_adapter.py +++ b/client/test/test_pdd_u2_purchase_adapter.py @@ -6,6 +6,7 @@ import unittest from src.pdd_device_service import PddDeviceService from src.pdd_purchase_adapter import PddPurchaseError +from src.performance_timing import TaskPerformanceTrace from src.pdd_u2_purchase_adapter import U2PddPurchaseAdapter from src.pdd_u2_purchase_adapter import U2PddLivePurchaseAdapter @@ -251,6 +252,54 @@ class U2PddPurchaseAdapterTest(unittest.TestCase): self.assertEqual(device.editor_values, []) self.assertEqual(device.app_wait_calls, 0) + def test_reliable_pdd_tree_skips_repeated_app_current(self): + device = FakeDevice() + adapter = self._adapter(device, []) + adapter.open_goods(GOODS_URL) + calls_after_open = device.app_current_calls + + state = adapter.read_state() + + self.assertEqual(state.page_kind, "goods") + self.assertEqual(device.app_current_calls, calls_after_open) + adapter.close() + + def test_ambiguous_tree_falls_back_to_app_current(self): + device = FakeDevice() + adapter = self._adapter(device, []) + adapter.open_goods(GOODS_URL) + calls_after_open = device.app_current_calls + device.mode = "special" + device.special_xml = ( + '' + ) + + adapter.read_state() + + self.assertEqual(device.app_current_calls, calls_after_open + 1) + adapter.close() + + def test_purchase_state_and_option_stages_are_recorded(self): + device = FakeDevice() + adapter = self._adapter(device, []) + records = [] + trace = TaskPerformanceTrace(sink=records.append) + trace.bind_task("PUR-PERFORMANCE") + + with trace.activate(): + adapter.open_goods(GOODS_URL) + adapter.read_state() + adapter.select_options( + {"color": "黑色", "size": "3XL【140-165斤】"} + ) + + operations = [record["operation"] for record in records] + self.assertIn("purchase_read_state", operations) + self.assertIn("purchase_select_color", operations) + self.assertIn("purchase_select_size", operations) + adapter.close() + def test_same_quantity_does_not_focus_editor(self): device = FakeDevice() adapter = self._adapter(device, []) @@ -517,11 +566,13 @@ class U2PddPurchaseAdapterTest(unittest.TestCase): adapter.set_quantity(2) state = adapter.read_state() + calls_before_submit = device.app_current_calls adapter.submit_order_once() with self.assertRaises(PddPurchaseError) as raised: adapter.submit_order_once() self.assertEqual(state.submit_candidate_count, 1) + self.assertEqual(device.app_current_calls, calls_before_submit + 1) self.assertEqual(raised.exception.code, "PURCHASE_SUBMIT_ALREADY_ATTEMPTED") # 依次点击采购入口、数量加号和最终提交。 self.assertEqual(len(device.clicks), 3) diff --git a/docs/client/06-quality-security.md b/docs/client/06-quality-security.md index 1c1957b..2fb16a9 100644 --- a/docs/client/06-quality-security.md +++ b/docs/client/06-quality-security.md @@ -108,6 +108,9 @@ Client 不保存或读取 `purchase.live_*` 手工授权设置。`purchase_mode` - 点击前必须确认包名、页面类型、目标语义和坐标边界。 - 高风险点击应在最新控件树上重新定位,不使用长时间缓存的坐标。 +- 采购普通状态读取先使用最新控件树确认 PDD 页面;控件树归属不明确时才查询 + 当前前台应用,避免在部分真机上重复等待十秒以上。最终下单点击前仍必须单独 + 查询前台应用。性能日志分别记录状态读取、颜色选择和尺码选择耗时。 - 规格选择后读取最新控件树并验证选中状态。 - 设置采购数量时先读取当前值;数量相同不聚焦输入框,小差值优先使用加减按钮。 只有输入框兜底路径确认输入法已经显示时才允许按一次返回键;输入法关闭后必须