"""uiautomator2 采购演练 Adapter 测试;不连接真实手机。""" from pathlib import Path import tempfile import unittest from src.pdd_device_service import PddDeviceService from src.pdd_purchase_adapter import PddPurchaseError from src.pdd_u2_purchase_adapter import U2PddPurchaseAdapter from src.pdd_u2_purchase_adapter import U2PddLivePurchaseAdapter 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 """ """ def panel_xml(quantity: int = 1) -> str: return f""" """ 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 '' 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 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_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( '' ) 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() adapter.submit_order_once() with self.assertRaises(PddPurchaseError) as raised: adapter.submit_order_once() self.assertEqual(state.submit_candidate_count, 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( "", '', ) 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()