967 lines
35 KiB
Python
967 lines
35 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,
|
|
parse_goods_page,
|
|
parse_quantity,
|
|
parse_spec_panel,
|
|
)
|
|
from src.pdd_device_service import PddDeviceService
|
|
|
|
|
|
FIXTURES = Path(__file__).parent / "fixtures"
|
|
|
|
|
|
@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
|
|
|
|
def app_current(self):
|
|
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):
|
|
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.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.current_calls = 0
|
|
|
|
def app_current(self):
|
|
self.current_calls += 1
|
|
if self.current_calls > 1:
|
|
raise RuntimeError("device not found")
|
|
return super().app_current()
|
|
|
|
|
|
class FocusMismatchDevice(FakeCollectDevice):
|
|
"""系统焦点报告为设置页,但无障碍树实际属于 PDD。"""
|
|
|
|
def app_current(self):
|
|
return {"package": "com.oplus.wirelesssettings"}
|
|
|
|
def app_start(self, _package):
|
|
self.app_started = True
|
|
|
|
|
|
class PanelDoesNotOpenDevice(FakeCollectDevice):
|
|
def click(self, x, y):
|
|
self.clicks.append((x, y))
|
|
|
|
|
|
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 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 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")
|
|
|
|
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_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)
|
|
|
|
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_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_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)
|
|
ticks = iter((0.0, 0.0, 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,
|
|
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_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())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|