fix: 兼容纵向颜色规格列表 (#264)
This commit is contained in:
@@ -653,6 +653,51 @@ def _color_swipe_y(root: ET.Element, region: Bounds) -> int:
|
|||||||
return region_center_y
|
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(
|
def _swipe_color_row(
|
||||||
device: Any,
|
device: Any,
|
||||||
root: ET.Element,
|
root: ET.Element,
|
||||||
@@ -668,6 +713,21 @@ def _swipe_color_row(
|
|||||||
device.swipe(start_x, y, end_x, y, duration=0.4)
|
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:
|
def _swipe_panel_down(device: Any, region: Bounds) -> None:
|
||||||
left, top, right, bottom = region
|
left, top, right, bottom = region
|
||||||
height = bottom - top
|
height = bottom - top
|
||||||
@@ -689,7 +749,7 @@ def select_color(
|
|||||||
max_swipes: int = 15,
|
max_swipes: int = 15,
|
||||||
action_delay: float = 0.5,
|
action_delay: float = 0.5,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""边横向滑动边刷新 XML,目标颜色充分可见后点击并验证选中。"""
|
"""判断颜色列表方向,有界遍历后精确点击并验证目标颜色。"""
|
||||||
if not target_color.strip():
|
if not target_color.strip():
|
||||||
raise ValueError("target_color 不能为空")
|
raise ValueError("target_color 不能为空")
|
||||||
|
|
||||||
@@ -713,11 +773,65 @@ def select_color(
|
|||||||
if selected:
|
if selected:
|
||||||
return True
|
return True
|
||||||
if target_is_static:
|
if target_is_static:
|
||||||
# 静态面板中目标已经出现,点击失败后继续横向滑动只会增加等待,
|
# 静态面板中目标已经出现,点击失败后继续滑动只会增加等待,
|
||||||
# 还可能触碰其他控件;必须立即交给上层报告未确认。
|
# 还可能触碰其他控件;必须立即交给上层报告未确认。
|
||||||
return False
|
return False
|
||||||
region = _horizontal_color_region(root, target_color) or region
|
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 时,
|
# 当前横向位置未知:先用手指向右滑到列表左端。每次刷新 XML 时,
|
||||||
# _click_target_and_verify 都会遍历整个 RecyclerView 内的所有行。
|
# _click_target_and_verify 都会遍历整个 RecyclerView 内的所有行。
|
||||||
previous_signature = _visible_signature(root, region)
|
previous_signature = _visible_signature(root, region)
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import unittest
|
|||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
from src.util.select_color_size import (
|
from src.util.select_color_size import (
|
||||||
|
_color_scroll_direction,
|
||||||
|
_parse_xml,
|
||||||
color_selection_failure_reason,
|
color_selection_failure_reason,
|
||||||
normalize_spec_text,
|
normalize_spec_text,
|
||||||
select_color,
|
select_color,
|
||||||
@@ -50,6 +52,108 @@ class StaticColorDevice:
|
|||||||
self.swipes.append((args, kwargs))
|
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'<node text="{text}" clickable="true"{selected} '
|
||||||
|
f'bounds="[{left},1080][{left + 270},1190]" />'
|
||||||
|
)
|
||||||
|
container = f'''<node class="androidx.recyclerview.widget.RecyclerView"
|
||||||
|
scrollable="true" bounds="[24,1040][1056,1240]">
|
||||||
|
{''.join(options)}
|
||||||
|
</node>'''
|
||||||
|
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'<node text="{text}" clickable="true"{selected} '
|
||||||
|
f'bounds="[36,{top}][850,{top + 85}]" />'
|
||||||
|
)
|
||||||
|
container = ''.join(options)
|
||||||
|
|
||||||
|
summary = f"已选:{self.target}" if self.selected else "请选择:颜色 尺码"
|
||||||
|
return f'''<hierarchy>
|
||||||
|
<node bounds="[0,0][1080,2340]">
|
||||||
|
<node class="android.widget.ScrollView" scrollable="true"
|
||||||
|
bounds="[0,945][1080,2079]">
|
||||||
|
<node text="{summary}" bounds="[36,950][800,1000]" />
|
||||||
|
<node text="颜色 (14)" bounds="[36,971][216,1024]" />
|
||||||
|
{container}
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</hierarchy>'''
|
||||||
|
|
||||||
|
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 '''<hierarchy><node bounds="[0,0][1080,2340]">
|
||||||
|
<node text="颜色 (8)" bounds="[36,820][220,880]" />
|
||||||
|
<node scrollable="true" bounds="[100,900][900,1700]">
|
||||||
|
<node text="颜色1" clickable="true" bounds="[140,940][360,1080]" />
|
||||||
|
<node text="颜色2" clickable="true" bounds="[540,940][760,1080]" />
|
||||||
|
<node text="颜色3" clickable="true" bounds="[140,1340][360,1480]" />
|
||||||
|
<node text="颜色4" clickable="true" bounds="[540,1340][760,1480]" />
|
||||||
|
</node>
|
||||||
|
</node></hierarchy>'''
|
||||||
|
|
||||||
|
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):
|
class StaticColorSelectionTest(unittest.TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpClass(cls) -> None:
|
def setUpClass(cls) -> None:
|
||||||
@@ -208,6 +312,114 @@ class StaticColorSelectionTest(unittest.TestCase):
|
|||||||
"target_ambiguous",
|
"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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -115,6 +115,10 @@ Client 不保存或读取 `purchase.live_*` 手工授权设置。`purchase_mode`
|
|||||||
当前前台应用,避免在部分真机上重复等待十秒以上。最终下单点击前仍必须单独
|
当前前台应用,避免在部分真机上重复等待十秒以上。最终下单点击前仍必须单独
|
||||||
查询前台应用。性能日志分别记录状态读取、颜色选择和尺码选择耗时。
|
查询前台应用。性能日志分别记录状态读取、颜色选择和尺码选择耗时。
|
||||||
- 规格选择后读取最新控件树并验证选中状态。
|
- 规格选择后读取最新控件树并验证选中状态。
|
||||||
|
- 颜色规格可能是单行横向列表、单列纵向列表或多行网格。Client 必须先根据
|
||||||
|
同一滚动容器内候选坐标分布和容器形状判断主要方向,再按方向有界遍历;
|
||||||
|
方向证据不足时不滑动、不点击。找到目标后仍须通过精确或唯一繁简等价匹配,
|
||||||
|
不能用近似颜色代替任务规格。
|
||||||
- 运行时规格解析请求必须在网络发送前写入独立 SQLite 记录;只调用一次专用命令,
|
- 运行时规格解析请求必须在网络发送前写入独立 SQLite 记录;只调用一次专用命令,
|
||||||
不轮询 Admin。只有 `matched` 响应的哈希、候选编号、原文和 options 都是本次候选的
|
不轮询 Admin。只有 `matched` 响应的哈希、候选编号、原文和 options 都是本次候选的
|
||||||
逐字副本时才继续。响应返回后重新完整遍历真机候选并复算哈希,页面、颜色或候选
|
逐字副本时才继续。响应返回后重新完整遍历真机候选并复算哈希,页面、颜色或候选
|
||||||
|
|||||||
Reference in New Issue
Block a user