From d69c9cc31cabdd468f7993a2c109b18fddc2d14d Mon Sep 17 00:00:00 2001 From: chengma Date: Tue, 18 Aug 2026 11:36:04 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=85=BC=E5=AE=B9=E7=BA=B5=E5=90=91?= =?UTF-8?q?=E9=A2=9C=E8=89=B2=E8=A7=84=E6=A0=BC=E5=88=97=E8=A1=A8=20(#264)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/util/select_color_size.py | 118 +++++++++++++- client/test/test_select_color_size.py | 212 ++++++++++++++++++++++++++ docs/client/06-quality-security.md | 4 + 3 files changed, 332 insertions(+), 2 deletions(-) diff --git a/client/src/util/select_color_size.py b/client/src/util/select_color_size.py index 975191f..6489dee 100644 --- a/client/src/util/select_color_size.py +++ b/client/src/util/select_color_size.py @@ -653,6 +653,51 @@ def _color_swipe_y(root: ET.Element, region: Bounds) -> int: return region_center_y +def _color_scroll_direction( + root: ET.Element, + region: Bounds, +) -> Optional[str]: + """判断颜色容器的主要滚动方向;证据不足时不猜测。""" + + option_bounds: list[Bounds] = [] + for node in root.iter("node"): + if node.get("clickable") != "true" or not _is_available(node): + continue + bounds = _parse_bounds(node.get("bounds", "")) + if bounds is None or not _center_in(bounds, region): + continue + if _intersection_area(bounds, region) <= 0: + continue + option_bounds.append(bounds) + + unique_bounds = list(dict.fromkeys(option_bounds)) + if len(unique_bounds) < 2: + return None + + left_span = max(item[0] for item in unique_bounds) - min( + item[0] for item in unique_bounds + ) + top_span = max(item[1] for item in unique_bounds) - min( + item[1] for item in unique_bounds + ) + width = region[2] - region[0] + height = region[3] - region[1] + + # 单列纵向列表的按钮宽度可能随文字变化,所以比较左上角分布, + # 不能用按钮中心点误把长短不一的同列按钮当成多列。 + if top_span >= 80 and top_span > max(80, left_span * 2): + return "vertical" + if left_span >= 80 and left_span > max(80, top_span * 2): + return "horizontal" + + # 多行网格同时具有 x/y 分布,只在容器长宽给出强证据时决定方向。 + if height >= width * 1.15 and top_span >= 80: + return "vertical" + if width >= height * 1.4 and left_span >= 80: + return "horizontal" + return None + + def _swipe_color_row( device: Any, root: ET.Element, @@ -668,6 +713,21 @@ def _swipe_color_row( device.swipe(start_x, y, end_x, y, duration=0.4) +def _swipe_panel_to_start(device: Any, region: Bounds) -> None: + """手指向下滑,让纵向规格容器回到颜色列表起点。""" + + left, top, right, bottom = region + height = bottom - top + x = (left + right) // 2 + device.swipe( + x, + top + int(height * 0.25), + x, + top + int(height * 0.82), + duration=0.35, + ) + + def _swipe_panel_down(device: Any, region: Bounds) -> None: left, top, right, bottom = region height = bottom - top @@ -689,7 +749,7 @@ def select_color( max_swipes: int = 15, action_delay: float = 0.5, ) -> bool: - """边横向滑动边刷新 XML,目标颜色充分可见后点击并验证选中。""" + """判断颜色列表方向,有界遍历后精确点击并验证目标颜色。""" if not target_color.strip(): raise ValueError("target_color 不能为空") @@ -713,11 +773,65 @@ def select_color( if selected: return True if target_is_static: - # 静态面板中目标已经出现,点击失败后继续横向滑动只会增加等待, + # 静态面板中目标已经出现,点击失败后继续滑动只会增加等待, # 还可能触碰其他控件;必须立即交给上层报告未确认。 return False region = _horizontal_color_region(root, target_color) or region + direction = _color_scroll_direction(root, region) + if direction is None: + return False + if direction == "vertical": + # Adapter 正常会先把颜色标题恢复到顶部;独立调用若标题已经 + # 滚出视口,则先有界回到起点,随后只向下扫描一次。 + if _heading_bounds(root) is None: + previous_signature = _visible_signature(root, region) + unchanged_count = 0 + for _ in range(max_swipes): + _swipe_panel_to_start(device, region) + time.sleep(action_delay) + root = _parse_xml(device.dump_hierarchy()) + region = _target_scrollable_ancestor(root, target_color) or region + if _heading_bounds(root) is not None: + break + signature = _visible_signature(root, region) + unchanged_count = ( + unchanged_count + 1 + if signature == previous_signature + else 0 + ) + previous_signature = signature + if unchanged_count >= 2: + break + + previous_signature = _visible_signature(root, region) + unchanged_count = 0 + for _ in range(max_swipes): + _swipe_panel_down(device, region) + time.sleep(action_delay) + root = _parse_xml(device.dump_hierarchy()) + region = _target_scrollable_ancestor(root, target_color) or region + + selected, root = _click_target_and_verify( + device, + root, + target_color, + region, + action_delay, + require_horizontal_safe=True, + ) + if selected: + return True + + signature = _visible_signature(root, region) + unchanged_count = ( + unchanged_count + 1 if signature == previous_signature else 0 + ) + previous_signature = signature + if unchanged_count >= 2: + break + return False + # 当前横向位置未知:先用手指向右滑到列表左端。每次刷新 XML 时, # _click_target_and_verify 都会遍历整个 RecyclerView 内的所有行。 previous_signature = _visible_signature(root, region) diff --git a/client/test/test_select_color_size.py b/client/test/test_select_color_size.py index a0e07ed..33e2cfa 100644 --- a/client/test/test_select_color_size.py +++ b/client/test/test_select_color_size.py @@ -5,6 +5,8 @@ import unittest import xml.etree.ElementTree as ET from src.util.select_color_size import ( + _color_scroll_direction, + _parse_xml, color_selection_failure_reason, normalize_spec_text, select_color, @@ -50,6 +52,108 @@ class StaticColorDevice: self.swipes.append((args, kwargs)) +class ScrollColorDevice: + """模拟可横向或纵向滚动的颜色列表。""" + + def __init__( + self, + direction: str, + target: str = "目标白色", + include_target: bool = True, + ) -> None: + self.direction = direction + self.target = target + self.include_target = include_target + self.page = 0 + self.selected = False + self.clicks = [] + self.swipes = [] + + def dump_hierarchy(self) -> str: + if self.direction == "horizontal": + start = self.page * 2 + visible = [f"颜色{index}" for index in range(start + 1, start + 4)] + if self.page == 2 and self.include_target: + visible[-1] = self.target + options = [] + for index, text in enumerate(visible): + left = 40 + index * 320 + selected = ' selected="true"' if text == self.target and self.selected else "" + options.append( + f'' + ) + container = f''' + {''.join(options)} + ''' + else: + visible = [f"颜色{index}" for index in range(1, 10)] + if self.page: + visible = [f"颜色{index}" for index in range(7, 14)] + [self.target] + if not self.include_target: + visible[-1] = "颜色14" + options = [] + for index, text in enumerate(visible): + top = 1050 + index * 110 + selected = ' selected="true"' if text == self.target and self.selected else "" + options.append( + f'' + ) + container = ''.join(options) + + summary = f"已选:{self.target}" if self.selected else "请选择:颜色 尺码" + return f''' + + + + + {container} + + + ''' + + def click(self, x: int, y: int) -> None: + self.clicks.append((x, y)) + if self.target in self.dump_hierarchy(): + self.selected = True + + def swipe(self, x1, y1, x2, y2, duration=0.35) -> None: + self.swipes.append((x1, y1, x2, y2, duration)) + if self.direction == "vertical" and abs(y2 - y1) > abs(x2 - x1): + self.page = 1 if y1 > y2 else 0 + if self.direction == "horizontal" and abs(x2 - x1) > abs(y2 - y1): + self.page = min(2, self.page + 1) if x1 > x2 else max(0, self.page - 1) + + +class AmbiguousGridDevice: + """模拟方向证据不足的正方形两行两列网格。""" + + def __init__(self) -> None: + self.clicks = [] + self.swipes = [] + + @staticmethod + def dump_hierarchy() -> str: + return ''' + + + + + + + + ''' + + def click(self, x: int, y: int) -> None: + self.clicks.append((x, y)) + + def swipe(self, *args, **kwargs) -> None: + self.swipes.append((args, kwargs)) + + class StaticColorSelectionTest(unittest.TestCase): @classmethod def setUpClass(cls) -> None: @@ -208,6 +312,114 @@ class StaticColorSelectionTest(unittest.TestCase): "target_ambiguous", ) + def test_vertical_color_list_scrolls_down_to_target(self): + device = ScrollColorDevice("vertical") + + selected = select_color( + device, + device.dump_hierarchy(), + device.target, + action_delay=0.001, + ) + + self.assertTrue(selected) + self.assertEqual(len(device.clicks), 1) + self.assertTrue(device.swipes) + self.assertTrue( + all( + abs(y2 - y1) > abs(x2 - x1) + for x1, y1, x2, y2, _duration in device.swipes + ) + ) + + def test_horizontal_color_list_keeps_horizontal_scan(self): + device = ScrollColorDevice("horizontal") + + selected = select_color( + device, + device.dump_hierarchy(), + device.target, + action_delay=0.001, + ) + + self.assertTrue(selected) + self.assertEqual(len(device.clicks), 1) + self.assertTrue(device.swipes) + self.assertTrue( + all( + abs(x2 - x1) > abs(y2 - y1) + for x1, y1, x2, y2, _duration in device.swipes + ) + ) + + def test_ambiguous_grid_stops_without_swipe_or_click(self): + device = AmbiguousGridDevice() + + selected = select_color( + device, + device.dump_hierarchy(), + "首屏外目标", + action_delay=0.001, + ) + + self.assertFalse(selected) + self.assertEqual(device.swipes, []) + self.assertEqual(device.clicks, []) + + def test_grid_uses_container_shape_only_when_direction_is_clear(self): + xml_data = AmbiguousGridDevice.dump_hierarchy() + ambiguous_root = _parse_xml(xml_data) + self.assertIsNone( + _color_scroll_direction(ambiguous_root, (100, 900, 900, 1700)) + ) + + tall_xml = xml_data.replace( + 'bounds="[100,900][900,1700]"', + 'bounds="[100,700][900,1900]"', + ) + self.assertEqual( + _color_scroll_direction( + _parse_xml(tall_xml), (100, 700, 900, 1900) + ), + "vertical", + ) + + wide_xml = xml_data.replace( + 'bounds="[100,900][900,1700]"', + 'bounds="[20,900][1060,1500]"', + ) + self.assertEqual( + _color_scroll_direction( + _parse_xml(wide_xml), (20, 900, 1060, 1500) + ), + "horizontal", + ) + + def test_vertical_list_stops_at_edge_without_approximate_click(self): + device = ScrollColorDevice( + "vertical", + target="不存在的近似颜色", + include_target=False, + ) + + selected = select_color( + device, + device.dump_hierarchy(), + device.target, + max_swipes=5, + action_delay=0.001, + ) + + self.assertFalse(selected) + self.assertEqual(device.clicks, []) + self.assertTrue(device.swipes) + self.assertTrue( + all( + abs(y2 - y1) > abs(x2 - x1) + for x1, y1, x2, y2, _duration in device.swipes + ) + ) + if __name__ == "__main__": unittest.main() diff --git a/docs/client/06-quality-security.md b/docs/client/06-quality-security.md index 5b87971..4b038b1 100644 --- a/docs/client/06-quality-security.md +++ b/docs/client/06-quality-security.md @@ -115,6 +115,10 @@ Client 不保存或读取 `purchase.live_*` 手工授权设置。`purchase_mode` 当前前台应用,避免在部分真机上重复等待十秒以上。最终下单点击前仍必须单独 查询前台应用。性能日志分别记录状态读取、颜色选择和尺码选择耗时。 - 规格选择后读取最新控件树并验证选中状态。 +- 颜色规格可能是单行横向列表、单列纵向列表或多行网格。Client 必须先根据 + 同一滚动容器内候选坐标分布和容器形状判断主要方向,再按方向有界遍历; + 方向证据不足时不滑动、不点击。找到目标后仍须通过精确或唯一繁简等价匹配, + 不能用近似颜色代替任务规格。 - 运行时规格解析请求必须在网络发送前写入独立 SQLite 记录;只调用一次专用命令, 不轮询 Admin。只有 `matched` 响应的哈希、候选编号、原文和 options 都是本次候选的 逐字副本时才继续。响应返回后重新完整遍历真机候选并复算哈希,页面、颜色或候选