From 6f5668b7ec7cf6d288a1bab7fdad8888b6a68f99 Mon Sep 17 00:00:00 2001 From: xst Date: Tue, 21 Jul 2026 09:10:08 +0800 Subject: [PATCH] feat(cross_end): add cross-platform E2E test support for freight full flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add tests/cross_end/ directory with dual-driver architecture: - conftest.py: Playwright admin_page + Appium app_driver fixtures - Admin Page Objects: FreightSourcePage, WaybillManagementPage, SettlementPage - App Page Objects: DriverOrderListPage, DriverTransportPage, DriverHomePage (re-export) - test_freight_full_flow.py: 8-step end-to-end test (publish → accept → load → unload → accounting → review → shipper pay → platform pay) - Shared context dict for cross-step data passing - Graceful skip when either Playwright or Appium is unavailable - All selectors marked with ⚠️ placeholders for discovery against real environments Add cross_end marker to pytest.ini for selective execution. --- tests/cross_end/README.md | 133 ++++++++++++ tests/cross_end/__init__.py | 1 + tests/cross_end/conftest.py | 197 ++++++++++++++++++ tests/cross_end/pages/__init__.py | 1 + tests/cross_end/pages/admin/__init__.py | 1 + .../pages/admin/freight_source_page.py | 115 ++++++++++ .../cross_end/pages/admin/settlement_page.py | 88 ++++++++ .../pages/admin/waybill_management_page.py | 98 +++++++++ tests/cross_end/pages/app/__init__.py | 1 + tests/cross_end/pages/app/driver_home_page.py | 7 + .../pages/app/driver_order_list_page.py | 92 ++++++++ .../pages/app/driver_transport_page.py | 132 ++++++++++++ tests/cross_end/tests/__init__.py | 1 + .../cross_end/tests/test_freight_full_flow.py | 197 ++++++++++++++++++ tests/pytest.ini | 1 + 15 files changed, 1065 insertions(+) create mode 100644 tests/cross_end/README.md create mode 100644 tests/cross_end/__init__.py create mode 100644 tests/cross_end/conftest.py create mode 100644 tests/cross_end/pages/__init__.py create mode 100644 tests/cross_end/pages/admin/__init__.py create mode 100644 tests/cross_end/pages/admin/freight_source_page.py create mode 100644 tests/cross_end/pages/admin/settlement_page.py create mode 100644 tests/cross_end/pages/admin/waybill_management_page.py create mode 100644 tests/cross_end/pages/app/__init__.py create mode 100644 tests/cross_end/pages/app/driver_home_page.py create mode 100644 tests/cross_end/pages/app/driver_order_list_page.py create mode 100644 tests/cross_end/pages/app/driver_transport_page.py create mode 100644 tests/cross_end/tests/__init__.py create mode 100644 tests/cross_end/tests/test_freight_full_flow.py diff --git a/tests/cross_end/README.md b/tests/cross_end/README.md new file mode 100644 index 0000000..a18484d --- /dev/null +++ b/tests/cross_end/README.md @@ -0,0 +1,133 @@ +# 跨端 E2E 测试 — 货运全流程 + +## 概述 + +`tests/cross_end/` 目录包含跨越多个端(管理端 Web + 司机安卓 APP)的端到端自动化测试, +验证从"发布货源"到"平台打款"的**完整业务闭环**。 + +``` + [管理端 Web] [司机安卓 APP] + │ │ + ① 发布货源 ──────────────────────────→│ + │ ② 接单 │ + │ ③ 装货 │ + │ ④ 卸货 │ + │←────────────────────────────── │ + ⑤ 核算运单 │ + ⑥ 核算审核 │ + ⑦ 货主打款 │ + ⑧ 平台打款给司机 │ +``` + +## 前置条件 + +### 管理端 + +```bash +# 1. 保存管理员登录态 (仅需一次) +python tests/web/admin/save_auth.py --role super_admin --browser chromium +``` + +### 司机安卓 APP + +| 条件 | 说明 | +|---|---| +| Appium Server | 启动: `appium` (默认 http://localhost:4723) | +| Android 模拟器/真机 | 已启动并通过 `adb devices` 确认 | +| 司机 APP | 已安装,包名 `com.arpa.ynchenggangdriver` | +| 登录态 | 在设备上**手动登录一次** (后续 `noReset=true` 自动复用) | + +### Python 依赖 + +```bash +pip install playwright appium-python-client +playwright install chromium +``` + +## 运行 + +```bash +# 运行跨端测试 +pytest tests/cross_end/ -m cross_end -v -s + +# 仅收集测试(不执行,验证 fixture 链正确) +pytest tests/cross_end/ --collect-only + +# 排除跨端测试(常规运行不影响) +pytest tests/ -m "not cross_end" +``` + +## 测试文件结构 + +``` +tests/cross_end/ +├── conftest.py # 双 driver fixture (admin_page + app_driver) +├── pages/ +│ ├── admin/ +│ │ ├── freight_source_page.py # 发布货源 Page Object +│ │ ├── waybill_management_page.py # 运单核算 + 审核 Page Object +│ │ └── settlement_page.py # 货主打款 + 平台打款 Page Object +│ └── app/ +│ ├── driver_home_page.py # 司机首页 (复用现有) +│ ├── driver_order_list_page.py # 订单列表 + 接单 Page Object +│ └── driver_transport_page.py # 装货 + 卸货 Page Object +├── tests/ +│ └── test_freight_full_flow.py # 主测试: 8 步全流程 +└── README.md +``` + +## 选择器 `⚠️` 标记 + +所有新 Page Object 中的定位器 (CSS 选择器 / accessibility_id) 都标有 `# ⚠️`, +因为无法从代码静态分析中确定实际 DOM 结构或 APP 元素 ID。 + +**替换步骤:** + +### Web 管理端选择器 + +1. 启动管理端并登录 +2. 打开浏览器 DevTools (F12) +3. 逐个导航到对应页面(货源管理、运单管理、承运结算) +4. 检查每个表单输入框的 `placeholder`、按钮的 `text`、弹窗的 CSS class +5. 替换 Page Object 中对应的 `# ⚠️` 选择器 + +### APP 端 accessibility_id + +1. 启动 Appium Desktop 并连接到设备 +2. 使用 Inspector 功能捕获屏幕元素 +3. 找到每个按钮/输入框的 `accessibility-id` 或 `resource-id` +4. 替换 Page Object 中对应的 `# ⚠️` 选择器 + +## 配置 + +所有环境配置从 `tests/config/test_config.yaml` 读取,跨端测试使用的 section: + +| Section | 用途 | +|---|---| +| `admin` | 管理端 base_url, viewport, timeout, 登录态路径 | +| `driver_android` | Appium host, device_name, app_package, no_reset | +| `accounts.super_admin` | 管理员手机号/密码 | +| `accounts.driver` | 司机手机号/密码 | + +不需要新增配置项。 + +## 故障排查 + +| 现象 | 可能原因 | 解决方案 | +|---|---|---| +| `pytest.skip: Playwright 未安装` | 环境缺少 playwright | `pip install playwright && playwright install chromium` | +| `pytest.skip: Appium-Python-Client 未安装` | 环境缺少 appium | `pip install appium-python-client` | +| 登录态已过期 | storage_state 过期 | `python tests/web/admin/save_auth.py --role super_admin` | +| APP 在登录页 | noReset 未生效或首次运行 | 在设备上手动登录一次 | +| 无法连接到 Appium server | Appium 未启动 | 终端执行 `appium` | +| AdbError / 设备未找到 | 模拟器未启动 | `adb devices` 确认设备在线 | +| 订单在 APP 中找不到 | 订单同步延迟 | 在 Page Object 中增加 retry 轮询逻辑 | +| `# ⚠️` 选择器错误 | 未更新实际选择器 | 按照上方步骤替换选择器 | + +## 已知限制 + +- **仅 chromium**: 管理端不跑 browser matrix,固定使用 chromium +- **仅 Android**: APP 端目前固定为 `driver_android`,不跑 iOS +- **创建真实数据**: 测试会在环境中创建真实订单,需定期清理或使用测试环境 +- **无 API 清理**: 测试失败后不会自动取消/删除订单,需手动处理 +- **同时需要两个环境**: 管理端 + Appium + Android 设备必须同时可用,缺一则 skip diff --git a/tests/cross_end/__init__.py b/tests/cross_end/__init__.py new file mode 100644 index 0000000..59e1afa --- /dev/null +++ b/tests/cross_end/__init__.py @@ -0,0 +1 @@ +# cross_end package diff --git a/tests/cross_end/conftest.py b/tests/cross_end/conftest.py new file mode 100644 index 0000000..006d925 --- /dev/null +++ b/tests/cross_end/conftest.py @@ -0,0 +1,197 @@ +"""跨端 E2E 测试 fixture — 同时持有 Playwright (管理端) 和 Appium (司机安卓) 两个 driver。 + +核心挑战: + pytest 的单端 conftest 各自拥有 page / driver fixture, + 跨端测试需要同时注入两者而不冲突。 + 解决方案: 使用显式命名的 fixture (admin_page / app_driver), + 各自独立创建/销毁生命周期。 + +前置条件: + - 管理端: python tests/web/admin/save_auth.py --role super_admin --browser chromium + - 司机安卓: 设备上手动登录一次 (noReset=true 自动复用) + - Appium server: appium (默认 http://localhost:4723) + +运行: + pytest tests/cross_end/ -m cross_end -v +""" + +from datetime import datetime +from pathlib import Path + +import pytest + +SCREENSHOTS_DIR = Path("output/screenshots") + +# ═══════════════════════════════════════════════════════════════════ +# Admin Page (Playwright) +# ═══════════════════════════════════════════════════════════════════ + +@pytest.fixture +def admin_page(admin_config, request): + """管理端 Playwright Page — 仅 chromium,加载 super_admin 登录态。 + + 与 tests/web/admin/conftest.py 的 page fixture 核心逻辑一致, + 但不参数化浏览器 (跨端测试已经够慢,不必跑 browser matrix)。 + """ + try: + from playwright.sync_api import sync_playwright + except ImportError: + pytest.skip("Playwright 未安装,跳过跨端 E2E 测试。") + + viewport = admin_config.get("viewport", {"width": 1920, "height": 1080}) + timeout = admin_config.get("timeout_seconds", 120) * 1000 + storage_state_dir = Path(admin_config.get("storage_state_dir", "tests/web/auth")) + browser_name = "chromium" + + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + + context_kwargs = { + "viewport": {"width": viewport["width"], "height": viewport["height"]}, + "locale": "zh-CN", + } + + # 加载 super_admin 登录态 + auth_file = storage_state_dir / "super_admin_chromium.json" + if auth_file.exists(): + context_kwargs["storage_state"] = str(auth_file) + + context = browser.new_context(**context_kwargs) + page = context.new_page() + page.set_default_timeout(timeout) + + yield page + + # 失败自动截图 + if hasattr(request.node, "rep_call") and request.node.rep_call.failed: + scenario_dir = SCREENSHOTS_DIR / request.node.name + scenario_dir.mkdir(parents=True, exist_ok=True) + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + path = str(scenario_dir / f"FAIL_admin_chromium_{ts}.png") + try: + page.screenshot(path=path, full_page=True) + except Exception: + pass + + context.close() + browser.close() + + +# ═══════════════════════════════════════════════════════════════════ +# App Driver (Appium) +# ═══════════════════════════════════════════════════════════════════ + +@pytest.fixture +def app_driver(driver_android_config, request): + """司机安卓端 Appium driver。 + + 复用 tests.app._shared.create_driver() 和 save_failure_screenshot()。 + """ + try: + from tests.app._shared import create_driver, save_failure_screenshot + except ImportError: + pytest.skip("Appium-Python-Client 未安装,跳过跨端 E2E 测试。") + + cfg = driver_android_config + d = None + try: + d = create_driver(cfg, platform="android") + yield d + save_failure_screenshot(d, cfg, "android", request) + finally: + if d is not None: + try: + d.quit() + except Exception: + pass + + +# ═══════════════════════════════════════════════════════════════════ +# Login helpers +# ═══════════════════════════════════════════════════════════════════ + +@pytest.fixture +def ensure_admin_login(admin_config, admin_page, config): + """确保管理端已登录为 super_admin。 + + 复用 tests/web/admin/conftest.py 的登录态验证逻辑, + 但硬编码 role="super_admin" (跨端流程始终用超管)。 + """ + + def _ensure_login(role: str = "super_admin") -> None: + account = config["accounts"].get(role) + if not account: + raise ValueError(f"未知角色 '{role}',可用: {list(config['accounts'].keys())}") + + base_url = admin_config["base_url"] + skip_login = admin_config.get("skip_login", True) + + if skip_login: + admin_page.goto(base_url) + admin_page.wait_for_timeout(2000) + if "#/login" not in admin_page.url and "/login" not in admin_page.url: + return + raise RuntimeError( + f"登录态已过期!请运行: python tests/web/admin/save_auth.py --role {role}" + ) + + # skip_login: false — 实时 UI 登录 + admin_page.goto(f"{base_url}/#/login") + admin_page.get_by_placeholder("请输入用户名").fill(account["phone"]) + admin_page.get_by_placeholder("请输入密码").fill(account["password"]) + admin_page.get_by_role("button", name="登录").click() + admin_page.wait_for_timeout(3000) + if "#/login" in admin_page.url or "/login" in admin_page.url: + raise RuntimeError( + "登录失败!可能存在图形验证码。请改为手动登录: python tests/web/admin/save_auth.py" + ) + + return _ensure_login + + +@pytest.fixture +def ensure_driver_login(driver_android_config, config): + """确保司机安卓端已登录。 + + 复用 tests.app._shared.make_ensure_logged_in() 工厂。 + """ + from tests.app._shared import make_ensure_logged_in + + fn = make_ensure_logged_in(driver_android_config, config, account_role="driver") + return lambda d: fn(d) + + +# ═══════════════════════════════════════════════════════════════════ +# Shared context +# ═══════════════════════════════════════════════════════════════════ + +@pytest.fixture +def cross_end_context() -> dict: + """跨端流程共享上下文 — 各步骤通过此 dict 传递数据。 + + 写入: + context["order_no"] = "TEST20260721..." + context["load_confirm"] = True + + 读取: + order_no = context["order_no"] + """ + return { + "order_no": "", + "waybill_no": "", + "load_confirm": False, + "unload_confirm": False, + "accounting_done": False, + "review_done": False, + } + + +# ═══════════════════════════════════════════════════════════════════ +# Report hook — 让 request.node.rep_call.failed 可用于失败截图 +# ═══════════════════════════════════════════════════════════════════ + +@pytest.hookimpl(tryfirst=True, hookwrapper=True) +def pytest_runtest_makereport(item, call): + outcome = yield + rep = outcome.get_result() + setattr(item, f"rep_{rep.when}", rep) diff --git a/tests/cross_end/pages/__init__.py b/tests/cross_end/pages/__init__.py new file mode 100644 index 0000000..a4e2f8f --- /dev/null +++ b/tests/cross_end/pages/__init__.py @@ -0,0 +1 @@ +# cross_end pages package diff --git a/tests/cross_end/pages/admin/__init__.py b/tests/cross_end/pages/admin/__init__.py new file mode 100644 index 0000000..e69303f --- /dev/null +++ b/tests/cross_end/pages/admin/__init__.py @@ -0,0 +1 @@ +# cross_end admin pages package diff --git a/tests/cross_end/pages/admin/freight_source_page.py b/tests/cross_end/pages/admin/freight_source_page.py new file mode 100644 index 0000000..ba47263 --- /dev/null +++ b/tests/cross_end/pages/admin/freight_source_page.py @@ -0,0 +1,115 @@ +"""管理端 — 货源管理 — 发布货源 Page Object。 + +Usage: + freight_page = FreightSourcePage(page) + freight_page.fill_freight_form(...) + freight_page.submit() + order_no = freight_page.get_published_order_no() +""" + +from playwright.sync_api import Page + +from tests.web.admin.pages.base_page import BasePage + + +class FreightSourcePage(BasePage): + """管理端 - 货源管理 - 发布货源。""" + + def __init__(self, page: Page): + super().__init__(page) + # ⚠️ 以下定位器需要根据实际 DOM 调整 + # 通过 Playwright Inspector 或浏览器 DevTools 获取真实选择器 + + # ── 货源表单字段 ── + self.goods_name_input = page.get_by_placeholder("请输入货物名称") # ⚠️ + self.weight_input = page.get_by_placeholder("请输入重量") # ⚠️ + self.load_address_input = page.get_by_placeholder("请选择装货地") # ⚠️ + self.unload_address_input = page.get_by_placeholder("请选择卸货地") # ⚠️ + self.price_input = page.get_by_placeholder("请输入运费") # ⚠️ + + # ── 操作按钮 ── + self.submit_btn = page.get_by_role("button", name="发布") # ⚠️ + self.confirm_btn = page.get_by_role("button", name="确认") # ⚠️ + + # ── 结果元素 ── + self.success_toast = page.locator(".el-message--success") # ⚠️ + self.order_no_text = page.locator(".order-no, .freight-no") # ⚠️ 发布成功后显示订单号的位置 + + def fill_freight_form( + self, + goods_name: str, + weight: float, + load_addr: str, + unload_addr: str, + ) -> None: + """填写货源发布表单。 + + Args: + goods_name: 货物名称 + weight: 货物重量 (吨) + load_addr: 装货地址 + unload_addr: 卸货地址 + """ + # ⚠️ 具体字段依赖实际表单结构,以下为通用模式 + self.fill_field(self.goods_name_input, goods_name) + self.fill_field(self.weight_input, str(weight)) + self.fill_field(self.load_address_input, load_addr) + self.fill_field(self.unload_address_input, unload_addr) + # 如有运费字段: + # self.fill_field(self.price_input, "1000") + + def submit(self) -> None: + """点击发布按钮。""" + self.click_when_ready(self.submit_btn) + self.page.wait_for_timeout(1000) + + def confirm_publish(self) -> None: + """确认发布弹窗(如果有二次确认)。""" + # ⚠️ 如果发布时有确认弹窗 + try: + self.click_when_ready(self.confirm_btn) + self.page.wait_for_timeout(500) + except Exception: + pass # 无确认弹窗则跳过 + + def get_published_order_no(self) -> str: + """发布成功后获取生成的订单号。 + + 策略 (按优先级尝试): + 1. 从成功提示文本中提取 + 2. 从跳转后的详情页 URL 中提取 + 3. 从列表页第一条提取 + + Returns: + 订单号字符串 + + Raises: + RuntimeError: 无法获取订单号 + """ + # ⚠️ 策略取决于系统实际行为 + # 策略1: 等待成功 toast + self.wait_visible(self.success_toast, timeout_ms=10_000) + toast_text = self.success_toast.inner_text() + self.page.wait_for_timeout(500) + + # 策略2: 从当前页面提取订单号文本 + if self.order_no_text.is_visible(): + return self.order_no_text.inner_text().strip() + + # 策略3: 从 URL 提取 + import re + match = re.search(r"orderNo=(\w+)", self.page.url) + if match: + return match.group(1) + + # 策略4: 从成功 toast 文本中提取数字编号 + match = re.search(r"[A-Z]{2,6}\d{10,20}", toast_text) + if match: + return match.group(0) + + raise RuntimeError( + f"无法获取发布的订单号。\n" + f"当前 URL: {self.page.url}\n" + f"Toast 文本: {toast_text}\n" + f"请更新 get_published_order_no() 中的选择器。" + ) diff --git a/tests/cross_end/pages/admin/settlement_page.py b/tests/cross_end/pages/admin/settlement_page.py new file mode 100644 index 0000000..4861674 --- /dev/null +++ b/tests/cross_end/pages/admin/settlement_page.py @@ -0,0 +1,88 @@ +"""管理端 — 承运结算 — 打款 Page Object。 + +覆盖步骤: + 7. 货主打款 (do_shipper_payment) + 8. 平台打款给司机 (do_platform_payment) + +Usage: + settlement_page = SettlementPage(page) + settlement_page.do_shipper_payment(order_no) + settlement_page.do_platform_payment(order_no) +""" + +from playwright.sync_api import Page + +from tests.web.admin.pages.base_page import BasePage + + +class SettlementPage(BasePage): + """管理端 - 承运结算 - 打款管理。""" + + def __init__(self, page: Page): + super().__init__(page) + # ⚠️ 以下定位器需要根据实际 DOM 调整 + + # ── 搜索区 ── + self.search_order_input = page.get_by_placeholder("请输入订单号") # ⚠️ + self.search_btn = page.get_by_role("button", name="查询") # ⚠️ + + # ── 操作按钮 ── + self.shipper_pay_btn = page.get_by_role("button", name="货主打款") # ⚠️ + self.platform_pay_btn = page.get_by_role("button", name="平台打款") # ⚠️ 或"财务打款" + self.confirm_btn = page.get_by_role("button", name="确定") # ⚠️ 确认打款弹窗 + + # ── 结果元素 ── + self.success_toast = page.locator(".el-message--success") # ⚠️ + self.status_text = page.locator(".settlement-status") # ⚠️ 结算状态文本 + + def search_order(self, order_no: str) -> None: + """搜索运单。""" + self.fill_field(self.search_order_input, order_no) + self.click_when_ready(self.search_btn) + self.page.wait_for_timeout(1000) + + def do_shipper_payment(self, order_no: str) -> None: + """货主打款流程: 搜索 → 货主打款 → 确认。 + + Args: + order_no: 订单号 + """ + self.search_order(order_no) + self.page.wait_for_timeout(500) + + self.click_when_ready(self.shipper_pay_btn) + self.page.wait_for_timeout(500) + + # 确认打款弹窗 + self._confirm_if_present() + + # 等待成功提示 + self.wait_visible(self.success_toast, timeout_ms=10_000) + + def do_platform_payment(self, order_no: str) -> None: + """平台打款给司机流程: 搜索 → 平台打款 → 确认。 + + Args: + order_no: 订单号 + """ + self.search_order(order_no) + self.page.wait_for_timeout(500) + + self.click_when_ready(self.platform_pay_btn) + self.page.wait_for_timeout(500) + + # 确认打款弹窗 + self._confirm_if_present() + + # 等待成功提示 + self.wait_visible(self.success_toast, timeout_ms=10_000) + + def _confirm_if_present(self) -> None: + """如果有确认弹窗则点击确定。""" + # ⚠️ 确认按钮可能以不同形式存在 + try: + if self.confirm_btn.is_visible(): + self.click_when_ready(self.confirm_btn) + self.page.wait_for_timeout(500) + except Exception: + pass diff --git a/tests/cross_end/pages/admin/waybill_management_page.py b/tests/cross_end/pages/admin/waybill_management_page.py new file mode 100644 index 0000000..abfaebc --- /dev/null +++ b/tests/cross_end/pages/admin/waybill_management_page.py @@ -0,0 +1,98 @@ +"""管理端 — 运输管理 — 运单核算 Page Object。 + +覆盖步骤: + 5. 核算运单 (do_accounting) + 6. 核算审核 (do_review) + +Usage: + waybill_page = WaybillManagementPage(page) + waybill_page.do_accounting(order_no) + waybill_page.do_review(order_no) +""" + +from playwright.sync_api import Page + +from tests.web.admin.pages.base_page import BasePage + + +class WaybillManagementPage(BasePage): + """管理端 - 运输管理 - 运单管理/核算。""" + + def __init__(self, page: Page): + super().__init__(page) + # ⚠️ 以下定位器需要根据实际 DOM 调整 + + # ── 搜索区 ── + self.search_order_input = page.get_by_placeholder("请输入订单号") # ⚠️ + self.search_btn = page.get_by_role("button", name="查询") # ⚠️ + + # ── 操作按钮 ── + self.accounting_btn = page.get_by_role("button", name="核算") # ⚠️ 核算运单 + self.review_btn = page.get_by_role("button", name="审核") # ⚠️ 核算审核 + self.confirm_btn = page.get_by_role("button", name="确定") # ⚠️ 确认弹窗 + + # ── 结果元素 ── + self.success_toast = page.locator(".el-message--success") # ⚠️ + self.table_first_row = page.locator(".el-table__body tr:first-child") # ⚠️ 列表第一行 + + def search_order(self, order_no: str) -> None: + """搜索运单。""" + self.fill_field(self.search_order_input, order_no) + self.click_when_ready(self.search_btn) + self.page.wait_for_timeout(1000) + + def click_accounting(self) -> None: + """点击核算按钮。""" + self.click_when_ready(self.accounting_btn) + self.page.wait_for_timeout(500) + + def click_review(self) -> None: + """点击审核按钮(核算审核)。""" + self.click_when_ready(self.review_btn) + self.page.wait_for_timeout(500) + + def confirm_action(self) -> None: + """确认操作弹窗。""" + # ⚠️ 可能没有确认弹窗,try 一下 + try: + if self.confirm_btn.is_visible(): + self.click_when_ready(self.confirm_btn) + self.page.wait_for_timeout(500) + except Exception: + pass + + def do_accounting(self, order_no: str) -> None: + """完整的核算运单流程: 搜索 → 核算 → 确认。 + + Args: + order_no: 订单号 + """ + self.search_order(order_no) + self.page.wait_for_timeout(500) + self.click_accounting() + self.confirm_action() + + # 等待核算完成 + self.has_success_toast() + + def do_review(self, order_no: str) -> None: + """完整的核算审核流程: 搜索 → 审核 → 确认。 + + Args: + order_no: 订单号 + """ + self.search_order(order_no) + self.page.wait_for_timeout(500) + self.click_review() + self.confirm_action() + + # 等待审核完成 + self.has_success_toast() + + def has_success_toast(self) -> bool: + """检查操作是否成功(有成功 toast)。""" + try: + self.wait_visible(self.success_toast, timeout_ms=5_000) + return True + except Exception: + return False diff --git a/tests/cross_end/pages/app/__init__.py b/tests/cross_end/pages/app/__init__.py new file mode 100644 index 0000000..cc2d593 --- /dev/null +++ b/tests/cross_end/pages/app/__init__.py @@ -0,0 +1 @@ +# cross_end app pages package diff --git a/tests/cross_end/pages/app/driver_home_page.py b/tests/cross_end/pages/app/driver_home_page.py new file mode 100644 index 0000000..a2d5c62 --- /dev/null +++ b/tests/cross_end/pages/app/driver_home_page.py @@ -0,0 +1,7 @@ +"""跨端测试 — 司机端首页 Page Object。 + +直接复用 tests/app/driver_android/pages/driver_home_page.py。 +""" + +# re-export existing page object +from tests.app.driver_android.pages.driver_home_page import DriverHomePage # noqa: F401 diff --git a/tests/cross_end/pages/app/driver_order_list_page.py b/tests/cross_end/pages/app/driver_order_list_page.py new file mode 100644 index 0000000..eed910a --- /dev/null +++ b/tests/cross_end/pages/app/driver_order_list_page.py @@ -0,0 +1,92 @@ +"""司机端 APP — 订单列表 + 接单 Page Object。 + +Usage: + order_list = DriverOrderListPage(driver) + order_list.search_and_accept_order(order_no) +""" + +from tests.app._base_page import AppBasePage + + +class DriverOrderListPage(AppBasePage): + """司机端 APP - 订单列表 - 接单。""" + + def __init__(self, driver, platform: str = "android"): + super().__init__(driver, platform) + # ⚠️ 以下 accessibility_id 需要根据实际 APP 元素 ID 调整 + # 使用 Appium Desktop Inspector 或 uiautomatorviewer 获取 + + # ── 搜索区 ── + self.search_bar = "et_search" # ⚠️ 搜索框 + self.search_input = "et_search_input" # ⚠️ 搜索输入框 + self.search_btn = "btn_search" # ⚠️ 搜索按钮 + + # ── 列表 ── + self.order_list_container = "rv_order_list" # ⚠️ 订单列表容器 + self.order_item_prefix = "order_item_" # ⚠️ 订单项 ID 前缀 + self.order_detail_btn = "btn_order_detail" # ⚠️ 进入订单详情 + + # ── 接单 ── + self.accept_order_btn = "btn_accept_order" # ⚠️ 接单按钮 + self.confirm_accept_btn = "btn_confirm" # ⚠️ 确认接单按钮 + self.accept_success_text = "接单成功" # ⚠️ 成功提示文本 + + def search_order(self, order_no: str) -> None: + """搜索指定订单。""" + # 如果搜索框是独立按钮需要先点击展开 + try: + self.tap_by_id(self.search_bar) + except Exception: + pass + + self.fill_by_id(self.search_input, order_no) + self.tap_by_id(self.search_btn) + self.driver.implicitly_wait(3) + + def accept_order(self, order_no: str) -> None: + """接受指定订单。 + + Args: + order_no: 订单号 + """ + # 点击搜索结果中的订单 + # ⚠️ 实际定位方式取决于列表项如何展示订单号 + # 策略1: 通过订单号文本点击 + self.tap_by_text(order_no) + self.driver.implicitly_wait(1) + + # 策略2: 通过详情按钮进入 + # self.tap_by_id(f"{self.order_item_prefix}{order_no}") + # self.tap_by_id(self.order_detail_btn) + + def search_and_accept_order(self, order_no: str) -> None: + """搜索订单并接单 — 完整的接单流程。 + + Args: + order_no: 订单号 + """ + # 1. 搜索 + self.search_order(order_no) + + # 2. 进入订单详情 + self.accept_order(order_no) + + # 3. 点击接单 + self.tap_by_id(self.accept_order_btn) + self.driver.implicitly_wait(1) + + # 4. 确认接单 (如有弹窗) + try: + self.tap_by_id(self.confirm_accept_btn) + except Exception: + pass # 无确认弹窗则跳过 + + self.driver.implicitly_wait(2) + + def verify_order_accepted(self) -> bool: + """验证订单已被接受(成功提示出现)。""" + try: + self.wait_visible_by_text(self.accept_success_text, timeout_s=5) + return True + except Exception: + return False diff --git a/tests/cross_end/pages/app/driver_transport_page.py b/tests/cross_end/pages/app/driver_transport_page.py new file mode 100644 index 0000000..972169f --- /dev/null +++ b/tests/cross_end/pages/app/driver_transport_page.py @@ -0,0 +1,132 @@ +"""司机端 APP — 运输中(装货/卸货) Page Object。 + +Usage: + transport = DriverTransportPage(driver) + transport.load_goods(order_no) + transport.unload_goods(order_no) +""" + +from tests.app._base_page import AppBasePage + + +class DriverTransportPage(AppBasePage): + """司机端 APP - 运输中 - 装货/卸货。""" + + def __init__(self, driver, platform: str = "android"): + super().__init__(driver, platform) + # ⚠️ 以下 accessibility_id 需要根据实际 APP 元素 ID 调整 + # 使用 Appium Desktop Inspector 或 uiautomatorviewer 获取 + + # ── 导航 tab ── + self.my_orders_tab = "tab_orders" # ⚠️ 我的运单 tab + + # ── 运单列表/详情 ── + self.order_item = "order_item" # ⚠️ 运单项 + self.order_detail_btn = "btn_order_detail" # ⚠️ 进入详情 + + # ── 装货 ── + self.load_goods_btn = "btn_load_goods" # ⚠️ 装货按钮 + self.confirm_load_btn = "btn_confirm" # ⚠️ 确认装货 + self.upload_load_photo_btn = "btn_upload_photo" # ⚠️ 上传装货照片 (如有) + self.load_success_text = "装货成功" # ⚠️ 成功提示 + + # ── 卸货 ── + self.unload_goods_btn = "btn_unload_goods" # ⚠️ 卸货按钮 + self.confirm_unload_btn = "btn_confirm" # ⚠️ 确认卸货 + self.upload_unload_photo_btn = "btn_upload_photo" # ⚠️ 上传卸货照片 (如有) + self.unload_success_text = "卸货成功" # ⚠️ 成功提示 + + def go_to_my_orders(self) -> None: + """切换到我的运单 tab。""" + self.tap_by_id(self.my_orders_tab) + self.driver.implicitly_wait(1) + + def find_and_open_order(self, order_no: str) -> None: + """查找并打开指定运单的详情页。 + + Args: + order_no: 订单号 + """ + # ⚠️ 策略取决于 APP 实际布局: + # 策略1: 通过订单号文本定位并点击 + try: + self.tap_by_text(order_no) + self.driver.implicitly_wait(1) + return + except Exception: + pass + + # 策略2: 滚动查找 + for _ in range(5): # 最多滚动 5 次 + try: + self.tap_by_text(order_no) + self.driver.implicitly_wait(1) + return + except Exception: + self.scroll_down() + + raise RuntimeError(f"在运单列表中未找到订单: {order_no}") + + def load_goods(self, order_no: str) -> None: + """执行装货流程。 + + Args: + order_no: 订单号 + """ + self.go_to_my_orders() + self.find_and_open_order(order_no) + + # 点击装货 + self.tap_by_id(self.load_goods_btn) + self.driver.implicitly_wait(1) + + # 上传照片 (如有) + try: + self.tap_by_id(self.upload_load_photo_btn) + self.driver.implicitly_wait(1) + except Exception: + pass + + # 确认装货 + self.tap_by_id(self.confirm_load_btn) + self.driver.implicitly_wait(2) + + def unload_goods(self, order_no: str) -> None: + """执行卸货流程。 + + Args: + order_no: 订单号 + """ + self.go_to_my_orders() + self.find_and_open_order(order_no) + + # 点击卸货 + self.tap_by_id(self.unload_goods_btn) + self.driver.implicitly_wait(1) + + # 上传照片 (如有) + try: + self.tap_by_id(self.upload_unload_photo_btn) + self.driver.implicitly_wait(1) + except Exception: + pass + + # 确认卸货 + self.tap_by_id(self.confirm_unload_btn) + self.driver.implicitly_wait(2) + + def verify_load_complete(self) -> bool: + """验证装货完成。""" + try: + self.wait_visible_by_text(self.load_success_text, timeout_s=5) + return True + except Exception: + return False + + def verify_unload_complete(self) -> bool: + """验证卸货完成。""" + try: + self.wait_visible_by_text(self.unload_success_text, timeout_s=5) + return True + except Exception: + return False diff --git a/tests/cross_end/tests/__init__.py b/tests/cross_end/tests/__init__.py new file mode 100644 index 0000000..3defa4c --- /dev/null +++ b/tests/cross_end/tests/__init__.py @@ -0,0 +1 @@ +# cross_end tests package diff --git a/tests/cross_end/tests/test_freight_full_flow.py b/tests/cross_end/tests/test_freight_full_flow.py new file mode 100644 index 0000000..5941e98 --- /dev/null +++ b/tests/cross_end/tests/test_freight_full_flow.py @@ -0,0 +1,197 @@ +"""跨端 E2E 测试 — 货运全流程。 + +业务流程 (8 步): + 1. Web 管理端: 发布货源 + 2. 司机安卓 APP: 接单 + 3. 司机安卓 APP: 装货 + 4. 司机安卓 APP: 卸货 + 5. Web 管理端: 核算运单 + 6. Web 管理端: 核算审核 + 7. Web 管理端: 货主打款 + 8. Web 管理端: 平台打款给司机 + +运行: + pytest tests/cross_end/ -m cross_end -v -s + +前置条件: + - Appium server: appium (http://localhost:4723) + - Android 模拟器已启动,司机 APP 已安装并登录过一次 (noReset) + - 管理端 auth 态: python tests/web/admin/save_auth.py --role super_admin +""" + +import pytest + +from tests.cross_end.pages.admin.freight_source_page import FreightSourcePage +from tests.cross_end.pages.admin.waybill_management_page import WaybillManagementPage +from tests.cross_end.pages.admin.settlement_page import SettlementPage +from tests.cross_end.pages.app.driver_home_page import DriverHomePage +from tests.cross_end.pages.app.driver_order_list_page import DriverOrderListPage +from tests.cross_end.pages.app.driver_transport_page import DriverTransportPage +from tests.utils.data_helper import generate_order_no + +pytestmark = [pytest.mark.cross_end, pytest.mark.p0, pytest.mark.smoke] + + +class TestFreightFullFlow: + """货运全流程跨端 E2E — 管理端发布货源 → 司机 APP 接单装货卸货 → 管理端核算打款。""" + + def test_full_freight_flow( + self, + admin_page, + app_driver, + ensure_admin_login, + ensure_driver_login, + cross_end_context, + logger, + ): + """验证从发布货源到平台打款的完整跨端流程。""" + logger.info("=" * 60) + logger.info("跨端 E2E: 货运全流程开始") + logger.info("=" * 60) + + # ── 登录 ── + ensure_admin_login("super_admin") + ensure_driver_login(app_driver) + + # 生成唯一订单号 (用于货源描述,实际订单号由系统生成) + from tests.web.admin.pages.components.navbar import NavBar + navbar = NavBar(admin_page) + test_order_prefix = generate_order_no() + + # ════════════════════════════════════════════════════════ + # Step 1: Web 管理端 — 发布货源 + # ════════════════════════════════════════════════════════ + logger.info("Step 1/8: Web管理端 — 发布货源") + + # ⚠️ 菜单名称需要根据实际管理端调整 + navbar.go_to_submenu("运输管理", "货源管理") + admin_page.wait_for_timeout(1000) + + freight_page = FreightSourcePage(admin_page) + freight_page.fill_freight_form( + goods_name=f"测试货物_{test_order_prefix}", + weight=10, + # ⚠️ 地址格式根据实际系统调整 + load_addr="云南省昆明市五华区", + unload_addr="云南省昆明市官渡区", + ) + freight_page.submit() + freight_page.confirm_publish() + + # 截图留档 + freight_page.screenshot("step1_freight_published") + + # 获取系统生成的订单号 + published_order_no = freight_page.get_published_order_no() + cross_end_context["order_no"] = published_order_no + logger.info(f" -> 发布成功, 订单号: {published_order_no}") + + # ════════════════════════════════════════════════════════ + # Step 2: 司机安卓 APP — 接单 + # ════════════════════════════════════════════════════════ + logger.info("Step 2/8: 司机安卓APP — 接单") + + driver_home = DriverHomePage(app_driver) + driver_home.go_to_order_list() + + order_list_page = DriverOrderListPage(app_driver) + order_list_page.search_and_accept_order(published_order_no) + + if not order_list_page.verify_order_accepted(): + order_list_page.screenshot("step2_order_accept_failed") + raise AssertionError(f"司机端接单失败: {published_order_no}") + + order_list_page.screenshot("step2_order_accepted") + logger.info(f" -> 接单成功: {published_order_no}") + + # ════════════════════════════════════════════════════════ + # Step 3: 司机安卓 APP — 装货 + # ════════════════════════════════════════════════════════ + logger.info("Step 3/8: 司机安卓APP — 装货") + + transport_page = DriverTransportPage(app_driver) + transport_page.load_goods(published_order_no) + cross_end_context["load_confirm"] = True + + transport_page.screenshot("step3_loaded") + logger.info(" -> 装货完成") + + # ════════════════════════════════════════════════════════ + # Step 4: 司机安卓 APP — 卸货 + # ════════════════════════════════════════════════════════ + logger.info("Step 4/8: 司机安卓APP — 卸货") + + transport_page.unload_goods(published_order_no) + cross_end_context["unload_confirm"] = True + + transport_page.screenshot("step4_unloaded") + logger.info(" -> 卸货完成") + + # ════════════════════════════════════════════════════════ + # Step 5: Web 管理端 — 核算运单 + # ════════════════════════════════════════════════════════ + logger.info("Step 5/8: Web管理端 — 核算运单") + + # ⚠️ 菜单名称需要根据实际管理端调整 + # 运单核算可能在 "运输管理 > 运单管理" 或 "承运结算" 下 + navbar.go_to_submenu("运输管理", "承运结算") + admin_page.wait_for_timeout(1000) + + waybill_page = WaybillManagementPage(admin_page) + waybill_page.do_accounting(published_order_no) + cross_end_context["accounting_done"] = True + + waybill_page.screenshot("step5_accounting_done") + logger.info(f" -> 核算完成: {published_order_no}") + + # ════════════════════════════════════════════════════════ + # Step 6: Web 管理端 — 核算审核 + # ════════════════════════════════════════════════════════ + logger.info("Step 6/8: Web管理端 — 核算审核") + + waybill_page.do_review(published_order_no) + cross_end_context["review_done"] = True + + waybill_page.screenshot("step6_review_done") + logger.info(f" -> 审核完成: {published_order_no}") + + # ════════════════════════════════════════════════════════ + # Step 7: Web 管理端 — 货主打款 + # ════════════════════════════════════════════════════════ + logger.info("Step 7/8: Web管理端 — 货主打款") + + # ⚠️ 打款页面可能与运单核算在同一菜单下或不同菜单 + settlement_page = SettlementPage(admin_page) + settlement_page.do_shipper_payment(published_order_no) + + settlement_page.screenshot("step7_shipper_paid") + logger.info(f" -> 货主打款完成: {published_order_no}") + + # ════════════════════════════════════════════════════════ + # Step 8: Web 管理端 — 平台打款给司机 + # ════════════════════════════════════════════════════════ + logger.info("Step 8/8: Web管理端 — 平台打款给司机") + + settlement_page.do_platform_payment(published_order_no) + + settlement_page.screenshot("step8_platform_paid") + logger.info(f" -> 平台打款完成: {published_order_no}") + + # ════════════════════════════════════════════════════════ + # 最终验证 + # ════════════════════════════════════════════════════════ + logger.info("=" * 60) + logger.info("跨端 E2E: 货运全流程完成!") + logger.info(f" 订单号: {cross_end_context['order_no']}") + logger.info(f" 装货确认: {cross_end_context['load_confirm']}") + logger.info(f" 卸货确认: {cross_end_context['unload_confirm']}") + logger.info(f" 核算完成: {cross_end_context['accounting_done']}") + logger.info(f" 审核完成: {cross_end_context['review_done']}") + logger.info("=" * 60) + + # 断言: 所有步骤必须完成 + assert cross_end_context["order_no"], "订单号不能为空" + assert cross_end_context["load_confirm"], "装货未确认" + assert cross_end_context["unload_confirm"], "卸货未确认" + assert cross_end_context["accounting_done"], "核算未完成" + assert cross_end_context["review_done"], "审核未完成" diff --git a/tests/pytest.ini b/tests/pytest.ini index dbfc487..a65e763 100644 --- a/tests/pytest.ini +++ b/tests/pytest.ini @@ -31,6 +31,7 @@ markers = shipper_ios: 货主苹果端 (Appium) appium: 所有 Appium 测试 playwright: 所有 Playwright 测试 + cross_end: 跨端 E2E 测试(管理端 + 司机安卓端联动) # 日志格式 log_cli = true