1874 lines
70 KiB
Python
1874 lines
70 KiB
Python
"""PDD 商品采集与 XML 解析测试,不连接真机。"""
|
||
|
||
from dataclasses import dataclass
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
import tempfile
|
||
import unittest
|
||
import xml.etree.ElementTree as ET
|
||
|
||
from src.pdd_collect_service import (
|
||
PddCollectError,
|
||
PddCollectService,
|
||
_is_spec_panel_open,
|
||
parse_goods_page,
|
||
parse_quantity,
|
||
parse_spec_panel,
|
||
)
|
||
from src.pdd_device_service import PddDeviceService
|
||
|
||
|
||
FIXTURES = Path(__file__).parent / "fixtures"
|
||
|
||
|
||
def style_package_spec_xml() -> str:
|
||
"""模拟口罩商品的“款式 + 套餐”规格面板。"""
|
||
|
||
return """<hierarchy>
|
||
<node class="android.widget.FrameLayout" bounds="[0,0][1080,2340]">
|
||
<node class="android.widget.ScrollView" scrollable="true"
|
||
bounds="[0,600][1080,2200]">
|
||
<node text="券后 ¥1.68" bounds="[300,650][600,710]" />
|
||
<node text="已选:成人款 单个装" bounds="[260,720][1000,780]" />
|
||
<node text="款式" bounds="[36,800][180,860]" />
|
||
<node text="成人款" clickable="true" selected="true"
|
||
bounds="[36,900][300,1000]" />
|
||
<node text="儿童款" clickable="true" selected="false"
|
||
bounds="[330,900][594,1000]" />
|
||
<node text="套餐" bounds="[36,1100][180,1160]" />
|
||
<node text="单个装" clickable="true" selected="true"
|
||
bounds="[36,1200][300,1300]" />
|
||
<node text="两个装" clickable="true" selected="false"
|
||
bounds="[330,1200][594,1300]" />
|
||
<node text="确定" clickable="true" bounds="[0,2000][1080,2150]" />
|
||
</node>
|
||
</node>
|
||
</hierarchy>"""
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class FakeTask:
|
||
goods_url: str
|
||
goods_id: str | None = None
|
||
|
||
|
||
class FakeCollectDevice:
|
||
def __init__(self, home_xml: str, spec_xml: str):
|
||
self.home_xml = home_xml
|
||
self.spec_xml = spec_xml
|
||
self.panel_open = False
|
||
self.clicks = []
|
||
self.swipes = []
|
||
self.swipe_panel_states = []
|
||
self.app_wait_calls = 0
|
||
self.app_current_calls = 0
|
||
self.opened_url = None
|
||
|
||
def app_current(self):
|
||
self.app_current_calls += 1
|
||
return {"package": "com.xunmeng.pinduoduo"}
|
||
|
||
def app_start(self, _package):
|
||
raise AssertionError("PDD 已经在前台,不应重复启动")
|
||
|
||
def app_wait(self, _package, timeout=10):
|
||
self.app_wait_calls += 1
|
||
return 123
|
||
|
||
def open_url(self, url):
|
||
self.opened_url = url
|
||
|
||
def dump_hierarchy(self):
|
||
if self.opened_url is None:
|
||
return '<hierarchy><node package="com.xunmeng.pinduoduo" /></hierarchy>'
|
||
return self.spec_xml if self.panel_open else self.home_xml
|
||
|
||
def click(self, x, y):
|
||
self.clicks.append((x, y))
|
||
if y > 1600:
|
||
self.panel_open = True
|
||
|
||
def swipe(self, *args, **kwargs):
|
||
self.swipes.append((args, kwargs))
|
||
self.swipe_panel_states.append(self.panel_open)
|
||
|
||
|
||
class GoodsMetadataScrollDevice(FakeCollectDevice):
|
||
"""模拟评价和店铺分别出现在商品页后续屏幕。"""
|
||
|
||
def __init__(self, pages, spec_xml):
|
||
super().__init__(pages[0], spec_xml)
|
||
self.pages = pages
|
||
self.page_index = 0
|
||
|
||
def dump_hierarchy(self):
|
||
if self.opened_url is None and not self.panel_open:
|
||
return super().dump_hierarchy()
|
||
if self.panel_open:
|
||
return self.spec_xml
|
||
return self.pages[self.page_index]
|
||
|
||
def swipe(self, *args, **kwargs):
|
||
super().swipe(*args, **kwargs)
|
||
if not self.panel_open:
|
||
self.page_index = min(self.page_index + 1, len(self.pages) - 1)
|
||
|
||
|
||
class LoadingDevice(FakeCollectDevice):
|
||
def dump_hierarchy(self):
|
||
return '<hierarchy><node text="正在加载" /></hierarchy>'
|
||
|
||
|
||
class DisconnectingDevice(FakeCollectDevice):
|
||
def __init__(self, home_xml, spec_xml):
|
||
super().__init__(home_xml, spec_xml)
|
||
self.hierarchy_calls = 0
|
||
|
||
def dump_hierarchy(self):
|
||
self.hierarchy_calls += 1
|
||
if self.hierarchy_calls > 1:
|
||
raise RuntimeError("device not found")
|
||
return super().dump_hierarchy()
|
||
|
||
|
||
class FocusMismatchDevice(FakeCollectDevice):
|
||
"""系统焦点报告为设置页,但无障碍树实际属于 PDD。"""
|
||
|
||
def app_current(self):
|
||
return {"package": "com.oplus.wirelesssettings"}
|
||
|
||
def app_start(self, _package):
|
||
self.app_started = True
|
||
|
||
|
||
class SlowFocusMismatchDevice(FocusMismatchDevice):
|
||
def __init__(self, home_xml, spec_xml, clock):
|
||
super().__init__(home_xml, spec_xml)
|
||
self.clock = clock
|
||
self.app_current_calls = 0
|
||
|
||
def app_current(self):
|
||
self.app_current_calls += 1
|
||
self.clock.sleep(11.0)
|
||
return {"package": "com.android.settings"}
|
||
|
||
|
||
class PanelDoesNotOpenDevice(FakeCollectDevice):
|
||
def click(self, x, y):
|
||
self.clicks.append((x, y))
|
||
|
||
|
||
TRANSIENT_EMPTY_SPEC_XML = """<hierarchy>
|
||
<node class="android.widget.FrameLayout" package="com.xunmeng.pinduoduo"
|
||
bounds="[0,0][1080,2340]">
|
||
<node class="android.widget.LinearLayout" package="com.xunmeng.pinduoduo"
|
||
bounds="[0,0][1080,2214]" />
|
||
</node>
|
||
</hierarchy>"""
|
||
|
||
|
||
class TransientSpecTreeDevice(FakeCollectDevice):
|
||
"""规格面板阶段交替返回临时空树和有效树。"""
|
||
|
||
def __init__(self, home_xml, spec_xml):
|
||
super().__init__(home_xml, spec_xml)
|
||
self.panel_reads = 0
|
||
|
||
def dump_hierarchy(self):
|
||
if self.panel_open:
|
||
self.panel_reads += 1
|
||
if self.panel_reads % 2 == 0:
|
||
return TRANSIENT_EMPTY_SPEC_XML
|
||
return super().dump_hierarchy()
|
||
|
||
|
||
class SpecTreeStaysBlankDevice(FakeCollectDevice):
|
||
"""面板首次有效,随后持续返回空树。"""
|
||
|
||
def __init__(self, home_xml, spec_xml):
|
||
super().__init__(home_xml, spec_xml)
|
||
self.panel_reads = 0
|
||
|
||
def dump_hierarchy(self):
|
||
if self.panel_open:
|
||
self.panel_reads += 1
|
||
if self.panel_reads > 1:
|
||
return TRANSIENT_EMPTY_SPEC_XML
|
||
return super().dump_hierarchy()
|
||
|
||
|
||
class SnakeColorDevice(FakeCollectDevice):
|
||
"""模拟两行横向颜色网格和滚动后才出现的尺码区域。"""
|
||
|
||
prices = {
|
||
"A色": 1000,
|
||
"B色": 1000,
|
||
"C色": 1200,
|
||
"D色": 1300,
|
||
"E色": 1400,
|
||
"F色": 1500,
|
||
}
|
||
|
||
def __init__(self, home_xml):
|
||
super().__init__(home_xml, "")
|
||
self.page = 1
|
||
self.selected_color = "A色"
|
||
self.sizes_visible = False
|
||
self.clicked_colors = []
|
||
|
||
def dump_hierarchy(self):
|
||
if self.opened_url is None and not self.panel_open:
|
||
return super().dump_hierarchy()
|
||
if not self.panel_open:
|
||
return self.home_xml
|
||
return self._spec_xml()
|
||
|
||
def click(self, x, y):
|
||
self.clicks.append((x, y))
|
||
if not self.panel_open:
|
||
self.panel_open = True
|
||
return
|
||
for name, bounds in self._current_color_bounds().items():
|
||
left, top, right, bottom = bounds
|
||
if left <= x <= right and top <= y <= bottom:
|
||
self.selected_color = name
|
||
self.clicked_colors.append(name)
|
||
return
|
||
|
||
def swipe(self, x1, y1, x2, y2, duration=0.35):
|
||
self.swipes.append(((x1, y1, x2, y2), {"duration": duration}))
|
||
self.swipe_panel_states.append(self.panel_open)
|
||
if abs(x2 - x1) > abs(y2 - y1):
|
||
self.page = 1 if x1 > x2 else 0
|
||
else:
|
||
self.sizes_visible = True
|
||
|
||
def _current_color_bounds(self):
|
||
if self.page == 0:
|
||
return {
|
||
"A色": (36, 740, 340, 850),
|
||
"B色": (370, 740, 674, 850),
|
||
"C色": (1044, 740, 1080, 850),
|
||
"D色": (36, 900, 340, 1010),
|
||
"E色": (370, 900, 674, 1010),
|
||
"F色": (1044, 900, 1080, 1010),
|
||
}
|
||
return {
|
||
"A色": (0, 740, 20, 850),
|
||
"B色": (36, 740, 340, 850),
|
||
"C色": (370, 740, 674, 850),
|
||
"D色": (0, 900, 20, 1010),
|
||
"E色": (36, 900, 340, 1010),
|
||
"F色": (370, 900, 674, 1010),
|
||
}
|
||
|
||
def _spec_xml(self):
|
||
color_nodes = []
|
||
for name, bounds in self._current_color_bounds().items():
|
||
left, top, right, bottom = bounds
|
||
selected = "true" if name == self.selected_color else "false"
|
||
color_nodes.append(
|
||
f'<node class="android.view.ViewGroup" content-desc="{name}" '
|
||
f'clickable="true" selected="{selected}" '
|
||
f'bounds="[{left},{top}][{right},{bottom}]" '
|
||
'visible-to-user="true" enabled="true" />'
|
||
)
|
||
size_nodes = ""
|
||
if self.sizes_visible:
|
||
size_nodes = """
|
||
<node class="android.widget.TextView" text="尺码"
|
||
bounds="[36,1120][130,1180]" visible-to-user="true"
|
||
enabled="true" />
|
||
<node class="android.view.ViewGroup" content-desc="M"
|
||
clickable="true" bounds="[36,1210][340,1300]"
|
||
visible-to-user="true" enabled="true" />
|
||
<node class="android.view.ViewGroup" content-desc="L"
|
||
clickable="true" bounds="[370,1210][674,1300]"
|
||
visible-to-user="true" enabled="true" />
|
||
"""
|
||
price = self.prices[self.selected_color] / 100
|
||
return f"""<hierarchy>
|
||
<node class="android.widget.FrameLayout" bounds="[0,0][1080,2000]"
|
||
visible-to-user="true" enabled="true">
|
||
<node class="android.widget.TextView" text="¥{price:.2f}"
|
||
bounds="[390,300][650,380]" visible-to-user="true"
|
||
enabled="true" />
|
||
<node class="androidx.recyclerview.widget.RecyclerView"
|
||
scrollable="true" bounds="[0,600][1080,1700]"
|
||
visible-to-user="true" enabled="true">
|
||
<node class="android.widget.TextView" text="颜色分类"
|
||
bounds="[36,650][200,710]" visible-to-user="true"
|
||
enabled="true" />
|
||
<node class="androidx.recyclerview.widget.RecyclerView"
|
||
scrollable="true" bounds="[36,720][1044,1060]"
|
||
visible-to-user="true" enabled="true">
|
||
{''.join(color_nodes)}
|
||
</node>
|
||
{size_nodes}
|
||
</node>
|
||
</node>
|
||
</hierarchy>"""
|
||
|
||
|
||
class IgnoredColorClickDevice(SnakeColorDevice):
|
||
"""页面暴露选中状态,但模拟一次没有生效的颜色点击。"""
|
||
|
||
def click(self, x, y):
|
||
if not self.panel_open:
|
||
super().click(x, y)
|
||
return
|
||
self.clicks.append((x, y))
|
||
|
||
|
||
class AllMissingColorPriceDevice(IgnoredColorClickDevice):
|
||
"""所有颜色点击都未生效,模拟全部颜色采价为空。"""
|
||
|
||
def _spec_xml(self):
|
||
return super()._spec_xml().replace('selected="true"', 'selected="false"')
|
||
|
||
|
||
class NoColorPriceDevice(SnakeColorDevice):
|
||
"""颜色可以选中,但价格节点一直没有出现。"""
|
||
|
||
def _spec_xml(self):
|
||
price = self.prices[self.selected_color] / 100
|
||
return super()._spec_xml().replace(
|
||
f'text="¥{price:.2f}"', 'text="价格加载中"'
|
||
)
|
||
|
||
|
||
class DelayedHorizontalSwipeDevice(SnakeColorDevice):
|
||
"""水平滑动后第一次读取仍返回旧视口,第二次才更新。"""
|
||
|
||
def __init__(self, home_xml):
|
||
super().__init__(home_xml)
|
||
self.pending_page = None
|
||
self.old_page_reads = 0
|
||
|
||
def dump_hierarchy(self):
|
||
if self.panel_open and self.pending_page is not None:
|
||
if self.old_page_reads > 0:
|
||
self.old_page_reads -= 1
|
||
else:
|
||
self.page = self.pending_page
|
||
self.pending_page = None
|
||
return super().dump_hierarchy()
|
||
|
||
def swipe(self, x1, y1, x2, y2, duration=0.35):
|
||
self.swipes.append(((x1, y1, x2, y2), {"duration": duration}))
|
||
self.swipe_panel_states.append(self.panel_open)
|
||
if abs(x2 - x1) > abs(y2 - y1):
|
||
self.pending_page = 1 if x1 > x2 else 0
|
||
self.old_page_reads = 1
|
||
else:
|
||
self.sizes_visible = True
|
||
|
||
|
||
class ImageColorDevice(SnakeColorDevice):
|
||
"""图片型颜色卡片:点图片会进入大图,点底部文字才选中颜色。"""
|
||
|
||
def __init__(self, home_xml):
|
||
super().__init__(home_xml)
|
||
self.big_image_open = False
|
||
|
||
def dump_hierarchy(self):
|
||
if self.big_image_open:
|
||
return """<hierarchy>
|
||
<node class="android.support.v4.view.ViewPager"
|
||
scrollable="true" bounds="[0,0][1080,2000]">
|
||
<node class="android.widget.ImageView" content-desc="A色" />
|
||
</node>
|
||
<node class="android.widget.TextView" text="1/31"
|
||
bounds="[500,1850][580,1900]" />
|
||
</hierarchy>"""
|
||
return super().dump_hierarchy()
|
||
|
||
def click(self, x, y):
|
||
if self.panel_open and 740 <= y < 820:
|
||
self.clicks.append((x, y))
|
||
self.big_image_open = True
|
||
return
|
||
super().click(x, y)
|
||
|
||
def press(self, key):
|
||
if key == "back":
|
||
self.big_image_open = False
|
||
|
||
def _spec_xml(self):
|
||
xml_data = super()._spec_xml()
|
||
old = (
|
||
'<node class="android.view.ViewGroup" content-desc="A色" '
|
||
f'clickable="true" selected="{str(self.selected_color == "A色").lower()}" '
|
||
'bounds="[36,740][340,850]" visible-to-user="true" enabled="true" />'
|
||
)
|
||
new = (
|
||
'<node class="android.view.ViewGroup" content-desc="A色" '
|
||
f'clickable="true" selected="{str(self.selected_color == "A色").lower()}" '
|
||
'bounds="[36,740][340,850]" visible-to-user="true" enabled="true">'
|
||
'<node class="android.widget.ImageView" content-desc="A色" '
|
||
'clickable="true" bounds="[36,740][340,820]" />'
|
||
'<node class="android.widget.ImageView" content-desc="打开大图" '
|
||
'clickable="true" bounds="[280,740][340,800]" />'
|
||
'<node class="android.widget.TextView" text="A色" clickable="true" '
|
||
'bounds="[36,820][340,850]" visible-to-user="true" />'
|
||
'</node>'
|
||
)
|
||
return xml_data.replace(old, new)
|
||
|
||
|
||
class DefaultSizeDevice(SnakeColorDevice):
|
||
"""尺码在下方且默认选中,取消后才能完整遍历颜色。"""
|
||
|
||
def __init__(self, home_xml, *, can_clear=True):
|
||
super().__init__(home_xml)
|
||
self.size_selected = True
|
||
self.can_clear = can_clear
|
||
self.size_clicks = 0
|
||
|
||
def click(self, x, y):
|
||
if self.panel_open and self.sizes_visible and 1210 <= y <= 1300:
|
||
self.clicks.append((x, y))
|
||
self.size_clicks += 1
|
||
if self.can_clear:
|
||
self.size_selected = False
|
||
return
|
||
super().click(x, y)
|
||
|
||
def swipe(self, x1, y1, x2, y2, duration=0.35):
|
||
self.swipes.append(((x1, y1, x2, y2), {"duration": duration}))
|
||
self.swipe_panel_states.append(self.panel_open)
|
||
if abs(x2 - x1) > abs(y2 - y1):
|
||
self.page = 1 if x1 > x2 else 0
|
||
else:
|
||
self.sizes_visible = y1 > y2
|
||
|
||
def _spec_xml(self):
|
||
xml_data = super()._spec_xml()
|
||
if self.sizes_visible:
|
||
selected = str(self.size_selected).lower()
|
||
xml_data = xml_data.replace(
|
||
'content-desc="M"\n clickable="true"',
|
||
f'content-desc="M"\n clickable="true" selected="{selected}"',
|
||
)
|
||
return xml_data
|
||
|
||
|
||
class DefaultPackageDevice(DefaultSizeDevice):
|
||
"""“套餐”作为第二规格时沿用安全的默认选择取消流程。"""
|
||
|
||
def _spec_xml(self):
|
||
return super()._spec_xml().replace('text="尺码"', 'text="套餐"')
|
||
|
||
|
||
class HorizontalPackageDevice:
|
||
"""模拟独立横向套餐列表,每屏只有三个且相邻页面有重叠。"""
|
||
|
||
packages = tuple(f"套餐{i}" for i in range(1, 8))
|
||
|
||
def __init__(self, expected_count=7):
|
||
self.page = 1
|
||
self.expected_count = expected_count
|
||
self.swipes = []
|
||
self.clicks = []
|
||
|
||
def dump_hierarchy(self):
|
||
start = min(self.page * 2, len(self.packages) - 3)
|
||
visible = self.packages[start : start + 3]
|
||
options = []
|
||
for index, text in enumerate(visible):
|
||
left = 36 + index * 320
|
||
options.append(
|
||
f'<node text="{text}" clickable="true" '
|
||
f'bounds="[{left},1250][{left + 280},1370]" />'
|
||
)
|
||
return f"""<hierarchy>
|
||
<node bounds="[0,0][1080,2340]">
|
||
<node class="android.widget.ScrollView" scrollable="true"
|
||
bounds="[0,650][1080,2200]">
|
||
<node text="券后 ¥5.69" bounds="[300,680][600,740]" />
|
||
<node text="款式" bounds="[36,800][180,860]" />
|
||
<node text="口罩款" clickable="true" selected="true"
|
||
bounds="[36,900][300,1000]" />
|
||
<node text="套餐({self.expected_count})"
|
||
bounds="[36,1120][240,1180]" />
|
||
<node class="androidx.recyclerview.widget.RecyclerView"
|
||
scrollable="true" bounds="[24,1200][1056,1420]">
|
||
{''.join(options)}
|
||
</node>
|
||
</node>
|
||
</node>
|
||
</hierarchy>"""
|
||
|
||
def swipe(self, x1, y1, x2, y2, duration=0.35):
|
||
self.swipes.append((x1, y1, x2, y2, duration))
|
||
if abs(x2 - x1) <= abs(y2 - y1):
|
||
return
|
||
if x1 > x2:
|
||
self.page = min(self.page + 1, 2)
|
||
else:
|
||
self.page = max(self.page - 1, 0)
|
||
|
||
|
||
class VerticalLongPackageDevice:
|
||
"""模拟纵向长套餐:滑到第二屏后“套餐”标题不再出现在控件树。"""
|
||
|
||
packages = tuple(f"套餐{i}" for i in range(1, 17))
|
||
|
||
def __init__(self):
|
||
self.page = 0
|
||
self.swipes = []
|
||
self.clicks = []
|
||
|
||
def dump_hierarchy(self):
|
||
start = min(self.page * 3, len(self.packages) - 4)
|
||
visible = self.packages[start : start + 4]
|
||
headings = ""
|
||
if self.page == 0:
|
||
headings = """
|
||
<node text="款式" bounds="[36,720][180,780]" />
|
||
<node text="口罩款" clickable="true" selected="true"
|
||
bounds="[36,800][300,900]" />
|
||
<node text="套餐(16)" bounds="[36,980][240,1040]" />
|
||
"""
|
||
options = []
|
||
for index, text in enumerate(visible):
|
||
top = 1080 + index * 210
|
||
right = 500 if self.page == 0 else 1015
|
||
options.append(
|
||
f'<node text="{text} ¥5.69" clickable="true" '
|
||
f'bounds="[36,{top}][{right},{top + 150}]" />'
|
||
)
|
||
return f"""<hierarchy>
|
||
<node bounds="[0,0][1080,2340]">
|
||
<node class="android.widget.ScrollView" scrollable="true"
|
||
bounds="[0,650][1080,2200]">
|
||
<node text="已选:套餐1" bounds="[36,660][500,710]" />
|
||
{headings}
|
||
{''.join(options)}
|
||
<node text="券后 ¥5.69" clickable="true"
|
||
bounds="[36,1880][300,1940]" />
|
||
<node text="增加数量" clickable="true"
|
||
bounds="[36,1950][220,2050]" />
|
||
<node text="提交订单" clickable="true"
|
||
bounds="[300,2080][1040,2190]" />
|
||
</node>
|
||
</node>
|
||
</hierarchy>"""
|
||
|
||
def swipe(self, x1, y1, x2, y2, duration=0.35):
|
||
self.swipes.append((x1, y1, x2, y2, duration))
|
||
if abs(y2 - y1) <= abs(x2 - x1):
|
||
return
|
||
if y1 > y2:
|
||
self.page = min(self.page + 1, 4)
|
||
else:
|
||
self.page = max(self.page - 1, 0)
|
||
|
||
|
||
class FakeClock:
|
||
"""测试用时钟:sleep 只推进虚拟时间,不真的等待。"""
|
||
|
||
def __init__(self):
|
||
self.now = 0.0
|
||
self.sleeps = []
|
||
|
||
def monotonic(self):
|
||
return self.now
|
||
|
||
def sleep(self, seconds):
|
||
self.sleeps.append(seconds)
|
||
self.now += seconds
|
||
|
||
|
||
def keep_only_one_sku(xml_data: str) -> str:
|
||
"""从脱敏固件中删除蓝色和 L,只保留一个组合。"""
|
||
|
||
root = ET.fromstring(xml_data)
|
||
for parent in root.iter():
|
||
for child in list(parent):
|
||
own_label = (
|
||
child.get("text") or child.get("content-desc") or ""
|
||
).strip()
|
||
labels = " ".join(
|
||
(node.get("text") or node.get("content-desc") or "").strip()
|
||
for node in child.iter("node")
|
||
)
|
||
if own_label == "蓝色" or labels.strip() == "L":
|
||
parent.remove(child)
|
||
return ET.tostring(root, encoding="unicode")
|
||
|
||
|
||
class PddCollectParserTest(unittest.TestCase):
|
||
@classmethod
|
||
def setUpClass(cls):
|
||
cls.home_xml = (FIXTURES / "pdd_goods_page.xml").read_text(encoding="utf-8")
|
||
cls.spec_xml = (FIXTURES / "pdd_spec_panel.xml").read_text(encoding="utf-8")
|
||
cls.count_heading_spec_xml = (
|
||
FIXTURES / "pdd_spec_panel_count_heading.xml"
|
||
).read_text(encoding="utf-8")
|
||
cls.non_scrollable_spec_xml = (
|
||
FIXTURES / "pdd_spec_panel_non_scrollable.xml"
|
||
).read_text(encoding="utf-8")
|
||
cls.sold_out_xml = (
|
||
FIXTURES / "pdd_sold_out_recommendations.xml"
|
||
).read_text(encoding="utf-8")
|
||
|
||
def test_quantity_keeps_raw_value_and_approximate_flag(self):
|
||
result = parse_quantity("已拼1.2万+件")
|
||
self.assertEqual(result.value, 12000)
|
||
self.assertEqual(result.raw, "已拼1.2万+件")
|
||
self.assertTrue(result.approximate)
|
||
|
||
def test_parse_goods_page(self):
|
||
result = parse_goods_page(self.home_xml)
|
||
self.assertEqual(result.title, "测试纯棉短袖商品")
|
||
self.assertEqual(result.shop_name, "测试服饰旗舰店")
|
||
self.assertEqual(result.sales.value, 12000)
|
||
self.assertTrue(result.sales.approximate)
|
||
self.assertEqual(result.reviews.value, 2356)
|
||
|
||
def test_parse_review_title_and_shop_name_next_to_enter_shop(self):
|
||
xml_data = """<hierarchy>
|
||
<node class="android.widget.FrameLayout" bounds="[0,0][1080,2000]">
|
||
<node class="android.widget.TextView" text="商品评价(191)"
|
||
bounds="[30,700][300,760]" />
|
||
<node class="android.widget.TextView" text="承诺假一赔十"
|
||
bounds="[30,1050][300,1110]" />
|
||
<node class="android.widget.TextView" text="小熊服饰店"
|
||
bounds="[300,1200][650,1260]" />
|
||
<node class="android.widget.TextView" text="进店"
|
||
bounds="[850,1200][1030,1260]" />
|
||
</node>
|
||
</hierarchy>"""
|
||
|
||
result = parse_goods_page(xml_data)
|
||
|
||
self.assertEqual(result.reviews.value, 191)
|
||
self.assertEqual(result.shop_name, "小熊服饰店")
|
||
|
||
def test_parse_spec_panel_uses_generic_dimensions_and_cents(self):
|
||
result = parse_spec_panel(self.spec_xml)
|
||
self.assertEqual(result.price_cent, 1000)
|
||
self.assertEqual([item.key for item in result.dimensions], ["color", "size"])
|
||
self.assertEqual(
|
||
[value.text for value in result.dimensions[0].values],
|
||
["红色", "蓝色"],
|
||
)
|
||
self.assertFalse(result.dimensions[0].values[1].available)
|
||
self.assertEqual(
|
||
[value.text for value in result.dimensions[1].values],
|
||
["M", "L"],
|
||
)
|
||
|
||
def test_parse_spec_panel_accepts_count_in_color_heading(self):
|
||
for heading in ("颜色 (6)", "颜色(6)"):
|
||
with self.subTest(heading=heading):
|
||
xml_data = self.count_heading_spec_xml.replace("颜色 (6)", heading)
|
||
result = parse_spec_panel(xml_data)
|
||
colors = next(
|
||
item for item in result.dimensions if item.key == "color"
|
||
)
|
||
|
||
self.assertEqual(colors.name, heading)
|
||
self.assertEqual(
|
||
[value.text for value in colors.values],
|
||
["黑色", "红色", "蓝色", "紫色", "浅粉", "本色"],
|
||
)
|
||
|
||
def test_style_and_package_are_separate_color_and_size_dimensions(self):
|
||
result = parse_spec_panel(style_package_spec_xml())
|
||
|
||
self.assertEqual(
|
||
[(item.key, item.name) for item in result.dimensions],
|
||
[("color", "款式"), ("size", "套餐")],
|
||
)
|
||
self.assertEqual(
|
||
[value.text for value in result.dimensions[0].values],
|
||
["成人款", "儿童款"],
|
||
)
|
||
self.assertEqual(
|
||
[value.text for value in result.dimensions[1].values],
|
||
["单个装", "两个装"],
|
||
)
|
||
|
||
def test_package_only_is_primary_dimension(self):
|
||
root = ET.fromstring(style_package_spec_xml())
|
||
for parent in root.iter():
|
||
for child in list(parent):
|
||
if child.get("text") in {"款式", "成人款", "儿童款"}:
|
||
parent.remove(child)
|
||
|
||
result = parse_spec_panel(ET.tostring(root, encoding="unicode"))
|
||
|
||
self.assertEqual(
|
||
[(item.key, item.name) for item in result.dimensions],
|
||
[("color", "套餐")],
|
||
)
|
||
|
||
def test_third_dimension_is_not_silently_merged(self):
|
||
xml_data = style_package_spec_xml().replace(
|
||
'<node text="确定" clickable="true" bounds="[0,2000][1080,2150]" />',
|
||
'<node text="尺码" bounds="[36,1400][180,1460]" />\n'
|
||
'<node text="均码" clickable="true" selected="false" '
|
||
'bounds="[36,1500][300,1600]" />\n'
|
||
'<node text="确定" clickable="true" bounds="[0,2000][1080,2150]" />',
|
||
)
|
||
parsed = parse_spec_panel(xml_data)
|
||
self.assertEqual(
|
||
[item.key for item in parsed.dimensions],
|
||
["color", "size", "size_2"],
|
||
)
|
||
device = FakeCollectDevice(self.home_xml, xml_data)
|
||
device.panel_open = True
|
||
device.opened_url = "https://mobile.yangkeduo.com/goods.html?goods_id=123"
|
||
service = PddCollectService(
|
||
PddDeviceService(lambda _serial: device),
|
||
"USB-001",
|
||
"client-001",
|
||
sleeper=lambda _seconds: None,
|
||
max_spec_swipes=0,
|
||
)
|
||
|
||
with self.assertRaises(PddCollectError) as raised:
|
||
service._collect_size_dimension(device)
|
||
|
||
self.assertEqual(raised.exception.code, "PDD_DATA_SPEC_UNSUPPORTED")
|
||
|
||
def test_collect_preserves_style_and_package_names(self):
|
||
xml_data = style_package_spec_xml().replace(
|
||
'<node text="儿童款" clickable="true" selected="false"\n'
|
||
' bounds="[330,900][594,1000]" />',
|
||
"",
|
||
).replace(
|
||
'<node text="两个装" clickable="true" selected="false"\n'
|
||
' bounds="[330,1200][594,1300]" />',
|
||
"",
|
||
)
|
||
device = FakeCollectDevice(self.home_xml, xml_data)
|
||
service = PddCollectService(
|
||
PddDeviceService(lambda _serial: device),
|
||
"USB-001",
|
||
"client-001",
|
||
sleeper=lambda _seconds: None,
|
||
max_spec_swipes=0,
|
||
)
|
||
|
||
result = service.collect(
|
||
FakeTask("https://mobile.yangkeduo.com/goods.html?goods_id=123")
|
||
)
|
||
|
||
self.assertEqual(
|
||
[(item.key, item.name) for item in result.dimensions],
|
||
[("color", "款式"), ("size", "套餐")],
|
||
)
|
||
self.assertEqual(
|
||
result.skus[0].options,
|
||
{"color": "成人款", "size": "单个装"},
|
||
)
|
||
|
||
def test_parse_non_scrollable_spec_panel(self):
|
||
result = parse_spec_panel(self.non_scrollable_spec_xml)
|
||
|
||
self.assertTrue(_is_spec_panel_open(self.non_scrollable_spec_xml))
|
||
self.assertEqual([item.key for item in result.dimensions], ["color", "size"])
|
||
self.assertEqual(
|
||
[value.text for value in result.dimensions[0].values],
|
||
[
|
||
"测试黑色",
|
||
"测试黑色+网袜",
|
||
"测试黑色+丝袜",
|
||
"测试黑色+短袜",
|
||
"测试黑色+长袜",
|
||
],
|
||
)
|
||
self.assertEqual(
|
||
[value.text for value in result.dimensions[1].values],
|
||
["均码(适合80-120斤)"],
|
||
)
|
||
self.assertEqual(result.price_cent, 817)
|
||
self.assertEqual(result.raw_price, "¥8.17")
|
||
self.assertNotIn("确认款式", [item.name for item in result.dimensions])
|
||
|
||
def test_non_scrollable_spec_panel_exposes_three_color_rows(self):
|
||
service = PddCollectService(
|
||
PddDeviceService(lambda _serial: object()),
|
||
"USB-001",
|
||
"client-001",
|
||
)
|
||
|
||
rows = service._visible_color_rows(self.non_scrollable_spec_xml)
|
||
|
||
self.assertEqual(
|
||
[[item.text for item in row] for row in rows],
|
||
[
|
||
["测试黑色", "测试黑色+网袜"],
|
||
["测试黑色+丝袜", "测试黑色+短袜"],
|
||
["测试黑色+长袜"],
|
||
],
|
||
)
|
||
self.assertTrue(all(item.available for row in rows for item in row))
|
||
self.assertTrue(all(item.bounds for row in rows for item in row))
|
||
|
||
def test_clickable_text_outside_spec_panel_is_not_an_option(self):
|
||
xml_data = """<hierarchy>
|
||
<node class="android.widget.FrameLayout" bounds="[0,0][1080,2400]">
|
||
<node text="测试黑色" clickable="true"
|
||
bounds="[36,1295][276,1382]" />
|
||
</node>
|
||
</hierarchy>"""
|
||
root = ET.fromstring(xml_data)
|
||
|
||
self.assertIsNone(PddCollectService._find_option_node(root, "测试黑色"))
|
||
|
||
def test_wait_accepts_non_scrollable_spec_panel_without_sleep(self):
|
||
device = FakeCollectDevice(self.home_xml, self.non_scrollable_spec_xml)
|
||
device.opened_url = "https://mobile.yangkeduo.com/goods.html?goods_id=123"
|
||
device.panel_open = True
|
||
clock = FakeClock()
|
||
service = PddCollectService(
|
||
PddDeviceService(lambda _serial: device),
|
||
"USB-001",
|
||
"client-001",
|
||
sleeper=clock.sleep,
|
||
monotonic=clock.monotonic,
|
||
spec_panel_timeout=1.0,
|
||
)
|
||
|
||
snapshot = service._wait_spec_panel(device)
|
||
|
||
self.assertEqual([item.key for item in snapshot.dimensions], ["color", "size"])
|
||
self.assertEqual(clock.sleeps, [])
|
||
|
||
def test_goods_page_is_not_non_scrollable_spec_panel(self):
|
||
self.assertFalse(_is_spec_panel_open(self.home_xml))
|
||
|
||
def test_sold_out_recommendations_are_not_spec_options(self):
|
||
with self.assertRaises(PddCollectError) as raised:
|
||
parse_spec_panel(self.sold_out_xml)
|
||
|
||
self.assertEqual(raised.exception.code, "PDD_GOODS_UNAVAILABLE")
|
||
self.assertEqual(
|
||
raised.exception.diagnostics, {"page_reason": "out_of_stock"}
|
||
)
|
||
|
||
page_without_marker = self.sold_out_xml.replace(
|
||
"商品已售罄,推荐以下相似商品", "为你推荐以下相似商品"
|
||
)
|
||
with self.assertRaises(PddCollectError) as incomplete:
|
||
parse_spec_panel(page_without_marker)
|
||
self.assertEqual(incomplete.exception.code, "PDD_DATA_SPEC_INCOMPLETE")
|
||
|
||
def test_blank_pdd_tree_is_not_a_spec_panel(self):
|
||
xml_data = """<hierarchy>
|
||
<node class="android.widget.FrameLayout"
|
||
package="com.xunmeng.pinduoduo"
|
||
bounds="[0,0][1080,2340]" />
|
||
</hierarchy>"""
|
||
|
||
self.assertFalse(_is_spec_panel_open(xml_data))
|
||
with self.assertRaises(PddCollectError) as raised:
|
||
parse_spec_panel(xml_data)
|
||
self.assertEqual(raised.exception.code, "PDD_DATA_SPEC_INCOMPLETE")
|
||
|
||
def test_collect_stops_on_sold_out_page_without_clicking_recommendations(self):
|
||
device = FakeCollectDevice(self.home_xml, self.sold_out_xml)
|
||
service = PddCollectService(
|
||
PddDeviceService(lambda _serial: device),
|
||
"USB-001",
|
||
"client-001",
|
||
sleeper=lambda _seconds: None,
|
||
)
|
||
|
||
with self.assertRaises(PddCollectError) as raised:
|
||
service.collect(
|
||
FakeTask("https://mobile.yangkeduo.com/goods.html?goods_id=123")
|
||
)
|
||
|
||
self.assertEqual(raised.exception.code, "PDD_GOODS_UNAVAILABLE")
|
||
self.assertEqual(
|
||
raised.exception.diagnostics["page_reason"], "out_of_stock"
|
||
)
|
||
# 只点击商品页规格入口,不点击任何推荐商品。
|
||
self.assertEqual(len(device.clicks), 1)
|
||
|
||
def test_full_window_with_scattered_labels_is_not_spec_panel(self):
|
||
xml_data = """<hierarchy>
|
||
<node class="android.widget.FrameLayout" bounds="[0,0][1080,2400]">
|
||
<node text="确认款式" bounds="[20,100][220,160]" />
|
||
<node text="已选择:测试值" bounds="[20,300][500,360]" />
|
||
<node text="颜色分类" bounds="[20,800][220,860]" />
|
||
<node text="确定" clickable="true" bounds="[0,2280][1080,2400]" />
|
||
</node>
|
||
</hierarchy>"""
|
||
|
||
self.assertFalse(_is_spec_panel_open(xml_data))
|
||
|
||
def test_open_panel_without_visible_options_is_not_reported_as_timeout(self):
|
||
panel_xml = """<hierarchy>
|
||
<node class="android.widget.FrameLayout" bounds="[0,0][1080,2400]">
|
||
<node text="请选择:颜色 尺码" bounds="[36,900][600,980]" />
|
||
<node class="androidx.recyclerview.widget.RecyclerView"
|
||
scrollable="true" bounds="[0,1077][1080,2079]" />
|
||
<node text="选择颜色及尺码后,提交订单"
|
||
bounds="[300,2200][1000,2300]" />
|
||
</node>
|
||
</hierarchy>"""
|
||
device = FakeCollectDevice(self.home_xml, panel_xml)
|
||
device.opened_url = "https://mobile.yangkeduo.com/goods.html?goods_id=123"
|
||
device.panel_open = True
|
||
clock = FakeClock()
|
||
service = PddCollectService(
|
||
PddDeviceService(lambda _serial: device),
|
||
"USB-001",
|
||
"client-001",
|
||
sleeper=clock.sleep,
|
||
monotonic=clock.monotonic,
|
||
spec_panel_timeout=1.0,
|
||
)
|
||
|
||
snapshot = service._wait_spec_panel(device)
|
||
|
||
self.assertEqual(snapshot.dimensions, ())
|
||
self.assertEqual(clock.sleeps, [])
|
||
|
||
def test_open_panel_with_incomplete_specs_reports_spec_incomplete(self):
|
||
panel_xml = """<hierarchy>
|
||
<node class="android.widget.FrameLayout" bounds="[0,0][1080,2400]">
|
||
<node text="请选择:颜色 尺码" bounds="[36,900][600,980]" />
|
||
<node class="androidx.recyclerview.widget.RecyclerView"
|
||
scrollable="true" bounds="[0,1077][1080,2079]" />
|
||
<node text="选择颜色及尺码后,提交订单"
|
||
bounds="[300,2200][1000,2300]" />
|
||
</node>
|
||
</hierarchy>"""
|
||
device = FakeCollectDevice(self.home_xml, panel_xml)
|
||
service = PddCollectService(
|
||
PddDeviceService(lambda _serial: device),
|
||
"USB-001",
|
||
"client-001",
|
||
sleeper=lambda _seconds: None,
|
||
max_spec_swipes=0,
|
||
)
|
||
|
||
with self.assertRaises(PddCollectError) as raised:
|
||
service.collect(
|
||
FakeTask("https://mobile.yangkeduo.com/goods.html?goods_id=123")
|
||
)
|
||
|
||
self.assertEqual(raised.exception.code, "PDD_DATA_SPEC_INCOMPLETE")
|
||
|
||
def test_truncated_spec_name_prefers_full_content_description(self):
|
||
xml_data = self.spec_xml.replace(
|
||
'content-desc="红色" clickable="true"',
|
||
'text="红…" content-desc="红色" clickable="true"',
|
||
)
|
||
result = parse_spec_panel(xml_data)
|
||
self.assertEqual(result.dimensions[0].values[0].text, "红色")
|
||
|
||
without_description = xml_data.replace(' content-desc="红色"', "")
|
||
with self.assertRaises(PddCollectError) as raised:
|
||
parse_spec_panel(without_description)
|
||
self.assertEqual(raised.exception.code, "PDD_DATA_SKU_NAME_TRUNCATED")
|
||
|
||
def test_login_and_invalid_xml_have_different_error_codes(self):
|
||
with self.assertRaises(PddCollectError) as login:
|
||
parse_goods_page('<hierarchy><node text="手机号登录" /></hierarchy>')
|
||
with self.assertRaises(PddCollectError) as invalid:
|
||
parse_goods_page("<hierarchy>")
|
||
self.assertEqual(login.exception.code, "PDD_PAGE_LOGIN_REQUIRED")
|
||
self.assertEqual(invalid.exception.code, "PDD_DATA_XML_INVALID")
|
||
|
||
def test_captcha_and_incomplete_spec_have_specific_codes(self):
|
||
with self.assertRaises(PddCollectError) as captcha:
|
||
parse_goods_page('<hierarchy><node text="请完成验证" /></hierarchy>')
|
||
with self.assertRaises(PddCollectError) as incomplete:
|
||
parse_spec_panel('<hierarchy><node text="普通页面" /></hierarchy>')
|
||
self.assertEqual(captcha.exception.code, "PDD_PAGE_CAPTCHA")
|
||
self.assertEqual(incomplete.exception.code, "PDD_DATA_SPEC_INCOMPLETE")
|
||
|
||
def test_collect_returns_versioned_pdd_data_without_real_device(self):
|
||
# 收窄到一个可用组合,便于验证完整流程而不模拟真实页面切换。
|
||
one_sku_xml = keep_only_one_sku(self.spec_xml)
|
||
device = FakeCollectDevice(self.home_xml, one_sku_xml)
|
||
service = PddCollectService(
|
||
PddDeviceService(lambda _serial: device),
|
||
"USB-001",
|
||
"client-001",
|
||
sleeper=lambda _seconds: None,
|
||
now=lambda: datetime(2026, 8, 7, 8, 0, tzinfo=timezone.utc),
|
||
max_spec_swipes=1,
|
||
)
|
||
|
||
result = service.collect(
|
||
FakeTask("https://mobile.yangkeduo.com/goods.html?goods_id=123")
|
||
)
|
||
data = result.to_pdd_data()
|
||
|
||
self.assertEqual(data["schema_version"], 1)
|
||
self.assertEqual(data["goods_id"], "123")
|
||
self.assertEqual(data["title"], "测试纯棉短袖商品")
|
||
self.assertEqual(data["shop_name"], "测试服饰旗舰店")
|
||
self.assertEqual(data["price_granularity"], "color")
|
||
self.assertEqual(data["metrics"]["sales"]["value"], 12000)
|
||
self.assertEqual(data["dimensions"][0]["key"], "color")
|
||
self.assertEqual(data["skus"][0]["options"], {"color": "红色", "size": "M"})
|
||
self.assertEqual(data["skus"][0]["price_cent"], 1000)
|
||
self.assertEqual(
|
||
data["skus"][0]["price_observed_at"],
|
||
{"color": "红色"},
|
||
)
|
||
self.assertEqual(data["source"]["device_address"], "USB-001")
|
||
self.assertIsNone(data["purchase"])
|
||
self.assertEqual(device.app_wait_calls, 0)
|
||
self.assertEqual(device.app_current_calls, 1)
|
||
|
||
def test_collect_scrolls_before_spec_panel_and_accumulates_metadata(self):
|
||
initial_page = self.home_xml.replace(
|
||
'<node class="android.widget.TextView" text="2356条评价" '
|
||
'bounds="[330,950][600,1010]" visible-to-user="true" '
|
||
'enabled="true" />',
|
||
"",
|
||
).replace(
|
||
'<node class="android.widget.TextView" text="店铺:测试服饰旗舰店" '
|
||
'bounds="[30,1050][600,1110]" visible-to-user="true" '
|
||
'enabled="true" />',
|
||
"",
|
||
)
|
||
buy_button = """
|
||
<node class="android.view.ViewGroup" content-desc="¥10.00立即购买"
|
||
clickable="true" bounds="[500,1700][1080,1950]"
|
||
visible-to-user="true" enabled="true">
|
||
<node class="android.widget.TextView" text="¥10.00"
|
||
bounds="[650,1730][850,1810]"
|
||
visible-to-user="true" enabled="true" />
|
||
<node class="android.widget.TextView" text="立即购买"
|
||
bounds="[650,1820][900,1900]"
|
||
visible-to-user="true" enabled="true" />
|
||
</node>
|
||
"""
|
||
review_page = f"""<hierarchy>
|
||
<node class="android.widget.FrameLayout" bounds="[0,0][1080,2000]">
|
||
<node class="android.widget.TextView" text="商品评价(191)"
|
||
bounds="[30,700][300,760]" />
|
||
{buy_button}
|
||
</node>
|
||
</hierarchy>"""
|
||
shop_page = f"""<hierarchy>
|
||
<node class="android.widget.FrameLayout" bounds="[0,0][1080,2000]">
|
||
<node class="android.widget.TextView" text="小熊服饰店"
|
||
bounds="[300,1200][650,1260]" />
|
||
<node class="android.widget.TextView" text="进店"
|
||
bounds="[850,1200][1030,1260]" />
|
||
{buy_button}
|
||
</node>
|
||
</hierarchy>"""
|
||
device = GoodsMetadataScrollDevice(
|
||
[initial_page, review_page, shop_page],
|
||
keep_only_one_sku(self.spec_xml),
|
||
)
|
||
service = PddCollectService(
|
||
PddDeviceService(lambda _serial: device),
|
||
"USB-001",
|
||
"client-001",
|
||
sleeper=lambda _seconds: None,
|
||
max_spec_swipes=0,
|
||
)
|
||
|
||
result = service.collect(
|
||
FakeTask("https://mobile.yangkeduo.com/goods.html?goods_id=123")
|
||
)
|
||
|
||
self.assertEqual(result.title, "测试纯棉短袖商品")
|
||
self.assertEqual(result.sales.value, 12000)
|
||
self.assertEqual(result.reviews.value, 191)
|
||
self.assertEqual(result.shop_name, "小熊服饰店")
|
||
self.assertEqual(device.swipe_panel_states[:2], [False, False])
|
||
self.assertEqual(device.page_index, 2)
|
||
self.assertTrue(device.clicks)
|
||
|
||
def test_pdd_hierarchy_is_ready_even_when_focused_package_is_settings(self):
|
||
pdd_home_xml = self.home_xml.replace(
|
||
"<node ", '<node package="com.xunmeng.pinduoduo" '
|
||
)
|
||
device = FocusMismatchDevice(
|
||
pdd_home_xml, keep_only_one_sku(self.spec_xml)
|
||
)
|
||
service = PddCollectService(
|
||
PddDeviceService(lambda _serial: device),
|
||
"USB-001",
|
||
"client-001",
|
||
sleeper=lambda _seconds: None,
|
||
max_spec_swipes=0,
|
||
)
|
||
|
||
result = service.collect(
|
||
FakeTask("https://mobile.yangkeduo.com/goods.html?goods_id=123")
|
||
)
|
||
|
||
self.assertEqual(result.goods_id, "123")
|
||
self.assertTrue(device.app_started)
|
||
self.assertEqual(device.app_wait_calls, 1)
|
||
|
||
def test_price_is_sampled_once_per_available_color(self):
|
||
device = FakeCollectDevice(self.home_xml, self.spec_xml)
|
||
service = PddCollectService(
|
||
PddDeviceService(lambda _serial: device),
|
||
"USB-001",
|
||
"client-001",
|
||
sleeper=lambda _seconds: None,
|
||
max_spec_swipes=0,
|
||
)
|
||
|
||
data = service.collect(
|
||
FakeTask("https://mobile.yangkeduo.com/goods.html?goods_id=123")
|
||
).to_pdd_data()
|
||
|
||
self.assertEqual(len(data["skus"]), 4)
|
||
red_skus = [
|
||
item for item in data["skus"] if item["options"]["color"] == "红色"
|
||
]
|
||
self.assertEqual(
|
||
{item["price_observed_at"]["color"] for item in red_skus},
|
||
{"红色"},
|
||
)
|
||
self.assertTrue(
|
||
all(
|
||
set(item["price_observed_at"]) == {"color"}
|
||
for item in red_skus
|
||
)
|
||
)
|
||
# 1 次打开规格面板,另有红色点 1 次;尺码只读不点击。
|
||
self.assertEqual(len(device.clicks), 2)
|
||
|
||
def test_colors_are_sampled_in_snake_order_before_sizes_are_read(self):
|
||
device = SnakeColorDevice(self.home_xml)
|
||
service = PddCollectService(
|
||
PddDeviceService(lambda _serial: device),
|
||
"USB-001",
|
||
"client-001",
|
||
sleeper=lambda _seconds: None,
|
||
)
|
||
|
||
data = service.collect(
|
||
FakeTask("https://mobile.yangkeduo.com/goods.html?goods_id=123")
|
||
).to_pdd_data()
|
||
|
||
self.assertEqual(
|
||
device.clicked_colors,
|
||
["A色", "B色", "C色", "F色", "E色", "D色"],
|
||
)
|
||
self.assertEqual(
|
||
[item["text"] for item in data["dimensions"][0]["values"]],
|
||
["A色", "B色", "C色", "D色", "E色", "F色"],
|
||
)
|
||
self.assertEqual(
|
||
[item["text"] for item in data["dimensions"][1]["values"]],
|
||
["M", "L"],
|
||
)
|
||
color_prices = {
|
||
item["options"]["color"]: item["price_cent"]
|
||
for item in data["skus"]
|
||
if item["options"]["size"] == "M"
|
||
}
|
||
self.assertEqual(color_prices["A色"], 1000)
|
||
self.assertEqual(color_prices["B色"], 1000)
|
||
self.assertEqual(color_prices["F色"], 1500)
|
||
self.assertTrue(all(
|
||
item["price_observed_at"] == {"color": item["options"]["color"]}
|
||
for item in data["skus"]
|
||
))
|
||
|
||
def test_image_color_card_clicks_caption_instead_of_opening_big_image(self):
|
||
device = ImageColorDevice(self.home_xml)
|
||
device.panel_open = True
|
||
device.page = 0
|
||
service = PddCollectService(
|
||
PddDeviceService(lambda _serial: device),
|
||
"USB-001",
|
||
"client-001",
|
||
sleeper=lambda _seconds: None,
|
||
)
|
||
|
||
sample = service._click_and_sample_color(device, "A色")
|
||
|
||
self.assertEqual(1000, sample.price_cent)
|
||
self.assertFalse(device.big_image_open)
|
||
self.assertGreaterEqual(device.clicks[0][1], 820)
|
||
|
||
def test_big_image_recovery_presses_back_only_once(self):
|
||
device = ImageColorDevice(self.home_xml)
|
||
device.panel_open = True
|
||
device.big_image_open = True
|
||
press_calls = []
|
||
original_press = device.press
|
||
|
||
def record_press(key):
|
||
press_calls.append(key)
|
||
original_press(key)
|
||
|
||
device.press = record_press
|
||
service = PddCollectService(
|
||
PddDeviceService(lambda _serial: device),
|
||
"USB-001",
|
||
"client-001",
|
||
sleeper=lambda _seconds: None,
|
||
)
|
||
|
||
service._recover_spec_panel_from_big_image(device)
|
||
|
||
self.assertEqual(["back"], press_calls)
|
||
self.assertFalse(device.big_image_open)
|
||
|
||
def test_default_size_is_cleared_once_before_colors_and_sizes_are_collected(self):
|
||
device = DefaultSizeDevice(self.home_xml)
|
||
service = PddCollectService(
|
||
PddDeviceService(lambda _serial: device),
|
||
"USB-001",
|
||
"client-001",
|
||
sleeper=lambda _seconds: None,
|
||
)
|
||
|
||
data = service.collect(
|
||
FakeTask("https://mobile.yangkeduo.com/goods.html?goods_id=123")
|
||
).to_pdd_data()
|
||
|
||
self.assertEqual(1, device.size_clicks)
|
||
self.assertFalse(device.size_selected)
|
||
self.assertEqual(
|
||
["A色", "B色", "C色", "F色", "E色", "D色"],
|
||
device.clicked_colors,
|
||
)
|
||
self.assertEqual(
|
||
["M", "L"],
|
||
[item["text"] for item in data["dimensions"][1]["values"]],
|
||
)
|
||
|
||
def test_size_that_cannot_be_cleared_is_not_clicked_repeatedly(self):
|
||
device = DefaultSizeDevice(self.home_xml, can_clear=False)
|
||
device.panel_open = True
|
||
service = PddCollectService(
|
||
PddDeviceService(lambda _serial: device),
|
||
"USB-001",
|
||
"client-001",
|
||
sleeper=lambda _seconds: None,
|
||
)
|
||
|
||
service._clear_default_size_selection(device)
|
||
|
||
self.assertEqual(1, device.size_clicks)
|
||
self.assertTrue(device.size_selected)
|
||
|
||
def test_default_package_is_cleared_and_keeps_original_heading(self):
|
||
device = DefaultPackageDevice(self.home_xml)
|
||
service = PddCollectService(
|
||
PddDeviceService(lambda _serial: device),
|
||
"USB-001",
|
||
"client-001",
|
||
sleeper=lambda _seconds: None,
|
||
)
|
||
|
||
data = service.collect(
|
||
FakeTask("https://mobile.yangkeduo.com/goods.html?goods_id=123")
|
||
).to_pdd_data()
|
||
|
||
self.assertEqual(1, device.size_clicks)
|
||
self.assertFalse(device.size_selected)
|
||
self.assertEqual(data["dimensions"][1]["key"], "size")
|
||
self.assertEqual(data["dimensions"][1]["name"], "套餐")
|
||
|
||
def test_horizontal_packages_are_scanned_from_left_to_right_without_clicks(self):
|
||
device = HorizontalPackageDevice()
|
||
service = PddCollectService(
|
||
PddDeviceService(lambda _serial: device),
|
||
"USB-001",
|
||
"client-001",
|
||
sleeper=lambda _seconds: None,
|
||
)
|
||
|
||
result = service._collect_size_dimension(device)
|
||
|
||
self.assertIsNotNone(result)
|
||
self.assertEqual(result.name, "套餐(7)")
|
||
self.assertEqual(
|
||
[value.text for value in result.values],
|
||
list(device.packages),
|
||
)
|
||
self.assertEqual(device.clicks, [])
|
||
self.assertTrue(
|
||
all(abs(x2 - x1) > abs(y2 - y1) for x1, y1, x2, y2, _ in device.swipes)
|
||
)
|
||
|
||
def test_package_heading_count_rejects_partial_collection(self):
|
||
device = HorizontalPackageDevice(expected_count=8)
|
||
service = PddCollectService(
|
||
PddDeviceService(lambda _serial: device),
|
||
"USB-001",
|
||
"client-001",
|
||
sleeper=lambda _seconds: None,
|
||
)
|
||
|
||
with self.assertRaises(PddCollectError) as raised:
|
||
service._collect_size_dimension(device)
|
||
|
||
self.assertEqual(raised.exception.code, "PDD_DATA_SPEC_INCOMPLETE")
|
||
self.assertEqual(raised.exception.diagnostics["expected_count"], 8)
|
||
self.assertEqual(raised.exception.diagnostics["collected_count"], 7)
|
||
|
||
def test_vertical_packages_continue_after_heading_scrolls_out(self):
|
||
device = VerticalLongPackageDevice()
|
||
service = PddCollectService(
|
||
PddDeviceService(lambda _serial: device),
|
||
"USB-001",
|
||
"client-001",
|
||
sleeper=lambda _seconds: None,
|
||
)
|
||
|
||
result = service._collect_size_dimension(device)
|
||
|
||
self.assertIsNotNone(result)
|
||
self.assertEqual(result.name, "套餐(16)")
|
||
self.assertEqual(
|
||
[value.text for value in result.values],
|
||
list(device.packages),
|
||
)
|
||
self.assertNotIn("增加数量", [value.text for value in result.values])
|
||
self.assertNotIn("提交订单", [value.text for value in result.values])
|
||
self.assertNotIn("券后", [value.text for value in result.values])
|
||
self.assertTrue(all("¥" not in value.text for value in result.values))
|
||
self.assertEqual(device.clicks, [])
|
||
self.assertTrue(
|
||
all(abs(y2 - y1) > abs(x2 - x1) for x1, y1, x2, y2, _ in device.swipes)
|
||
)
|
||
|
||
def test_visible_click_is_fallback_when_page_exposes_no_selection_state(self):
|
||
xml_data = keep_only_one_sku(self.spec_xml)
|
||
root = ET.fromstring(xml_data)
|
||
for parent in root.iter():
|
||
for child in list(parent):
|
||
if (child.get("text") or "").startswith("已选"):
|
||
parent.remove(child)
|
||
xml_without_selection = ET.tostring(root, encoding="unicode")
|
||
device = FakeCollectDevice(self.home_xml, xml_without_selection)
|
||
service = PddCollectService(
|
||
PddDeviceService(lambda _serial: device),
|
||
"USB-001",
|
||
"client-001",
|
||
sleeper=lambda _seconds: None,
|
||
max_spec_swipes=0,
|
||
)
|
||
|
||
data = service.collect(
|
||
FakeTask("https://mobile.yangkeduo.com/goods.html?goods_id=123")
|
||
).to_pdd_data()
|
||
|
||
self.assertEqual(data["skus"][0]["price_cent"], 1000)
|
||
self.assertEqual(
|
||
data["skus"][0]["price_observed_at"], {"color": "红色"}
|
||
)
|
||
|
||
def test_visible_text_does_not_confirm_an_ignored_click(self):
|
||
device = IgnoredColorClickDevice(self.home_xml)
|
||
device.panel_open = True
|
||
device.page = 0
|
||
service = PddCollectService(
|
||
PddDeviceService(lambda _serial: device),
|
||
"USB-001",
|
||
"client-001",
|
||
sleeper=lambda _seconds: None,
|
||
)
|
||
|
||
sample = service._click_and_sample_color(device, "B色")
|
||
|
||
self.assertIsNone(sample.price_cent)
|
||
|
||
def test_color_price_is_checked_immediately_then_stabilized(self):
|
||
device = SnakeColorDevice(self.home_xml)
|
||
device.panel_open = True
|
||
device.page = 0
|
||
clock = FakeClock()
|
||
service = PddCollectService(
|
||
PddDeviceService(lambda _serial: device),
|
||
"USB-001",
|
||
"client-001",
|
||
sleeper=clock.sleep,
|
||
monotonic=clock.monotonic,
|
||
)
|
||
|
||
sample = service._click_and_sample_color(device, "A色")
|
||
|
||
self.assertEqual(sample.price_cent, 1000)
|
||
self.assertEqual(clock.sleeps, [0.1])
|
||
self.assertEqual(device.clicked_colors, ["A色"])
|
||
|
||
def test_ignored_color_click_stops_at_selection_timeout(self):
|
||
device = IgnoredColorClickDevice(self.home_xml)
|
||
device.panel_open = True
|
||
device.page = 0
|
||
clock = FakeClock()
|
||
service = PddCollectService(
|
||
PddDeviceService(lambda _serial: device),
|
||
"USB-001",
|
||
"client-001",
|
||
sleeper=clock.sleep,
|
||
monotonic=clock.monotonic,
|
||
color_poll_interval=0.1,
|
||
color_selection_timeout=0.3,
|
||
)
|
||
|
||
sample = service._click_and_sample_color(device, "B色")
|
||
|
||
self.assertIsNone(sample.price_cent)
|
||
self.assertLessEqual(clock.now, 0.31)
|
||
self.assertEqual(len(device.clicks), 1)
|
||
|
||
def test_selected_color_without_price_stops_at_price_timeout(self):
|
||
device = NoColorPriceDevice(self.home_xml)
|
||
device.panel_open = True
|
||
device.page = 0
|
||
clock = FakeClock()
|
||
service = PddCollectService(
|
||
PddDeviceService(lambda _serial: device),
|
||
"USB-001",
|
||
"client-001",
|
||
sleeper=clock.sleep,
|
||
monotonic=clock.monotonic,
|
||
color_poll_interval=0.1,
|
||
color_price_timeout=0.3,
|
||
)
|
||
|
||
sample = service._click_and_sample_color(device, "A色")
|
||
|
||
self.assertIsNone(sample.price_cent)
|
||
self.assertLessEqual(clock.now, 0.31)
|
||
self.assertEqual(device.clicked_colors, ["A色"])
|
||
|
||
def test_horizontal_swipe_waits_once_more_when_view_is_unchanged(self):
|
||
device = DelayedHorizontalSwipeDevice(self.home_xml)
|
||
device.panel_open = True
|
||
device.page = 0
|
||
clock = FakeClock()
|
||
service = PddCollectService(
|
||
PddDeviceService(lambda _serial: device),
|
||
"USB-001",
|
||
"client-001",
|
||
sleeper=clock.sleep,
|
||
monotonic=clock.monotonic,
|
||
)
|
||
previous = service._color_view_signature(device.dump_hierarchy())
|
||
device.swipe(800, 800, 200, 800, duration=0.35)
|
||
|
||
latest_xml = service._wait_for_horizontal_change(device, previous)
|
||
|
||
self.assertNotEqual(service._color_view_signature(latest_xml), previous)
|
||
self.assertEqual(clock.sleeps, [0.1, 0.1])
|
||
|
||
def test_color_wait_parameters_must_be_positive(self):
|
||
with self.assertRaisesRegex(ValueError, "颜色轮询间隔必须大于 0 秒"):
|
||
PddCollectService(
|
||
PddDeviceService(lambda _serial: object()),
|
||
"USB-001",
|
||
"client-001",
|
||
color_poll_interval=0,
|
||
)
|
||
|
||
def test_all_missing_color_prices_still_collects_sizes(self):
|
||
device = AllMissingColorPriceDevice(self.home_xml)
|
||
service = PddCollectService(
|
||
PddDeviceService(lambda _serial: device),
|
||
"USB-001",
|
||
"client-001",
|
||
sleeper=lambda _seconds: None,
|
||
)
|
||
|
||
data = service.collect(
|
||
FakeTask("https://mobile.yangkeduo.com/goods.html?goods_id=123")
|
||
).to_pdd_data()
|
||
|
||
self.assertEqual(
|
||
[item["text"] for item in data["dimensions"][1]["values"]],
|
||
["M", "L"],
|
||
)
|
||
self.assertTrue(device.sizes_visible)
|
||
self.assertTrue(data["skus"])
|
||
self.assertTrue(all(item["price_cent"] is None for item in data["skus"]))
|
||
self.assertTrue(all(item["raw_price"] is None for item in data["skus"]))
|
||
self.assertTrue(
|
||
all(item["price_observed_at"] == {} for item in data["skus"])
|
||
)
|
||
self.assertTrue(all(item["available"] for item in data["skus"]))
|
||
|
||
def test_missing_goods_id_is_structured_error(self):
|
||
service = PddCollectService(
|
||
PddDeviceService(lambda _serial: object()),
|
||
"USB-001",
|
||
"client-001",
|
||
)
|
||
with self.assertRaises(PddCollectError) as raised:
|
||
service.collect(FakeTask("https://mobile.yangkeduo.com/goods.html"))
|
||
self.assertEqual(raised.exception.code, "PDD_DATA_GOODS_ID_MISSING")
|
||
|
||
def test_non_pdd_goods_url_is_rejected_before_opening_device(self):
|
||
service = PddCollectService(
|
||
PddDeviceService(lambda _serial: object()),
|
||
"USB-001",
|
||
"client-001",
|
||
)
|
||
with self.assertRaises(PddCollectError) as raised:
|
||
service.collect(FakeTask("https://example.com/goods.html?goods_id=123"))
|
||
self.assertEqual(raised.exception.code, "PDD_DATA_GOODS_URL_INVALID")
|
||
|
||
def test_task_goods_id_must_match_url(self):
|
||
service = PddCollectService(
|
||
PddDeviceService(lambda _serial: object()),
|
||
"USB-001",
|
||
"client-001",
|
||
)
|
||
with self.assertRaises(PddCollectError) as raised:
|
||
service.collect(
|
||
FakeTask(
|
||
"https://mobile.yangkeduo.com/goods.html?goods_id=123",
|
||
goods_id="456",
|
||
)
|
||
)
|
||
self.assertEqual(raised.exception.code, "PDD_DATA_GOODS_ID_MISMATCH")
|
||
|
||
def test_page_timeout_has_specific_error_code(self):
|
||
device = LoadingDevice(self.home_xml, self.spec_xml)
|
||
ticks = iter((0.0, 0.0, 0.0, 2.0))
|
||
service = PddCollectService(
|
||
PddDeviceService(lambda _serial: device),
|
||
"USB-001",
|
||
"client-001",
|
||
sleeper=lambda _seconds: None,
|
||
monotonic=lambda: next(ticks),
|
||
page_timeout=1.0,
|
||
)
|
||
|
||
with self.assertRaises(PddCollectError) as raised:
|
||
service.collect(
|
||
FakeTask("https://mobile.yangkeduo.com/goods.html?goods_id=123")
|
||
)
|
||
self.assertEqual(raised.exception.code, "PDD_PAGE_TIMEOUT")
|
||
|
||
def test_stable_home_reopens_once_then_stops_before_spec_click(self):
|
||
home = (FIXTURES / "pdd_home_page.xml").read_text(encoding="utf-8")
|
||
device = FakeCollectDevice(home, self.spec_xml)
|
||
clock = FakeClock()
|
||
service = PddCollectService(
|
||
PddDeviceService(lambda _serial: device),
|
||
"USB-001",
|
||
"client-001",
|
||
sleeper=clock.sleep,
|
||
monotonic=clock.monotonic,
|
||
page_timeout=10.0,
|
||
)
|
||
|
||
with self.assertRaises(PddCollectError) as raised:
|
||
service.collect(
|
||
FakeTask("https://mobile.yangkeduo.com/goods.html?goods_id=123")
|
||
)
|
||
|
||
self.assertEqual("PDD_GOODS_UNAVAILABLE", raised.exception.code)
|
||
self.assertEqual(2, raised.exception.diagnostics["open_attempts"])
|
||
self.assertEqual([], device.clicks)
|
||
|
||
def test_slow_wrong_focus_does_not_consume_home_reopen_timeout(self):
|
||
home = (FIXTURES / "pdd_home_page.xml").read_text(encoding="utf-8")
|
||
clock = FakeClock()
|
||
device = SlowFocusMismatchDevice(home, self.spec_xml, clock)
|
||
service = PddCollectService(
|
||
PddDeviceService(lambda _serial: device),
|
||
"USB-001",
|
||
"client-001",
|
||
sleeper=clock.sleep,
|
||
monotonic=clock.monotonic,
|
||
page_timeout=10.0,
|
||
)
|
||
|
||
with self.assertRaises(PddCollectError) as raised:
|
||
service.collect(
|
||
FakeTask("https://mobile.yangkeduo.com/goods.html?goods_id=123")
|
||
)
|
||
|
||
self.assertEqual("PDD_GOODS_UNAVAILABLE", raised.exception.code)
|
||
self.assertEqual(1, device.app_current_calls)
|
||
self.assertEqual([], device.clicks)
|
||
|
||
def test_non_pdd_tree_with_purchase_words_is_not_ready(self):
|
||
device = FocusMismatchDevice(
|
||
'<hierarchy><node package="com.android.settings" text="免拼购买" '
|
||
'bounds="[0,0][100,100]" /></hierarchy>',
|
||
self.spec_xml,
|
||
)
|
||
ticks = iter((0.0, 0.0, 0.0, 2.0))
|
||
service = PddCollectService(
|
||
PddDeviceService(lambda _serial: device),
|
||
"USB-001",
|
||
"client-001",
|
||
sleeper=lambda _seconds: None,
|
||
monotonic=lambda: next(ticks),
|
||
page_timeout=1.0,
|
||
)
|
||
|
||
with self.assertRaises(PddCollectError) as raised:
|
||
service.collect(
|
||
FakeTask("https://mobile.yangkeduo.com/goods.html?goods_id=123")
|
||
)
|
||
self.assertEqual(raised.exception.code, "PDD_PAGE_TIMEOUT")
|
||
|
||
def test_page_timeout_saves_last_xml_diagnostic(self):
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
device = LoadingDevice(self.home_xml, self.spec_xml)
|
||
clock = FakeClock()
|
||
service = PddCollectService(
|
||
PddDeviceService(lambda _serial: device),
|
||
"USB-001",
|
||
"client-001",
|
||
sleeper=clock.sleep,
|
||
monotonic=clock.monotonic,
|
||
page_timeout=1.0,
|
||
artifact_directory=Path(directory),
|
||
)
|
||
|
||
with self.assertRaises(PddCollectError) as raised:
|
||
service.collect(
|
||
FakeTask("https://mobile.yangkeduo.com/goods.html?goods_id=123")
|
||
)
|
||
|
||
artifacts = raised.exception.diagnostics["artifacts"]
|
||
self.assertEqual(len(artifacts), 1)
|
||
self.assertTrue(Path(artifacts[0]["path"]).is_file())
|
||
self.assertEqual(len(artifacts[0]["sha256"]), 64)
|
||
|
||
def test_runtime_device_disconnect_has_stable_error_code(self):
|
||
service = PddCollectService(
|
||
PddDeviceService(
|
||
lambda _serial: DisconnectingDevice(self.home_xml, self.spec_xml)
|
||
),
|
||
"USB-001",
|
||
"client-001",
|
||
)
|
||
with self.assertRaises(PddCollectError) as raised:
|
||
service.collect(
|
||
FakeTask("https://mobile.yangkeduo.com/goods.html?goods_id=123")
|
||
)
|
||
self.assertEqual(raised.exception.code, "DEVICE_DISCONNECTED")
|
||
|
||
def test_missing_shop_name_is_allowed(self):
|
||
incomplete_home = self.home_xml.replace(
|
||
'<node class="android.widget.TextView" text="店铺:测试服饰旗舰店" '
|
||
'bounds="[30,1050][600,1110]" visible-to-user="true" '
|
||
'enabled="true" />',
|
||
"",
|
||
)
|
||
device = FakeCollectDevice(incomplete_home, self.spec_xml)
|
||
service = PddCollectService(
|
||
PddDeviceService(lambda _serial: device),
|
||
"USB-001",
|
||
"client-001",
|
||
sleeper=lambda _seconds: None,
|
||
)
|
||
|
||
result = service.collect(
|
||
FakeTask("https://mobile.yangkeduo.com/goods.html?goods_id=123")
|
||
)
|
||
self.assertIsNone(result.shop_name)
|
||
|
||
def test_missing_reviews_stops_when_goods_page_no_longer_changes(self):
|
||
home_without_reviews = self.home_xml.replace(
|
||
'<node class="android.widget.TextView" text="2356条评价" '
|
||
'bounds="[330,950][600,1010]" visible-to-user="true" '
|
||
'enabled="true" />',
|
||
"",
|
||
)
|
||
device = FakeCollectDevice(
|
||
home_without_reviews, keep_only_one_sku(self.spec_xml)
|
||
)
|
||
service = PddCollectService(
|
||
PddDeviceService(lambda _serial: device),
|
||
"USB-001",
|
||
"client-001",
|
||
sleeper=lambda _seconds: None,
|
||
max_spec_swipes=0,
|
||
)
|
||
|
||
result = service.collect(
|
||
FakeTask("https://mobile.yangkeduo.com/goods.html?goods_id=123")
|
||
)
|
||
|
||
self.assertIsNone(result.reviews.raw)
|
||
self.assertTrue(device.clicks)
|
||
self.assertEqual(device.swipe_panel_states[:2], [False, False])
|
||
|
||
def test_goods_metadata_default_swipe_limit_is_three(self):
|
||
pages = [
|
||
f"""<hierarchy>
|
||
<node class="androidx.viewpager.widget.ViewPager"
|
||
content-desc="测试商品标题第{index}屏内容"
|
||
bounds="[0,100][1080,500]" />
|
||
<node class="android.widget.TextView" text="已拼12000件"
|
||
bounds="[30,600][300,660]" />
|
||
<node class="androidx.recyclerview.widget.RecyclerView"
|
||
scrollable="true" bounds="[0,0][1080,1800]" />
|
||
</hierarchy>"""
|
||
for index in range(7)
|
||
]
|
||
device = GoodsMetadataScrollDevice(pages, self.spec_xml)
|
||
device.opened_url = "https://mobile.yangkeduo.com/goods.html?goods_id=123"
|
||
service = PddCollectService(
|
||
PddDeviceService(lambda _serial: device),
|
||
"USB-001",
|
||
"client-001",
|
||
sleeper=lambda _seconds: None,
|
||
)
|
||
|
||
result = service._collect_goods_details(device)
|
||
|
||
self.assertIsNone(result.shop_name)
|
||
self.assertIsNone(result.reviews.raw)
|
||
self.assertEqual(3, len(device.swipes))
|
||
self.assertEqual(3, device.page_index)
|
||
|
||
def test_cancel_during_goods_page_scroll_stops_before_spec_panel(self):
|
||
incomplete_home = self.home_xml.replace(
|
||
'<node class="android.widget.TextView" text="2356条评价" '
|
||
'bounds="[330,950][600,1010]" visible-to-user="true" '
|
||
'enabled="true" />',
|
||
"",
|
||
).replace(
|
||
'<node class="android.widget.TextView" text="店铺:测试服饰旗舰店" '
|
||
'bounds="[30,1050][600,1110]" visible-to-user="true" '
|
||
'enabled="true" />',
|
||
"",
|
||
)
|
||
cancelled = {"value": False}
|
||
device = FakeCollectDevice(incomplete_home, self.spec_xml)
|
||
service = PddCollectService(
|
||
PddDeviceService(lambda _serial: device),
|
||
"USB-001",
|
||
"client-001",
|
||
sleeper=lambda _seconds: cancelled.update(value=True),
|
||
cancelled=lambda: cancelled["value"],
|
||
)
|
||
|
||
with self.assertRaises(PddCollectError) as raised:
|
||
service.collect(
|
||
FakeTask("https://mobile.yangkeduo.com/goods.html?goods_id=123")
|
||
)
|
||
|
||
self.assertEqual(raised.exception.code, "PDD_CANCELLED")
|
||
self.assertEqual(device.swipe_panel_states, [False])
|
||
self.assertEqual(device.clicks, [])
|
||
|
||
def test_spec_panel_timeout_has_stable_code_and_xml_evidence(self):
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
device = PanelDoesNotOpenDevice(self.home_xml, self.spec_xml)
|
||
tick = {"value": -0.5}
|
||
|
||
def clock():
|
||
tick["value"] += 0.5
|
||
return tick["value"]
|
||
|
||
service = PddCollectService(
|
||
PddDeviceService(lambda _serial: device),
|
||
"USB-001",
|
||
"client-001",
|
||
sleeper=lambda _seconds: None,
|
||
monotonic=clock,
|
||
spec_panel_timeout=1.0,
|
||
artifact_directory=Path(directory),
|
||
)
|
||
|
||
with self.assertRaises(PddCollectError) as raised:
|
||
service.collect(
|
||
FakeTask("https://mobile.yangkeduo.com/goods.html?goods_id=123")
|
||
)
|
||
|
||
self.assertEqual(
|
||
raised.exception.code, "PDD_PAGE_SPEC_PANEL_TIMEOUT"
|
||
)
|
||
artifacts = raised.exception.diagnostics["artifacts"]
|
||
self.assertTrue(Path(artifacts[0]["path"]).is_file())
|
||
|
||
def test_transient_empty_spec_trees_are_skipped_during_full_collection(self):
|
||
device = TransientSpecTreeDevice(
|
||
self.home_xml, keep_only_one_sku(self.spec_xml)
|
||
)
|
||
service = PddCollectService(
|
||
PddDeviceService(lambda _serial: device),
|
||
"USB-001",
|
||
"client-001",
|
||
sleeper=lambda _seconds: None,
|
||
max_spec_swipes=0,
|
||
)
|
||
|
||
result = service.collect(
|
||
FakeTask("https://mobile.yangkeduo.com/goods.html?goods_id=123")
|
||
)
|
||
|
||
self.assertEqual("测试纯棉短袖商品", result.title)
|
||
self.assertTrue(result.skus)
|
||
self.assertGreater(device.panel_reads, 4)
|
||
self.assertEqual(TRANSIENT_EMPTY_SPEC_XML, service._last_invalid_spec_xml)
|
||
self.assertIn("颜色分类", service._last_valid_spec_xml)
|
||
|
||
def test_persistent_empty_spec_tree_has_lost_code_and_dual_evidence(self):
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
device = SpecTreeStaysBlankDevice(
|
||
self.home_xml, keep_only_one_sku(self.spec_xml)
|
||
)
|
||
clock = FakeClock()
|
||
service = PddCollectService(
|
||
PddDeviceService(lambda _serial: device),
|
||
"USB-001",
|
||
"client-001",
|
||
sleeper=clock.sleep,
|
||
monotonic=clock.monotonic,
|
||
spec_panel_timeout=1.0,
|
||
max_spec_swipes=0,
|
||
artifact_directory=Path(directory),
|
||
)
|
||
|
||
with self.assertRaises(PddCollectError) as raised:
|
||
service.collect(
|
||
FakeTask(
|
||
"https://mobile.yangkeduo.com/goods.html?goods_id=123"
|
||
)
|
||
)
|
||
|
||
self.assertEqual("PDD_PAGE_SPEC_PANEL_LOST", raised.exception.code)
|
||
paths = [
|
||
Path(item["path"]).name
|
||
for item in raised.exception.diagnostics["artifacts"]
|
||
]
|
||
self.assertTrue(any(name.startswith("last-valid-spec-") for name in paths))
|
||
self.assertTrue(any(name.startswith("last-invalid-spec-") for name in paths))
|
||
self.assertLessEqual(clock.now, 1.2)
|
||
|
||
def test_special_page_is_not_retried_as_transient_empty_tree(self):
|
||
device = FakeCollectDevice(self.home_xml, self.spec_xml)
|
||
device.panel_open = True
|
||
device.opened_url = "https://mobile.yangkeduo.com/goods.html?goods_id=123"
|
||
device.spec_xml = (
|
||
'<hierarchy><node text="请完成验证" '
|
||
'package="com.xunmeng.pinduoduo" /></hierarchy>'
|
||
)
|
||
clock = FakeClock()
|
||
service = PddCollectService(
|
||
PddDeviceService(lambda _serial: device),
|
||
"USB-001",
|
||
"client-001",
|
||
sleeper=clock.sleep,
|
||
monotonic=clock.monotonic,
|
||
)
|
||
|
||
with self.assertRaises(PddCollectError) as raised:
|
||
service._read_valid_spec_panel(device)
|
||
|
||
self.assertEqual("PDD_PAGE_CAPTCHA", raised.exception.code)
|
||
self.assertEqual([], clock.sleeps)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|