fix: 兼容非滚动图片颜色采购选择 (#178)
This commit is contained in:
@@ -49,7 +49,11 @@ from .pdd_purchase_adapter import (
|
|||||||
PurchasePageState,
|
PurchasePageState,
|
||||||
)
|
)
|
||||||
from .util.get_size_panle_coord import get_size_panel_coord
|
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]
|
Bounds = tuple[int, int, int, int]
|
||||||
@@ -734,10 +738,32 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter):
|
|||||||
device, panel_xml, color, action_delay=0.2
|
device, panel_xml, color, action_delay=0.2
|
||||||
)
|
)
|
||||||
if not color_selected:
|
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(
|
raise PddPurchaseError(
|
||||||
"PURCHASE_OPTIONS_MISMATCH",
|
"PURCHASE_OPTIONS_MISMATCH",
|
||||||
f"没有精确选中颜色:{color}",
|
messages[failure_reason],
|
||||||
step="purchase_select_options",
|
step="purchase_select_options",
|
||||||
|
diagnostics=diagnostics,
|
||||||
)
|
)
|
||||||
size = checked.get("size")
|
size = checked.get("size")
|
||||||
if size:
|
if size:
|
||||||
|
|||||||
@@ -173,9 +173,16 @@ def _horizontal_color_region(
|
|||||||
if candidates:
|
if candidates:
|
||||||
return max(candidates, key=lambda item: item[0])[1]
|
return max(candidates, key=lambda item: item[0])[1]
|
||||||
|
|
||||||
# 部分自绘横向列表不会暴露 scrollable,使用颜色标题下方区域兜底。
|
# 部分自绘列表不会暴露 scrollable。目标已经出现时,用完整卡片和
|
||||||
|
# 下一规格标题确定纵向边界,避免固定高度截断较高的图片卡片。
|
||||||
if heading is None or screen is None:
|
if heading is None or screen is None:
|
||||||
return 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]
|
top = heading[3]
|
||||||
bottom = min(screen[3], top + max(160, int(screen[3] * 0.14)))
|
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
|
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]:
|
def _vertical_panel_region(root: ET.Element) -> Optional[Bounds]:
|
||||||
heading = _heading_bounds(root)
|
heading = _heading_bounds(root)
|
||||||
vertical: list[tuple[float, Bounds]] = []
|
vertical: list[tuple[float, Bounds]] = []
|
||||||
@@ -327,6 +379,28 @@ def _target_is_selected(root: ET.Element, target: str) -> bool:
|
|||||||
return False
|
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(
|
def _visible_signature(
|
||||||
root: ET.Element,
|
root: ET.Element,
|
||||||
region: Bounds,
|
region: Bounds,
|
||||||
@@ -439,6 +513,10 @@ def select_color(
|
|||||||
raise ValueError("target_color 不能为空")
|
raise ValueError("target_color 不能为空")
|
||||||
|
|
||||||
root = _parse_xml(xml_data)
|
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)
|
region = _horizontal_color_region(root, target_color)
|
||||||
if region is None:
|
if region is None:
|
||||||
return False
|
return False
|
||||||
@@ -453,6 +531,10 @@ def select_color(
|
|||||||
)
|
)
|
||||||
if selected:
|
if selected:
|
||||||
return True
|
return True
|
||||||
|
if target_is_static:
|
||||||
|
# 静态面板中目标已经出现,点击失败后继续横向滑动只会增加等待,
|
||||||
|
# 还可能触碰其他控件;必须立即交给上层报告未确认。
|
||||||
|
return False
|
||||||
region = _horizontal_color_region(root, target_color) or region
|
region = _horizontal_color_region(root, target_color) or region
|
||||||
|
|
||||||
# 当前横向位置未知:先用手指向右滑到列表左端。每次刷新 XML 时,
|
# 当前横向位置未知:先用手指向右滑到列表左端。每次刷新 XML 时,
|
||||||
|
|||||||
@@ -240,6 +240,21 @@ class ContextualConfirmPanelDevice(FakeDevice):
|
|||||||
self.mode = "panel"
|
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 '<hierarchy><node package="com.xunmeng.pinduoduo" /></hierarchy>'
|
||||||
|
if self.mode == "panel":
|
||||||
|
return self.panel_data
|
||||||
|
return home_xml()
|
||||||
|
|
||||||
|
|
||||||
def address_confirmation_xml(address: str) -> str:
|
def address_confirmation_xml(address: str) -> str:
|
||||||
return f"""<hierarchy>
|
return f"""<hierarchy>
|
||||||
<node package="com.xunmeng.pinduoduo" bounds="[0,0][1080,2376]">
|
<node package="com.xunmeng.pinduoduo" bounds="[0,0][1080,2376]">
|
||||||
@@ -827,6 +842,40 @@ class U2PddPurchaseAdapterTest(unittest.TestCase):
|
|||||||
self.assertIn("[已脱敏]", content)
|
self.assertIn("[已脱敏]", content)
|
||||||
adapter.close()
|
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):
|
def test_invalid_non_pdd_url_is_rejected_before_connect(self):
|
||||||
device = FakeDevice()
|
device = FakeDevice()
|
||||||
adapter = self._adapter(device, [])
|
adapter = self._adapter(device, [])
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user