feat(cross_end): add cross-platform E2E test support for freight full flow

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.
This commit is contained in:
xst
2026-07-21 09:10:08 +08:00
parent b822428a2a
commit 6f5668b7ec
15 changed files with 1065 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# cross_end pages package
+1
View File
@@ -0,0 +1 @@
# cross_end admin pages package
@@ -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() 中的选择器。"
)
@@ -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
@@ -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
+1
View File
@@ -0,0 +1 @@
# cross_end app pages package
@@ -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
@@ -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
@@ -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