736 lines
27 KiB
Python
736 lines
27 KiB
Python
"""uiautomator2 采购演练 Adapter 测试;不连接真实手机。"""
|
|
|
|
from pathlib import Path
|
|
import tempfile
|
|
import unittest
|
|
import xml.etree.ElementTree as ET
|
|
|
|
from src.pdd_device_service import PddDeviceService
|
|
from src.pdd_purchase_adapter import PddPurchaseError
|
|
from src.performance_timing import TaskPerformanceTrace
|
|
from src.pdd_u2_purchase_adapter import (
|
|
U2PddLivePurchaseAdapter,
|
|
U2PddPurchaseAdapter,
|
|
_final_submit_targets,
|
|
_page_kind,
|
|
)
|
|
|
|
|
|
GOODS_URL = "https://mobile.yangkeduo.com/goods.html?goods_id=753136429979"
|
|
FIXTURES = Path(__file__).parent / "fixtures"
|
|
|
|
|
|
class FakeClock:
|
|
def __init__(self) -> None:
|
|
self.now = 0.0
|
|
|
|
def monotonic(self) -> float:
|
|
return self.now
|
|
|
|
def sleep(self, seconds: float) -> None:
|
|
self.now += seconds
|
|
|
|
|
|
def home_xml() -> str:
|
|
return """<hierarchy>
|
|
<node package="com.xunmeng.pinduoduo" bounds="[0,0][1080,2376]">
|
|
<node package="com.xunmeng.pinduoduo" clickable="true"
|
|
enabled="true" visible-to-user="true"
|
|
bounds="[500,2100][1080,2328]">
|
|
<node package="com.xunmeng.pinduoduo" text="立即购买 ¥5.03"
|
|
enabled="true" visible-to-user="true"
|
|
bounds="[600,2150][1000,2280]"/>
|
|
</node>
|
|
</node>
|
|
</hierarchy>"""
|
|
|
|
|
|
def panel_xml(quantity: int = 1) -> str:
|
|
return f"""<hierarchy>
|
|
<node package="com.xunmeng.pinduoduo" bounds="[0,0][1080,2376]">
|
|
<node text="已选: 黑色 3XL【140-165斤】" bounds="[300,500][1000,560]"/>
|
|
<node content-desc="黑色" selected="true" bounds="[30,900][330,1000]"/>
|
|
<node text="3XL【140-165斤】" selected="true"
|
|
bounds="[40,1200][500,1300]"/>
|
|
<node text="折后¥5.03" bounds="[300,400][600,470]"/>
|
|
<node class="android.widget.EditText" text="{quantity}"
|
|
bounds="[480,700][560,780]"/>
|
|
<node content-desc="减少数量" clickable="true" bounds="[390,700][470,780]"/>
|
|
<node content-desc="增加数量" clickable="true" bounds="[570,700][650,780]"/>
|
|
<node clickable="true" bounds="[0,2181][1080,2328]">
|
|
<node text="提交订单 ¥5.03" enabled="true" visible-to-user="true"
|
|
bounds="[380,2220][700,2290]"/>
|
|
</node>
|
|
</node>
|
|
</hierarchy>"""
|
|
|
|
|
|
def contextual_confirm_panel_xml(quantity: int = 1) -> str:
|
|
"""模拟商品 897186799891 的非滚动“确定”规格面板。"""
|
|
|
|
return f"""<hierarchy>
|
|
<node package="com.xunmeng.pinduoduo" bounds="[0,0][1080,2340]">
|
|
<node package="com.xunmeng.pinduoduo" class="android.widget.RelativeLayout"
|
|
bounds="[0,720][1080,2214]" visible-to-user="true" enabled="true">
|
|
<node package="com.xunmeng.pinduoduo" text="确认款式"
|
|
bounds="[438,746][642,814]"/>
|
|
<node text="券后 ¥8.17" bounds="[324,852][573,919]"/>
|
|
<node text="已选择: 黑色 均码" bounds="[324,1009][1044,1062]"/>
|
|
<node class="android.widget.EditText" text="{quantity}"
|
|
bounds="[408,1080][489,1155]"/>
|
|
<node content-desc="减少数量" clickable="true" enabled="true"
|
|
visible-to-user="true" bounds="[324,1080][402,1155]"/>
|
|
<node content-desc="增加数量" clickable="true" enabled="true"
|
|
visible-to-user="true" bounds="[495,1080][573,1155]"/>
|
|
<node text="颜色分类" bounds="[36,1214][216,1267]"/>
|
|
<node text="黑色" clickable="true" selected="true"
|
|
bounds="[36,1295][276,1382]"/>
|
|
<node text="尺码" bounds="[36,1684][126,1737]"/>
|
|
<node text="均码" clickable="true" selected="true"
|
|
bounds="[36,1765][711,1852]"/>
|
|
<node text="确定" clickable="true" enabled="true"
|
|
visible-to-user="true" bounds="[0,2067][1080,2214]"/>
|
|
</node>
|
|
</node>
|
|
</hierarchy>"""
|
|
|
|
|
|
class FakeEditor:
|
|
def __init__(self, device) -> None:
|
|
self.device = device
|
|
self.count = 1
|
|
|
|
def set_text(self, value: str) -> None:
|
|
self.device.editor_values.append(value)
|
|
self.device.quantity = int(value)
|
|
if self.device.show_keyboard_on_edit:
|
|
self.device.keyboard_shown = True
|
|
|
|
|
|
class FakeDevice:
|
|
def __init__(self, initial_xml: str = "") -> None:
|
|
self.mode = "special" if initial_xml else "home"
|
|
self.special_xml = initial_xml
|
|
self.quantity = 1
|
|
self.clicks = []
|
|
self.opened_urls = []
|
|
self.app_wait_calls = 0
|
|
self.app_current_calls = 0
|
|
self.has_opened = False
|
|
self.editor_values = []
|
|
self.show_keyboard_on_edit = True
|
|
self.keyboard_shown = False
|
|
self.keyboard_status_known = True
|
|
self.keyboard_can_dismiss = True
|
|
self.close_panel_on_back = False
|
|
self.presses = []
|
|
|
|
def app_current(self):
|
|
self.app_current_calls += 1
|
|
return {"package": "com.xunmeng.pinduoduo"}
|
|
|
|
def app_start(self, _package):
|
|
pass
|
|
|
|
def app_wait(self, _package, timeout=10):
|
|
self.app_wait_calls += 1
|
|
return True
|
|
|
|
def open_url(self, url):
|
|
self.opened_urls.append(url)
|
|
self.has_opened = True
|
|
|
|
def dump_hierarchy(self):
|
|
if not self.has_opened:
|
|
return '<hierarchy><node package="com.xunmeng.pinduoduo" /></hierarchy>'
|
|
if self.mode == "special":
|
|
return self.special_xml
|
|
return home_xml() if self.mode == "home" else panel_xml(self.quantity)
|
|
|
|
def click(self, x, y):
|
|
self.clicks.append((x, y))
|
|
if self.mode == "panel" and 570 <= x <= 650 and 700 <= y <= 780:
|
|
self.quantity += 1
|
|
return
|
|
if self.mode == "panel" and 390 <= x <= 470 and 700 <= y <= 780:
|
|
self.quantity -= 1
|
|
return
|
|
self.mode = "panel"
|
|
|
|
def shell(self, command):
|
|
self.asserted_shell_command = command
|
|
if not self.keyboard_status_known:
|
|
return "input method state unavailable"
|
|
value = "true" if self.keyboard_shown else "false"
|
|
return f"mInputShown={value} mIsInputViewShown={value}"
|
|
|
|
def press(self, key):
|
|
self.presses.append(key)
|
|
if key == "back" and self.keyboard_can_dismiss:
|
|
self.keyboard_shown = False
|
|
if self.close_panel_on_back:
|
|
self.mode = "home"
|
|
|
|
def __call__(self, **selector):
|
|
if selector == {"className": "android.widget.EditText"}:
|
|
return FakeEditor(self)
|
|
raise AssertionError(f"未预期的 selector: {selector}")
|
|
|
|
|
|
class ColdStartDevice(FakeDevice):
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.current_calls = 0
|
|
self.app_started = False
|
|
|
|
def app_current(self):
|
|
self.current_calls += 1
|
|
package = (
|
|
"com.android.launcher"
|
|
if self.current_calls == 1
|
|
else "com.xunmeng.pinduoduo"
|
|
)
|
|
return {"package": package}
|
|
|
|
def app_start(self, _package):
|
|
self.app_started = True
|
|
|
|
|
|
class StaleGoodsDevice(FakeDevice):
|
|
"""深链打开前后都停留在同一个旧商品页。"""
|
|
|
|
def dump_hierarchy(self):
|
|
return home_xml()
|
|
|
|
|
|
class SlowSettingsFocusDevice(FakeDevice):
|
|
"""模拟真机:前台查询慢,而且错误报告为设置页。"""
|
|
|
|
def __init__(self, clock: FakeClock) -> None:
|
|
super().__init__()
|
|
self.clock = clock
|
|
|
|
def app_current(self):
|
|
self.app_current_calls += 1
|
|
self.clock.sleep(11.0)
|
|
return {"package": "com.android.settings"}
|
|
|
|
|
|
class PanelDoesNotOpenDevice(FakeDevice):
|
|
def click(self, x, y):
|
|
self.clicks.append((x, y))
|
|
|
|
|
|
class ContextualConfirmPanelDevice(FakeDevice):
|
|
def dump_hierarchy(self):
|
|
if not self.has_opened:
|
|
return '<hierarchy><node package="com.xunmeng.pinduoduo" /></hierarchy>'
|
|
if self.mode == "special":
|
|
return self.special_xml
|
|
if self.mode == "panel":
|
|
return contextual_confirm_panel_xml(self.quantity)
|
|
return home_xml()
|
|
|
|
def click(self, x, y):
|
|
self.clicks.append((x, y))
|
|
if self.mode == "panel" and 495 <= x <= 573 and 1080 <= y <= 1155:
|
|
self.quantity += 1
|
|
return
|
|
self.mode = "panel"
|
|
|
|
|
|
class U2PddPurchaseAdapterTest(unittest.TestCase):
|
|
def _adapter(self, device, calls):
|
|
def select_color_fn(_device, _xml, target, **_kwargs):
|
|
calls.append(("color", target))
|
|
return target == "黑色"
|
|
|
|
def select_size_fn(_device, _xml, target, **_kwargs):
|
|
calls.append(("size", target))
|
|
return target == "3XL【140-165斤】"
|
|
|
|
return U2PddPurchaseAdapter(
|
|
"USB-001",
|
|
device_service=PddDeviceService(connector=lambda _serial: device),
|
|
sleeper=lambda _seconds: None,
|
|
select_color_fn=select_color_fn,
|
|
select_size_fn=select_size_fn,
|
|
)
|
|
|
|
def _live_adapter(self, device, calls):
|
|
def select_color_fn(_device, _xml, target, **_kwargs):
|
|
calls.append(("color", target))
|
|
return target == "黑色"
|
|
|
|
def select_size_fn(_device, _xml, target, **_kwargs):
|
|
calls.append(("size", target))
|
|
return target == "3XL【140-165斤】"
|
|
|
|
return U2PddLivePurchaseAdapter(
|
|
"USB-001",
|
|
device_service=PddDeviceService(connector=lambda _serial: device),
|
|
sleeper=lambda _seconds: None,
|
|
select_color_fn=select_color_fn,
|
|
select_size_fn=select_size_fn,
|
|
)
|
|
|
|
def test_dry_run_reaches_confirmation_without_clicking_submit(self):
|
|
device = FakeDevice()
|
|
calls = []
|
|
adapter = self._adapter(device, calls)
|
|
|
|
adapter.open_goods(GOODS_URL)
|
|
goods = adapter.read_state()
|
|
adapter.select_options(
|
|
{"color": "黑色", "size": "3XL【140-165斤】"}
|
|
)
|
|
adapter.set_quantity(2)
|
|
confirmation = adapter.read_state()
|
|
adapter.enter_confirmation()
|
|
adapter.stop_before_submit()
|
|
adapter.close()
|
|
|
|
self.assertEqual(goods.page_kind, "goods")
|
|
self.assertEqual(goods.goods_id, "753136429979")
|
|
self.assertEqual(
|
|
confirmation.selected_options,
|
|
{"color": "黑色", "size": "3XL【140-165斤】"},
|
|
)
|
|
self.assertEqual(confirmation.quantity, 2)
|
|
self.assertEqual(confirmation.price_cent, 503)
|
|
self.assertEqual(confirmation.page_kind, "order_confirmation")
|
|
self.assertEqual(calls, [("color", "黑色"), ("size", "3XL【140-165斤】")])
|
|
# 点击一次商品页采购入口和一次加号;最终“提交订单”从不点击。
|
|
self.assertEqual(len(device.clicks), 2)
|
|
self.assertEqual(device.editor_values, [])
|
|
self.assertEqual(device.app_wait_calls, 0)
|
|
|
|
def test_contextual_confirm_panel_is_reliable_without_clicking_confirm(self):
|
|
device = ContextualConfirmPanelDevice()
|
|
calls = []
|
|
|
|
def select_color_fn(_device, _xml, target, **_kwargs):
|
|
calls.append(("color", target))
|
|
return target == "黑色"
|
|
|
|
def select_size_fn(_device, _xml, target, **_kwargs):
|
|
calls.append(("size", target))
|
|
return target == "均码"
|
|
|
|
adapter = U2PddPurchaseAdapter(
|
|
"USB-001",
|
|
device_service=PddDeviceService(connector=lambda _serial: device),
|
|
sleeper=lambda _seconds: None,
|
|
select_color_fn=select_color_fn,
|
|
select_size_fn=select_size_fn,
|
|
)
|
|
|
|
adapter.open_goods(GOODS_URL)
|
|
adapter.select_options({"color": "黑色", "size": "均码"})
|
|
state = adapter.read_state()
|
|
adapter.stop_before_submit()
|
|
|
|
self.assertEqual(state.page_kind, "order_confirmation")
|
|
self.assertEqual(state.submit_candidate_count, 1)
|
|
self.assertEqual(calls, [("color", "黑色"), ("size", "均码")])
|
|
# dry-run 只点击商品页采购入口,不点击底部“确定”。
|
|
self.assertEqual(device.clicks, [(790, 2214)])
|
|
adapter.close()
|
|
|
|
def test_purchase_panel_recognizes_style_and_package_headings(self):
|
|
xml_data = contextual_confirm_panel_xml().replace(
|
|
'text="颜色分类"', 'text="款式"'
|
|
).replace('text="尺码"', 'text="套餐"')
|
|
root = ET.fromstring(xml_data)
|
|
|
|
self.assertEqual(_page_kind(root, ""), "order_confirmation")
|
|
self.assertEqual(len(_final_submit_targets(root)), 1)
|
|
|
|
def test_plain_confirm_dialog_is_not_a_submit_target(self):
|
|
root = ET.fromstring("""<hierarchy>
|
|
<node package="com.xunmeng.pinduoduo" bounds="[0,0][1080,2340]">
|
|
<node package="com.xunmeng.pinduoduo" text="提示"
|
|
bounds="[100,800][980,1400]">
|
|
<node package="com.xunmeng.pinduoduo" text="确定"
|
|
clickable="true" enabled="true" visible-to-user="true"
|
|
bounds="[300,1250][780,1380]"/>
|
|
</node>
|
|
</node>
|
|
</hierarchy>""")
|
|
|
|
self.assertEqual(_page_kind(root, ""), "unknown")
|
|
self.assertEqual(_final_submit_targets(root), [])
|
|
|
|
def test_live_adapter_clicks_contextual_confirm_only_once(self):
|
|
device = ContextualConfirmPanelDevice()
|
|
adapter = U2PddLivePurchaseAdapter(
|
|
"USB-001",
|
|
device_service=PddDeviceService(connector=lambda _serial: device),
|
|
sleeper=lambda _seconds: None,
|
|
select_color_fn=lambda *_args, **_kwargs: True,
|
|
select_size_fn=lambda *_args, **_kwargs: True,
|
|
)
|
|
adapter.open_goods(GOODS_URL)
|
|
adapter.select_options({"color": "黑色", "size": "均码"})
|
|
|
|
adapter.submit_order_once()
|
|
with self.assertRaises(PddPurchaseError) as raised:
|
|
adapter.submit_order_once()
|
|
|
|
self.assertEqual(raised.exception.code, "PURCHASE_SUBMIT_ALREADY_ATTEMPTED")
|
|
# 采购入口一次,底部“确定”一次。
|
|
self.assertEqual(len(device.clicks), 2)
|
|
self.assertEqual(device.clicks[-1], (540, 2140))
|
|
adapter.close()
|
|
|
|
def test_reliable_pdd_tree_skips_repeated_app_current(self):
|
|
device = FakeDevice()
|
|
adapter = self._adapter(device, [])
|
|
adapter.open_goods(GOODS_URL)
|
|
calls_after_open = device.app_current_calls
|
|
|
|
state = adapter.read_state()
|
|
|
|
self.assertEqual(state.page_kind, "goods")
|
|
self.assertEqual(device.app_current_calls, calls_after_open)
|
|
adapter.close()
|
|
|
|
def test_ambiguous_tree_falls_back_to_app_current(self):
|
|
device = FakeDevice()
|
|
adapter = self._adapter(device, [])
|
|
adapter.open_goods(GOODS_URL)
|
|
calls_after_open = device.app_current_calls
|
|
device.mode = "special"
|
|
device.special_xml = (
|
|
'<hierarchy><node package="com.xunmeng.pinduoduo" '
|
|
'text="占位"/></hierarchy>'
|
|
)
|
|
|
|
adapter.read_state()
|
|
|
|
self.assertEqual(device.app_current_calls, calls_after_open + 1)
|
|
adapter.close()
|
|
|
|
def test_purchase_state_and_option_stages_are_recorded(self):
|
|
device = FakeDevice()
|
|
adapter = self._adapter(device, [])
|
|
records = []
|
|
trace = TaskPerformanceTrace(sink=records.append)
|
|
trace.bind_task("PUR-PERFORMANCE")
|
|
|
|
with trace.activate():
|
|
adapter.open_goods(GOODS_URL)
|
|
adapter.read_state()
|
|
adapter.select_options(
|
|
{"color": "黑色", "size": "3XL【140-165斤】"}
|
|
)
|
|
|
|
operations = [record["operation"] for record in records]
|
|
self.assertIn("purchase_read_state", operations)
|
|
self.assertIn("purchase_select_color", operations)
|
|
self.assertIn("purchase_select_size", operations)
|
|
adapter.close()
|
|
|
|
def test_same_quantity_does_not_focus_editor(self):
|
|
device = FakeDevice()
|
|
adapter = self._adapter(device, [])
|
|
adapter.open_goods(GOODS_URL)
|
|
adapter.select_options({"color": "黑色", "size": "3XL【140-165斤】"})
|
|
|
|
adapter.set_quantity(1)
|
|
|
|
self.assertEqual(device.editor_values, [])
|
|
self.assertEqual(device.presses, [])
|
|
self.assertEqual(len(device.clicks), 1)
|
|
adapter.close()
|
|
|
|
def test_small_quantity_difference_uses_buttons(self):
|
|
device = FakeDevice()
|
|
adapter = self._adapter(device, [])
|
|
adapter.open_goods(GOODS_URL)
|
|
adapter.select_options({"color": "黑色", "size": "3XL【140-165斤】"})
|
|
|
|
adapter.set_quantity(4)
|
|
|
|
self.assertEqual(device.quantity, 4)
|
|
self.assertEqual(device.editor_values, [])
|
|
self.assertEqual(device.presses, [])
|
|
# 采购入口一次,加号三次。
|
|
self.assertEqual(len(device.clicks), 4)
|
|
adapter.close()
|
|
|
|
def test_large_quantity_difference_closes_visible_keyboard_once(self):
|
|
device = FakeDevice()
|
|
adapter = self._adapter(device, [])
|
|
adapter.open_goods(GOODS_URL)
|
|
adapter.select_options({"color": "黑色", "size": "3XL【140-165斤】"})
|
|
|
|
adapter.set_quantity(7)
|
|
|
|
self.assertEqual(device.editor_values, ["7"])
|
|
self.assertEqual(device.presses, ["back"])
|
|
self.assertFalse(device.keyboard_shown)
|
|
adapter.close()
|
|
|
|
def test_editor_does_not_press_back_when_keyboard_is_already_hidden(self):
|
|
device = FakeDevice()
|
|
device.show_keyboard_on_edit = False
|
|
adapter = self._adapter(device, [])
|
|
adapter.open_goods(GOODS_URL)
|
|
adapter.select_options({"color": "黑色", "size": "3XL【140-165斤】"})
|
|
|
|
adapter.set_quantity(7)
|
|
|
|
self.assertEqual(device.editor_values, ["7"])
|
|
self.assertEqual(device.presses, [])
|
|
adapter.close()
|
|
|
|
def test_editor_stops_when_keyboard_cannot_be_closed(self):
|
|
device = FakeDevice()
|
|
device.keyboard_can_dismiss = False
|
|
adapter = self._adapter(device, [])
|
|
adapter.open_goods(GOODS_URL)
|
|
adapter.select_options({"color": "黑色", "size": "3XL【140-165斤】"})
|
|
|
|
with self.assertRaises(PddPurchaseError) as raised:
|
|
adapter.set_quantity(7)
|
|
|
|
self.assertEqual(raised.exception.code, "PURCHASE_KEYBOARD_DISMISS_FAILED")
|
|
self.assertEqual(device.presses, ["back"])
|
|
adapter.close()
|
|
|
|
def test_editor_does_not_press_back_when_keyboard_state_is_unknown(self):
|
|
device = FakeDevice()
|
|
device.keyboard_status_known = False
|
|
adapter = self._adapter(device, [])
|
|
adapter.open_goods(GOODS_URL)
|
|
adapter.select_options({"color": "黑色", "size": "3XL【140-165斤】"})
|
|
|
|
with self.assertRaises(PddPurchaseError) as raised:
|
|
adapter.set_quantity(7)
|
|
|
|
self.assertEqual(raised.exception.code, "PURCHASE_KEYBOARD_DISMISS_FAILED")
|
|
self.assertEqual(device.presses, [])
|
|
adapter.close()
|
|
|
|
def test_editor_stops_when_back_closes_specification_panel(self):
|
|
device = FakeDevice()
|
|
device.close_panel_on_back = True
|
|
adapter = self._adapter(device, [])
|
|
adapter.open_goods(GOODS_URL)
|
|
adapter.select_options({"color": "黑色", "size": "3XL【140-165斤】"})
|
|
|
|
with self.assertRaises(PddPurchaseError) as raised:
|
|
adapter.set_quantity(7)
|
|
|
|
self.assertEqual(raised.exception.code, "PURCHASE_PANEL_LOST_AFTER_KEYBOARD")
|
|
self.assertEqual(device.presses, ["back"])
|
|
adapter.close()
|
|
|
|
def test_unsupported_dynamic_option_stops_before_click(self):
|
|
device = FakeDevice()
|
|
adapter = self._adapter(device, [])
|
|
adapter.open_goods(GOODS_URL)
|
|
|
|
with self.assertRaises(PddPurchaseError) as raised:
|
|
adapter.select_options({"bundle": "标准版"})
|
|
|
|
self.assertEqual(raised.exception.code, "PURCHASE_OPTIONS_UNSUPPORTED")
|
|
self.assertEqual(device.clicks, [])
|
|
adapter.close()
|
|
|
|
def test_cold_start_waits_for_pdd_once(self):
|
|
device = ColdStartDevice()
|
|
adapter = self._adapter(device, [])
|
|
|
|
adapter.open_goods(GOODS_URL)
|
|
|
|
self.assertTrue(device.app_started)
|
|
self.assertEqual(device.app_wait_calls, 1)
|
|
adapter.close()
|
|
|
|
def test_captcha_stops_without_waiting_or_clicking(self):
|
|
device = FakeDevice(
|
|
'<hierarchy><node package="com.xunmeng.pinduoduo" '
|
|
'text="请完成验证" bounds="[0,0][1080,2376]"/></hierarchy>'
|
|
)
|
|
adapter = self._adapter(device, [])
|
|
|
|
with self.assertRaises(PddPurchaseError) as raised:
|
|
adapter.open_goods(GOODS_URL)
|
|
|
|
self.assertEqual(raised.exception.code, "PDD_PAGE_CAPTCHA")
|
|
self.assertEqual(device.clicks, [])
|
|
|
|
def test_stable_home_reopens_once_then_stops_before_click(self):
|
|
device = FakeDevice(
|
|
(FIXTURES / "pdd_home_page.xml").read_text(encoding="utf-8")
|
|
)
|
|
clock = FakeClock()
|
|
adapter = U2PddPurchaseAdapter(
|
|
"USB-001",
|
|
device_service=PddDeviceService(connector=lambda _serial: device),
|
|
sleeper=clock.sleep,
|
|
monotonic=clock.monotonic,
|
|
page_timeout=10.0,
|
|
)
|
|
|
|
with self.assertRaises(PddPurchaseError) as raised:
|
|
adapter.open_goods(GOODS_URL)
|
|
|
|
self.assertEqual("PDD_GOODS_UNAVAILABLE", raised.exception.code)
|
|
self.assertFalse(raised.exception.retryable)
|
|
self.assertEqual(2, len(device.opened_urls))
|
|
self.assertEqual([], device.clicks)
|
|
|
|
def test_slow_wrong_focus_still_completes_home_reopen_state_machine(self):
|
|
clock = FakeClock()
|
|
device = SlowSettingsFocusDevice(clock)
|
|
device.mode = "special"
|
|
device.special_xml = (
|
|
FIXTURES / "pdd_home_page.xml"
|
|
).read_text(encoding="utf-8")
|
|
adapter = U2PddPurchaseAdapter(
|
|
"USB-001",
|
|
device_service=PddDeviceService(connector=lambda _serial: device),
|
|
sleeper=clock.sleep,
|
|
monotonic=clock.monotonic,
|
|
page_timeout=10.0,
|
|
)
|
|
|
|
with self.assertRaises(PddPurchaseError) as raised:
|
|
adapter.open_goods(GOODS_URL)
|
|
|
|
self.assertEqual("PDD_GOODS_UNAVAILABLE", raised.exception.code)
|
|
self.assertEqual(1, device.app_current_calls)
|
|
self.assertEqual(2, len(device.opened_urls))
|
|
adapter.close()
|
|
|
|
def test_stale_goods_page_is_not_accepted_as_target(self):
|
|
device = StaleGoodsDevice()
|
|
clock = FakeClock()
|
|
adapter = U2PddPurchaseAdapter(
|
|
"USB-001",
|
|
device_service=PddDeviceService(connector=lambda _serial: device),
|
|
sleeper=clock.sleep,
|
|
monotonic=clock.monotonic,
|
|
page_timeout=1.0,
|
|
)
|
|
|
|
with self.assertRaises(PddPurchaseError) as raised:
|
|
adapter.open_goods(GOODS_URL)
|
|
|
|
self.assertEqual("PDD_GOODS_IDENTITY_UNCONFIRMED", raised.exception.code)
|
|
self.assertEqual([], device.clicks)
|
|
adapter.close()
|
|
|
|
def test_slow_wrong_focus_does_not_consume_panel_timeout(self):
|
|
clock = FakeClock()
|
|
device = SlowSettingsFocusDevice(clock)
|
|
adapter = U2PddPurchaseAdapter(
|
|
"USB-001",
|
|
device_service=PddDeviceService(connector=lambda _serial: device),
|
|
sleeper=clock.sleep,
|
|
monotonic=clock.monotonic,
|
|
panel_timeout=1.0,
|
|
select_color_fn=lambda *_args, **_kwargs: True,
|
|
select_size_fn=lambda *_args, **_kwargs: True,
|
|
)
|
|
|
|
adapter.open_goods(GOODS_URL)
|
|
adapter.select_options(
|
|
{"color": "黑色", "size": "3XL【140-165斤】"}
|
|
)
|
|
|
|
self.assertEqual(1, device.app_current_calls)
|
|
self.assertEqual(11.0, clock.now)
|
|
self.assertEqual(1, len(device.clicks))
|
|
adapter.close()
|
|
|
|
def test_panel_timeout_saves_sanitized_last_hierarchy(self):
|
|
clock = FakeClock()
|
|
device = PanelDoesNotOpenDevice()
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
adapter = U2PddPurchaseAdapter(
|
|
"USB-001",
|
|
device_service=PddDeviceService(
|
|
connector=lambda _serial: device
|
|
),
|
|
sleeper=clock.sleep,
|
|
monotonic=clock.monotonic,
|
|
panel_timeout=0.5,
|
|
artifact_directory=Path(directory),
|
|
)
|
|
adapter.open_goods(GOODS_URL)
|
|
|
|
with self.assertRaises(PddPurchaseError) as raised:
|
|
adapter.select_options({"color": "黑色"})
|
|
|
|
diagnostics = raised.exception.diagnostics
|
|
self.assertEqual("goods", diagnostics["page_kind"])
|
|
self.assertGreaterEqual(diagnostics["hierarchy_reads"], 2)
|
|
self.assertGreaterEqual(diagnostics["panel_hierarchy_reads"], 1)
|
|
artifact = diagnostics["artifacts"][0]
|
|
content = Path(artifact["path"]).read_text(encoding="utf-8")
|
|
self.assertNotIn("5.03", content)
|
|
self.assertNotIn("立即购买", content)
|
|
self.assertIn("[已脱敏]", content)
|
|
adapter.close()
|
|
|
|
def test_invalid_non_pdd_url_is_rejected_before_connect(self):
|
|
device = FakeDevice()
|
|
adapter = self._adapter(device, [])
|
|
|
|
with self.assertRaises(PddPurchaseError) as raised:
|
|
adapter.open_goods("https://example.test/goods?goods_id=1")
|
|
|
|
self.assertEqual(raised.exception.code, "PURCHASE_GOODS_URL_INVALID")
|
|
self.assertEqual(device.opened_urls, [])
|
|
|
|
def test_live_adapter_clicks_unique_submit_target_only_once(self):
|
|
device = FakeDevice()
|
|
adapter = self._live_adapter(device, [])
|
|
adapter.open_goods(GOODS_URL)
|
|
adapter.select_options(
|
|
{"color": "黑色", "size": "3XL【140-165斤】"}
|
|
)
|
|
adapter.set_quantity(2)
|
|
|
|
state = adapter.read_state()
|
|
calls_before_submit = device.app_current_calls
|
|
adapter.submit_order_once()
|
|
with self.assertRaises(PddPurchaseError) as raised:
|
|
adapter.submit_order_once()
|
|
|
|
self.assertEqual(state.submit_candidate_count, 1)
|
|
self.assertEqual(device.app_current_calls, calls_before_submit + 1)
|
|
self.assertEqual(raised.exception.code, "PURCHASE_SUBMIT_ALREADY_ATTEMPTED")
|
|
# 依次点击采购入口、数量加号和最终提交。
|
|
self.assertEqual(len(device.clicks), 3)
|
|
adapter.close()
|
|
|
|
def test_live_adapter_rejects_multiple_submit_targets_without_click(self):
|
|
device = FakeDevice()
|
|
adapter = self._live_adapter(device, [])
|
|
adapter.open_goods(GOODS_URL)
|
|
adapter.select_options(
|
|
{"color": "黑色", "size": "3XL【140-165斤】"}
|
|
)
|
|
device.special_xml = panel_xml().replace(
|
|
"</hierarchy>",
|
|
'<node text="确认购买" enabled="true" visible-to-user="true" '
|
|
'bounds="[50,2200][300,2300]"/></hierarchy>',
|
|
)
|
|
device.mode = "special"
|
|
|
|
with self.assertRaises(PddPurchaseError) as raised:
|
|
adapter.submit_order_once()
|
|
|
|
self.assertEqual(raised.exception.code, "PURCHASE_SUBMIT_TARGET_AMBIGUOUS")
|
|
self.assertEqual(len(device.clicks), 1)
|
|
adapter.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|