diff --git a/client/src/pdd_u2_purchase_adapter.py b/client/src/pdd_u2_purchase_adapter.py index 3e4c1bc..b613727 100644 --- a/client/src/pdd_u2_purchase_adapter.py +++ b/client/src/pdd_u2_purchase_adapter.py @@ -49,7 +49,11 @@ from .pdd_purchase_adapter import ( PurchasePageState, ) from .util.get_size_panle_coord import get_size_panel_coord -from .util.select_color_size import select_color, select_size +from .util.select_color_size import ( + color_selection_failure_reason, + select_color, + select_size, +) Bounds = tuple[int, int, int, int] @@ -734,10 +738,32 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter): device, panel_xml, color, action_delay=0.2 ) if not color_selected: + failed_xml = self._dump_hierarchy() + failure_reason = color_selection_failure_reason( + failed_xml, color + ) + diagnostics: dict[str, Any] = { + "selection_failure": failure_reason, + } + artifact = self._save_last_xml( + "purchase-color-selection-mismatch" + ) + if artifact is not None: + diagnostics["artifacts"] = [artifact] + messages = { + "target_not_visible": f"没有找到目标颜色:{color}", + "safe_target_missing": ( + f"目标颜色没有完整可见的安全点击位置:{color}" + ), + "selection_unconfirmed": ( + f"点击颜色后页面没有确认已选中:{color}" + ), + } raise PddPurchaseError( "PURCHASE_OPTIONS_MISMATCH", - f"没有精确选中颜色:{color}", + messages[failure_reason], step="purchase_select_options", + diagnostics=diagnostics, ) size = checked.get("size") if size: diff --git a/client/src/util/select_color_size.py b/client/src/util/select_color_size.py index b5e9864..9d0daf9 100644 --- a/client/src/util/select_color_size.py +++ b/client/src/util/select_color_size.py @@ -173,9 +173,16 @@ def _horizontal_color_region( if candidates: return max(candidates, key=lambda item: item[0])[1] - # 部分自绘横向列表不会暴露 scrollable,使用颜色标题下方区域兜底。 + # 部分自绘列表不会暴露 scrollable。目标已经出现时,用完整卡片和 + # 下一规格标题确定纵向边界,避免固定高度截断较高的图片卡片。 if heading is None or screen is None: return None + if target: + target_region = _non_scrollable_target_region( + root, target, heading, screen + ) + if target_region is not None: + return target_region top = heading[3] bottom = min(screen[3], top + max(160, int(screen[3] * 0.14))) @@ -184,6 +191,51 @@ def _horizontal_color_region( return int(screen[2] * 0.04), top, int(screen[2] * 0.96), bottom +def _non_scrollable_target_region( + root: ET.Element, + target: str, + heading: Bounds, + screen: Bounds, +) -> Optional[Bounds]: + """用目标卡片和下一规格标题界定非滚动颜色区域。""" + + parents = _parent_map(root) + target_bounds = [ + bounds + for node in root.iter("node") + if _is_available(node) and _matches_target(node, target) + if (bounds := _nearest_click_bounds(node, parents, screen)) is not None + if bounds[1] >= heading[3] + ] + if not target_bounds: + return None + + card = max( + target_bounds, + key=lambda item: (item[2] - item[0]) * (item[3] - item[1]), + ) + next_heading_tops = [] + for node in root.iter("node"): + text = re.sub( + r"[((]\s*\d+\s*[))]\s*$", + "", + node.get("text", "").replace(" ", ""), + ) + bounds = _parse_bounds(node.get("bounds", "")) + if ( + text in ("尺码", "套餐") + and node.get("clickable") != "true" + and bounds is not None + and bounds[1] > heading[3] + ): + next_heading_tops.append(bounds[1]) + + bottom = min(next_heading_tops) if next_heading_tops else card[3] + if card[3] > bottom or bottom <= heading[3]: + return None + return screen[0], heading[3], screen[2], bottom + + def _vertical_panel_region(root: ET.Element) -> Optional[Bounds]: heading = _heading_bounds(root) vertical: list[tuple[float, Bounds]] = [] @@ -327,6 +379,28 @@ def _target_is_selected(root: ET.Element, target: str) -> bool: return False +def color_selection_failure_reason(xml_data: XmlData, target: str) -> str: + """说明颜色选择失败阶段,供采购 Adapter 生成准确诊断。""" + + root = _parse_xml(xml_data) + matching_nodes = [ + node + for node in root.iter("node") + if _is_available(node) and _matches_target(node, target) + ] + if not matching_nodes: + return "target_not_visible" + region = _horizontal_color_region(root, target) + if region is None or _target_click_bounds( + root, + target, + region, + require_horizontal_safe=True, + ) is None: + return "safe_target_missing" + return "selection_unconfirmed" + + def _visible_signature( root: ET.Element, region: Bounds, @@ -439,6 +513,10 @@ def select_color( raise ValueError("target_color 不能为空") root = _parse_xml(xml_data) + target_is_static = ( + _target_scrollable_ancestor(root, target_color) is None + and any(_matches_target(node, target_color) for node in root.iter("node")) + ) region = _horizontal_color_region(root, target_color) if region is None: return False @@ -453,6 +531,10 @@ def select_color( ) if selected: return True + if target_is_static: + # 静态面板中目标已经出现,点击失败后继续横向滑动只会增加等待, + # 还可能触碰其他控件;必须立即交给上层报告未确认。 + return False region = _horizontal_color_region(root, target_color) or region # 当前横向位置未知:先用手指向右滑到列表左端。每次刷新 XML 时, diff --git a/client/test/test_pdd_u2_purchase_adapter.py b/client/test/test_pdd_u2_purchase_adapter.py index 2d23a9e..4837081 100644 --- a/client/test/test_pdd_u2_purchase_adapter.py +++ b/client/test/test_pdd_u2_purchase_adapter.py @@ -240,6 +240,21 @@ class ContextualConfirmPanelDevice(FakeDevice): self.mode = "panel" +class SubmitHintPanelDevice(FakeDevice): + """模拟带提交提示的非滚动图片颜色面板。""" + + def __init__(self, panel_data: str) -> None: + super().__init__() + self.panel_data = panel_data + + def dump_hierarchy(self): + if not self.has_opened: + return '' + if self.mode == "panel": + return self.panel_data + return home_xml() + + def address_confirmation_xml(address: str) -> str: return f""" @@ -827,6 +842,40 @@ class U2PddPurchaseAdapterTest(unittest.TestCase): self.assertIn("[已脱敏]", content) adapter.close() + def test_color_mismatch_reports_reason_and_saves_sanitized_tree(self): + panel_data = ( + FIXTURES / "pdd_spec_panel_submit_hint.xml" + ).read_text(encoding="utf-8") + device = SubmitHintPanelDevice(panel_data) + with tempfile.TemporaryDirectory() as directory: + adapter = U2PddPurchaseAdapter( + "USB-001", + device_service=PddDeviceService( + connector=lambda _serial: device + ), + sleeper=lambda _seconds: None, + select_color_fn=lambda *_args, **_kwargs: False, + artifact_directory=Path(directory), + ) + adapter.open_goods(GOODS_URL) + + with self.assertRaises(PddPurchaseError) as raised: + adapter.select_options({"color": "测试黑色"}) + + self.assertEqual( + raised.exception.code, "PURCHASE_OPTIONS_MISMATCH" + ) + self.assertIn("点击颜色后", raised.exception.message) + self.assertEqual( + raised.exception.diagnostics["selection_failure"], + "selection_unconfirmed", + ) + artifact = raised.exception.diagnostics["artifacts"][0] + content = Path(artifact["path"]).read_text(encoding="utf-8") + self.assertNotIn("测试黑色", content) + self.assertIn("[已脱敏]", content) + adapter.close() + def test_invalid_non_pdd_url_is_rejected_before_connect(self): device = FakeDevice() adapter = self._adapter(device, []) diff --git a/client/test/test_select_color_size.py b/client/test/test_select_color_size.py new file mode 100644 index 0000000..3622831 --- /dev/null +++ b/client/test/test_select_color_size.py @@ -0,0 +1,127 @@ +"""采购规格颜色选择测试;只使用脱敏 XML,不连接真机。""" + +from pathlib import Path +import unittest +import xml.etree.ElementTree as ET + +from src.util.select_color_size import ( + color_selection_failure_reason, + select_color, +) + + +FIXTURES = Path(__file__).parent / "fixtures" + + +class StaticColorDevice: + """模拟没有滚动属性的图片颜色规格面板。""" + + def __init__(self, xml_data: str, confirm_selection: bool = True) -> None: + self.xml_data = xml_data + self.confirm_selection = confirm_selection + self.clicks = [] + self.swipes = [] + + def dump_hierarchy(self) -> str: + return self.xml_data + + def click(self, x: int, y: int) -> None: + self.clicks.append((x, y)) + if not self.confirm_selection: + return + root = ET.fromstring(self.xml_data) + for node in root.iter("node"): + if node.get("text", "").strip() == "测试黑色": + node.set("selected", "true") + if node.get("text", "").strip().startswith("请选择"): + node.set("text", "已选: 测试黑色") + self.xml_data = ET.tostring(root, encoding="unicode") + + def swipe(self, *args, **kwargs) -> None: + self.swipes.append((args, kwargs)) + + +class StaticColorSelectionTest(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.panel_xml = ( + FIXTURES / "pdd_spec_panel_submit_hint.xml" + ).read_text(encoding="utf-8") + + def test_tall_static_color_card_is_clicked_and_confirmed(self): + device = StaticColorDevice(self.panel_xml) + + selected = select_color( + device, + self.panel_xml, + "测试黑色", + action_delay=0.001, + ) + + self.assertTrue(selected) + self.assertEqual(device.clicks, [(194, 1389)]) + self.assertEqual(device.swipes, []) + + def test_static_card_stops_when_click_is_not_confirmed(self): + device = StaticColorDevice(self.panel_xml, confirm_selection=False) + + selected = select_color( + device, + self.panel_xml, + "测试黑色", + action_delay=0.001, + ) + + self.assertFalse(selected) + self.assertEqual(device.clicks, [(194, 1389)]) + self.assertEqual(device.swipes, []) + self.assertEqual( + color_selection_failure_reason( + device.dump_hierarchy(), "测试黑色" + ), + "selection_unconfirmed", + ) + + def test_partially_visible_static_card_is_not_clicked(self): + partial_xml = self.panel_xml.replace( + "[36,1188][352,1591]", "[0,1188][60,1591]" + ).replace( + "[36,1188][352,1504]", "[0,1188][60,1504]" + ).replace( + "[36,1483][352,1591]", "[0,1483][60,1591]" + ) + device = StaticColorDevice(partial_xml) + + selected = select_color( + device, partial_xml, "测试黑色", action_delay=0.001 + ) + + self.assertFalse(selected) + self.assertEqual(device.clicks, []) + self.assertEqual(device.swipes, []) + self.assertEqual( + color_selection_failure_reason(partial_xml, "测试黑色"), + "safe_target_missing", + ) + + def test_card_crossing_size_heading_is_not_clicked(self): + crossing_xml = self.panel_xml.replace( + "[36,1188][352,1591]", "[36,1188][352,1700]" + ).replace( + "[36,1188][352,1504]", "[36,1188][352,1660]" + ).replace( + "[36,1483][352,1591]", "[36,1592][352,1700]" + ) + device = StaticColorDevice(crossing_xml) + + selected = select_color( + device, crossing_xml, "测试黑色", action_delay=0.001 + ) + + self.assertFalse(selected) + self.assertEqual(device.clicks, []) + self.assertEqual(device.swipes, []) + + +if __name__ == "__main__": + unittest.main()