refactor(tests): restructure into layered POM architecture with Allure reporting
- Split framework into core/ (config, logger, db, driver factories, screenshot, allure integration) and pages/ (PO layer: base/admin/driver); flatten test files per platform (web/admin, app/driver_android, cross_end) - Slim root conftest.py 656 -> ~100 lines: drop inline CN HTML report, add Allure severity mapping (p0/p1/p2 -> blocker/normal/minor) and environment.properties; support both tests.*-prefixed and bare imports - Preserve TC-D2S-001/002 cross-end E2E cases with business logic unchanged, add allure epic/feature/story/step annotations - Deduplicate: single makereport hook, shared failure-screenshot helpers, single SCREENSHOTS_DIR constant; deterministic auth-state role loading - Move DB credentials from db_helper.py into test_config.yaml (database section) - Move pytest.ini to repo root; switch reporting to allure-pytest (drop pytest-html/junitxml); update requirements.txt accordingly - Delete deprecated scaffolding: test_freight_full_flow + 6 stale page objects, iOS/shipper empty shells, dead utils - Fix flaky vxe-table action-row wait: wait for a visible td instead of hidden placeholder rows (fixed shipper-payment timeouts) - Verified end-to-end against live env: both E2E cases pass (2 passed 3m12s); run artifacts (logs/screenshots/allure) included per repo convention
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
"""PO 层 — 页面对象集中管理,与用例分离。
|
||||
|
||||
base/ Web/App 页面对象基类
|
||||
admin/ 管理端页面对象
|
||||
driver/ 司机 APP 页面对象
|
||||
"""
|
||||
@@ -0,0 +1 @@
|
||||
"""管理端页面对象。"""
|
||||
@@ -0,0 +1,355 @@
|
||||
"""管理端 Page Object — 运费核算 + 运单审核 + 垫资打款 完整流程。
|
||||
|
||||
基于 resources/selectors/正常流程-web端.md 中的元素定位。
|
||||
使用直接 hash URL 导航(比菜单点击更可靠),按钮选择器带 fallback 兜底。
|
||||
|
||||
URL 路由:
|
||||
运费核算: #/tms/Carrier/freight
|
||||
运单审核: #/tms/Carrier/OrderDetailAudit
|
||||
垫资-财务打款: #/tms/Carrier/dzFinancialapproval
|
||||
垫资-货主打款: #/tms/Carrier/dzApplicationForPayment
|
||||
"""
|
||||
|
||||
from playwright.sync_api import Page
|
||||
|
||||
from tests.pages.base.web_base_page import BasePage
|
||||
|
||||
|
||||
class FreightAccountingPage(BasePage):
|
||||
"""管理端 - 运费核算 / 运单审核 / 垫资打款。"""
|
||||
|
||||
def __init__(self, page: Page, admin_base_url: str = ""):
|
||||
super().__init__(page)
|
||||
self.admin_base_url = admin_base_url
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# URL 导航
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
def _go(self, hash_route: str):
|
||||
"""直接 hash URL 跳转,比菜单点击稳定。"""
|
||||
url = f"{self.admin_base_url}/#/{hash_route.lstrip('#').lstrip('/')}"
|
||||
self.page.goto(url, wait_until="domcontentloaded")
|
||||
self.page.wait_for_timeout(2000)
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 通用操作
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
def _fill_order_no(self, order_no: str):
|
||||
"""搜索栏输入运输单号(多个同名 input 时取第一个)。"""
|
||||
input_el = self.page.locator('input[placeholder="请输入运输单号"]').first
|
||||
self.wait_visible(input_el)
|
||||
input_el.clear()
|
||||
input_el.fill(order_no)
|
||||
|
||||
def _click_search(self):
|
||||
"""点击查询按钮。"""
|
||||
btn = self.page.locator("button:has-text('查询')").first
|
||||
self.wait_visible(btn)
|
||||
btn.click()
|
||||
self.page.wait_for_timeout(1000)
|
||||
|
||||
def _table_has_data(self) -> bool:
|
||||
"""检查查询结果是否有数据。"""
|
||||
try:
|
||||
self.page.wait_for_selector("table tbody tr", timeout=3_000)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _click_table_action(self, button_index: int):
|
||||
"""点击表格固定列操作按钮(纯图标 <i> 按钮,selector 文档路径 + fallback)。
|
||||
|
||||
vxe-table 操作列按钮结构: td > div > button:nth-child(N) > span > i
|
||||
"""
|
||||
# 精确路径(selector 文档原始定位)
|
||||
sel = (
|
||||
".vxe-table--fixed-right-wrapper.scrolling--middle "
|
||||
".vxe-table--body-wrapper.fixed-right--wrapper "
|
||||
"table > tbody > tr:nth-child(1) > td > div "
|
||||
f"> button:nth-child({button_index}) > span > i"
|
||||
)
|
||||
# 宽松路径(当 DOM 结构略有变化时兜底)
|
||||
sel_fallback = (
|
||||
".vxe-table--fixed-right-wrapper "
|
||||
"table > tbody > tr:nth-child(1) "
|
||||
f"> td:last-child > div > button:nth-child({button_index})"
|
||||
)
|
||||
|
||||
for attempt in range(3):
|
||||
try:
|
||||
# 等待可见的数据单元格出现。vxe-table 固定列会渲染多个隐藏的重复
|
||||
# 单元格 (class="...fixed--hidden"),必须用 :visible 钉住真正可见的
|
||||
# 数据行,否则 wait_for_selector 会卡在第一个隐藏单元格上超时。
|
||||
self.page.wait_for_selector("table tbody tr td:visible", timeout=8_000)
|
||||
self.page.wait_for_timeout(300)
|
||||
btn = self.page.locator(sel).first
|
||||
if btn.count() == 0:
|
||||
btn = self.page.locator(sel_fallback).first
|
||||
self.wait_visible(btn, timeout_ms=5_000)
|
||||
btn.click()
|
||||
self.page.wait_for_timeout(800)
|
||||
return
|
||||
except Exception:
|
||||
if attempt == 2:
|
||||
raise
|
||||
self.page.wait_for_timeout(1_000)
|
||||
|
||||
def _modal(self):
|
||||
"""返回当前可见弹窗内容容器(.first 避免 strict mode)。"""
|
||||
return self.page.locator(
|
||||
".vxe-modal--wrapper.is--visible .vxe-modal--body .vxe-modal--content"
|
||||
).first
|
||||
|
||||
def _scroll_modal(self):
|
||||
"""滚动弹窗到底部。"""
|
||||
modal = self._modal()
|
||||
self.wait_visible(modal)
|
||||
modal.evaluate("el => el.scrollTop = el.scrollHeight")
|
||||
self.page.wait_for_timeout(500)
|
||||
|
||||
def _retry_click_modal_btn(self, locator, timeout_ms: int = 5_000,
|
||||
retries: int = 3, wait_after: int = 1500):
|
||||
"""弹窗内按钮带重试点击(每次重试重新获取元素,防 DOM 置换)。"""
|
||||
for attempt in range(retries):
|
||||
el = locator() if callable(locator) else locator
|
||||
try:
|
||||
self.wait_visible(el, timeout_ms=timeout_ms)
|
||||
el.click()
|
||||
self.page.wait_for_timeout(wait_after)
|
||||
return
|
||||
except Exception:
|
||||
if attempt == retries - 1:
|
||||
raise
|
||||
self.page.wait_for_timeout(1_000)
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 运费核算
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
def do_freight_accounting(self, order_no: str, retry_sql=None, team_leader_payment: bool = False):
|
||||
"""运费核算:导航→搜索→(无数据时 SQL 重试)→编辑→[可选:选择车队长+收款模式]→核算。
|
||||
|
||||
Args:
|
||||
order_no: 运输单号
|
||||
retry_sql: 可调用对象,首次查询无数据时调用
|
||||
team_leader_payment: True=车队长全额收款模式(核算前额外选择车队长和收款模式)
|
||||
"""
|
||||
self._go("tms/Carrier/freight")
|
||||
self._fill_order_no(order_no)
|
||||
self._click_search()
|
||||
|
||||
# 无数据 → 执行 SQL 重试
|
||||
if not self._table_has_data():
|
||||
if retry_sql:
|
||||
try:
|
||||
retry_sql()
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"运费核算 SQL 重试失败!单号: {order_no}, 错误: {e}")
|
||||
self._fill_order_no(order_no)
|
||||
self._click_search()
|
||||
|
||||
if not self._table_has_data():
|
||||
raise RuntimeError(f"运费核算查询无数据!单号: {order_no}")
|
||||
|
||||
# 点击编辑 (button_index=4, 来自 selector 文档 Step 3)
|
||||
self._click_table_action(button_index=4)
|
||||
|
||||
# ── 车队长全额收款模式:核算前选择车队长 + 收款模式 ──
|
||||
if team_leader_payment:
|
||||
self._select_team_leader_payment()
|
||||
|
||||
# 核算按钮 (selector 文档 Step 4)
|
||||
btn = self._modal().locator(
|
||||
"form > div:nth-child(4) button:nth-child(4) span"
|
||||
).first
|
||||
self._retry_click_modal_btn(btn, wait_after=1500)
|
||||
self.screenshot("freight_accounting_done")
|
||||
|
||||
def _select_team_leader_payment(self):
|
||||
"""在核算编辑弹窗中选择"车队长"+车队长"全额收款"模式。
|
||||
|
||||
前置条件:已经在运费核算编辑弹窗中(点击了编辑按钮之后)。
|
||||
步骤:
|
||||
1. 点击选择车队长下拉框,选中车队长
|
||||
2. 点击选择收款模式下拉框,选择车队长全额收款
|
||||
|
||||
注意: Element UI 的 el-select-dropdown popper 在 DOM 中可能有多个残留
|
||||
(旧的 hidden),需要用 nth(-1) 取最晚渲染的那个。
|
||||
"""
|
||||
# Step 1: 点击选择车队长下拉框
|
||||
selector_input = self.page.locator(
|
||||
"#app > div > div.main-container.hasTagsView > section > div > div > div "
|
||||
"> div.vxe-modal--wrapper.type--modal.is--animat.lock--view.is--mask.is--visible.is--active "
|
||||
"> div > div.vxe-modal--body > div.vxe-modal--content > div > form "
|
||||
"> div:nth-child(2) > div:nth-child(4) > div > div > div > div > input"
|
||||
).first
|
||||
self.wait_visible(selector_input, timeout_ms=5_000)
|
||||
selector_input.click()
|
||||
self.page.wait_for_timeout(800)
|
||||
|
||||
# Step 2: 在下拉列表中点击车队长选项(取最后一个下拉弹窗,它是最新渲染的)
|
||||
team_leader_option = self.page.locator(
|
||||
"body > div.el-select-dropdown.el-popper "
|
||||
"> div.el-scrollbar > div.el-select-dropdown__wrap.el-scrollbar__wrap "
|
||||
"> ul > li"
|
||||
).nth(-1)
|
||||
self.wait_visible(team_leader_option, timeout_ms=5_000)
|
||||
team_leader_option.click()
|
||||
# 等待车队长选择完成后,收款模式下拉框解除 disabled
|
||||
self.page.wait_for_timeout(1200)
|
||||
|
||||
# Step 3: 等待收款模式 el-select 解除 disabled 后再点击
|
||||
# Element UI 的 disabled 加在 el-select 外层的 div 上,不是 input 本身
|
||||
# 所以需要等包含 is-disabled class 的上层 div 消失
|
||||
payment_select_wrapper = self.page.locator(
|
||||
".vxe-modal--wrapper.is--visible "
|
||||
"form > div:nth-child(2) > div:nth-child(5) > div > div > div"
|
||||
).first
|
||||
payment_input = self.page.locator(
|
||||
".vxe-modal--wrapper.is--visible "
|
||||
"form > div:nth-child(2) > div:nth-child(5) input"
|
||||
).first
|
||||
self.wait_visible(payment_input, timeout_ms=5_000)
|
||||
for _ in range(30):
|
||||
cls = payment_select_wrapper.get_attribute("class") or ""
|
||||
if "is-disabled" not in cls:
|
||||
break
|
||||
self.page.wait_for_timeout(200)
|
||||
else:
|
||||
raise RuntimeError("收款模式下拉框始终未解除 disabled,可能车队长选择未生效")
|
||||
payment_input.click()
|
||||
self.page.wait_for_timeout(800)
|
||||
|
||||
# Step 4: 在下拉列表中点击"车队长全额收款"
|
||||
full_payment_option = self.page.locator(
|
||||
"body > div.el-select-dropdown.el-popper "
|
||||
"> div.el-scrollbar > div.el-select-dropdown__wrap.el-scrollbar__wrap "
|
||||
"> ul > li.el-select-dropdown__item.selected"
|
||||
).nth(-1)
|
||||
self.wait_visible(full_payment_option, timeout_ms=5_000)
|
||||
full_payment_option.click()
|
||||
self.page.wait_for_timeout(800)
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 运单审核
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
def do_waybill_audit(self, order_no: str):
|
||||
"""运单审核:导航→搜索→审核→滚动弹窗→审核通过。"""
|
||||
self._go("tms/Carrier/OrderDetailAudit")
|
||||
self._fill_order_no(order_no)
|
||||
self._click_search()
|
||||
|
||||
# 点击审核 (button_index=2, selector 文档 Step 10)
|
||||
self._click_table_action(button_index=2)
|
||||
|
||||
# 滚动弹窗 (selector 文档 Step 11)
|
||||
self._scroll_modal()
|
||||
|
||||
# 审核通过 (selector 文档 Step 12)
|
||||
btn = self._modal().locator(
|
||||
"._btns > button.el-button.el-button--primary.el-button--medium > span"
|
||||
).first
|
||||
self._retry_click_modal_btn(btn, wait_after=1500)
|
||||
self.screenshot("waybill_audit_done")
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 垫资-财务打款
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
def do_finance_payment(self, order_no: str, verify_code: str = "1234"):
|
||||
"""垫资-财务打款:导航→搜索→在线支付→滚动→勾选光大/手机→验证码→确认。"""
|
||||
self._go("tms/Carrier/dzFinancialapproval")
|
||||
self._fill_order_no(order_no)
|
||||
self._click_search()
|
||||
|
||||
# 在线支付 (button_index=2, selector 文档 Step 18)
|
||||
self._click_table_action(button_index=2)
|
||||
self._scroll_modal()
|
||||
|
||||
# 勾选光大银行 (selector 文档 Step 20)
|
||||
try:
|
||||
radio = self._modal().locator(
|
||||
"form > div:nth-child(9) > div > div:nth-child(2) "
|
||||
"> label > span.el-radio__input > span"
|
||||
).first
|
||||
self._retry_click_modal_btn(radio, timeout_ms=3_000, wait_after=300)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 勾选手机号 (selector 文档 Step 21)
|
||||
try:
|
||||
radio = self.page.locator(
|
||||
"[id^='el-collapse-content-'] > div > div:nth-child(3) "
|
||||
"> label > span.el-radio__input > span"
|
||||
).first
|
||||
self._retry_click_modal_btn(radio, timeout_ms=3_000, wait_after=300)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 获取验证码 (selector 文档 Step 22)
|
||||
try:
|
||||
btn = self._modal().locator(
|
||||
"form > div.el-row > div.el-col.el-col-4 button span"
|
||||
).first
|
||||
self._retry_click_modal_btn(btn, timeout_ms=3_000, wait_after=500)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 输入验证码 (selector 文档 Step 23)
|
||||
try:
|
||||
inp = self._modal().locator(
|
||||
"form > div.el-row > div.el-col.el-col-20 input"
|
||||
).first
|
||||
self.wait_visible(inp)
|
||||
inp.clear()
|
||||
inp.fill(verify_code)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 确认打款 (selector 文档 Step 24)
|
||||
btn = self._modal().locator(
|
||||
"form > div:nth-child(12) > div > button span"
|
||||
).first
|
||||
self._retry_click_modal_btn(btn, wait_after=1500)
|
||||
self.screenshot("finance_payment_done")
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 垫资-货主打款
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
def do_shipper_payment(self, order_no: str):
|
||||
"""垫资-货主打款:导航→搜索→打款→勾选光大→确认打款→确认弹窗。"""
|
||||
self._go("tms/Carrier/dzApplicationForPayment")
|
||||
self._fill_order_no(order_no)
|
||||
self._click_search()
|
||||
|
||||
# 打款 (button_index=2, selector 文档 Step 30)
|
||||
self._click_table_action(button_index=2)
|
||||
|
||||
# 勾选光大 (selector 文档 Step 31)
|
||||
try:
|
||||
radio = self._modal().locator(
|
||||
"form > div > div:nth-child(3) > div > div > div:nth-child(3) "
|
||||
"> label > span.el-radio__label"
|
||||
).first
|
||||
self._retry_click_modal_btn(radio, timeout_ms=3_000, wait_after=300)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 确认打款 (selector 文档 Step 31-2)
|
||||
btn = self._modal().locator("> div > div > button > span").first
|
||||
self._retry_click_modal_btn(btn, wait_after=1000)
|
||||
|
||||
# message-box 确认 (selector 文档 Step 32)
|
||||
try:
|
||||
msg = self.page.locator(
|
||||
"body > div.el-message-box__wrapper .el-message-box__btns "
|
||||
"> button.el-button.el-button--default.el-button--small.el-button--primary > span"
|
||||
)
|
||||
self._retry_click_modal_btn(msg, timeout_ms=5_000, wait_after=1500)
|
||||
except Exception:
|
||||
pass
|
||||
self.screenshot("shipper_payment_done")
|
||||
@@ -0,0 +1,54 @@
|
||||
"""登录页面 — Page Object。
|
||||
|
||||
Usage:
|
||||
from tests.pages.admin.login_page import LoginPage
|
||||
|
||||
login_page = LoginPage(page)
|
||||
login_page.login(phone="super_admin", password="***")
|
||||
"""
|
||||
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
from tests.pages.base.web_base_page import BasePage
|
||||
|
||||
|
||||
class LoginPage(BasePage):
|
||||
"""运八管理端登录页。"""
|
||||
|
||||
def __init__(self, page: Page):
|
||||
super().__init__(page)
|
||||
# ── 定位器声明 ──
|
||||
self.username_input = page.get_by_placeholder("请输入用户名")
|
||||
self.password_input = page.get_by_placeholder("请输入密码")
|
||||
self.login_btn = page.get_by_role("button", name="登录")
|
||||
self.error_tip = page.locator(".ant-form-item-explain-error")
|
||||
|
||||
def login(self, phone: str, password: str, expect_success: bool = True) -> None:
|
||||
"""执行登录流程。
|
||||
|
||||
Args:
|
||||
phone: 用户名/手机号
|
||||
password: 密码
|
||||
expect_success: True=预期成功跳转,False=预期留在登录页并有错误提示
|
||||
"""
|
||||
self.fill_field(self.username_input, phone)
|
||||
self.fill_field(self.password_input, password)
|
||||
self.click_when_ready(self.login_btn)
|
||||
|
||||
if expect_success:
|
||||
# 等待登录成功后页面跳转(hash 路由模式)
|
||||
self.page.wait_for_timeout(3000)
|
||||
assert "/#/login" not in self.page.url and "/login" not in self.page.url, \
|
||||
"登录失败,仍在登录页"
|
||||
else:
|
||||
# 预期登录失败,停留在当前页
|
||||
expect(self.page).to_have_url("**/login**", timeout=5_000)
|
||||
|
||||
def get_error_message(self) -> str:
|
||||
"""获取登录失败时的错误提示文本。"""
|
||||
self.wait_visible(self.error_tip)
|
||||
return self.error_tip.inner_text()
|
||||
|
||||
def is_on_login_page(self) -> bool:
|
||||
"""检查是否仍在登录页(兼容 hash 路由)。"""
|
||||
return "/login" in self.page.url or "/#/login" in self.page.url
|
||||
@@ -0,0 +1,53 @@
|
||||
"""导航栏公共组件。
|
||||
|
||||
跨页面复用的顶部/侧边导航操作。
|
||||
|
||||
Usage:
|
||||
from tests.pages.admin.navbar import NavBar
|
||||
|
||||
navbar = NavBar(page)
|
||||
navbar.go_to("运输管理")
|
||||
"""
|
||||
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
from tests.pages.base.web_base_page import BasePage
|
||||
|
||||
|
||||
class NavBar(BasePage):
|
||||
"""侧边导航栏组件。"""
|
||||
|
||||
def __init__(self, page: Page):
|
||||
super().__init__(page)
|
||||
# ⚠️ 以下定位器需要根据实际 DOM 调整
|
||||
self.sidebar = page.locator(".ant-layout-sider")
|
||||
self.logout_btn = page.get_by_text("退出登录")
|
||||
|
||||
def go_to(self, menu_name: str) -> None:
|
||||
"""点击侧边栏菜单项展开/跳转。
|
||||
|
||||
Args:
|
||||
menu_name: 菜单名称文本(如 "运输管理"、"基础信息")
|
||||
"""
|
||||
menu_item = self.page.get_by_text(menu_name).first
|
||||
self.click_when_ready(menu_item)
|
||||
|
||||
def go_to_submenu(self, parent_menu: str, sub_menu: str) -> None:
|
||||
"""点击父级菜单后,点击子菜单项。
|
||||
|
||||
Args:
|
||||
parent_menu: 父级菜单文本(如 "整车运输")
|
||||
sub_menu: 子菜单文本(如 "运单管理")
|
||||
"""
|
||||
self.go_to(parent_menu)
|
||||
sub_item = self.page.get_by_text(sub_menu).first
|
||||
self.click_when_ready(sub_item)
|
||||
|
||||
def logout(self) -> None:
|
||||
"""退出登录。"""
|
||||
self.click_when_ready(self.logout_btn)
|
||||
expect(self.page).to_have_url("#/login", timeout=10_000)
|
||||
|
||||
def is_expanded(self) -> bool:
|
||||
"""检查侧边栏是否展开。"""
|
||||
return self.sidebar.is_visible()
|
||||
@@ -0,0 +1 @@
|
||||
"""页面对象基类。"""
|
||||
@@ -0,0 +1,75 @@
|
||||
"""App Page Object 基类 — 各端通用 (Appium)。"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from appium.webdriver.common.appiumby import AppiumBy
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
from selenium.webdriver.support import expected_conditions as EC
|
||||
|
||||
from tests.core.allure_support import attach_png
|
||||
from tests.core.paths import SCREENSHOTS_DIR
|
||||
|
||||
|
||||
class AppBasePage:
|
||||
"""App Page Object 基类 — 各端通用。"""
|
||||
|
||||
def __init__(self, driver, platform: str, timeout_ms: int = 30_000):
|
||||
self.driver = driver
|
||||
self.platform = platform # "android" | "ios"
|
||||
self.timeout = timeout_ms / 1000
|
||||
|
||||
def wait_visible_by_id(self, accessibility_id: str, timeout_s: float | None = None) -> None:
|
||||
wait = WebDriverWait(self.driver, timeout=timeout_s or self.timeout)
|
||||
wait.until(
|
||||
EC.visibility_of_element_located((AppiumBy.ACCESSIBILITY_ID, accessibility_id))
|
||||
)
|
||||
|
||||
def wait_visible_by_text(self, text: str, timeout_s: float | None = None) -> None:
|
||||
wait = WebDriverWait(self.driver, timeout=timeout_s or self.timeout)
|
||||
locator = (
|
||||
(AppiumBy.XPATH, f"//*[@text='{text}']")
|
||||
if self.platform == "android"
|
||||
else (AppiumBy.XPATH, f"//*[@label='{text}']")
|
||||
)
|
||||
wait.until(EC.visibility_of_element_located(locator))
|
||||
|
||||
def tap_by_id(self, accessibility_id: str) -> None:
|
||||
self.wait_visible_by_id(accessibility_id)
|
||||
self.driver.find_element(AppiumBy.ACCESSIBILITY_ID, accessibility_id).click()
|
||||
|
||||
def tap_by_text(self, text: str) -> None:
|
||||
strategy = AppiumBy.XPATH
|
||||
locator = (
|
||||
f"//*[@text='{text}']"
|
||||
if self.platform == "android"
|
||||
else f"//*[@label='{text}']"
|
||||
)
|
||||
wait = WebDriverWait(self.driver, timeout=self.timeout)
|
||||
wait.until(EC.visibility_of_element_located((strategy, locator)))
|
||||
self.driver.find_element(strategy, locator).click()
|
||||
|
||||
def fill_by_id(self, accessibility_id: str, value: str) -> None:
|
||||
self.wait_visible_by_id(accessibility_id)
|
||||
element = self.driver.find_element(AppiumBy.ACCESSIBILITY_ID, accessibility_id)
|
||||
element.clear()
|
||||
element.send_keys(value)
|
||||
|
||||
def scroll_down(self) -> None:
|
||||
size = self.driver.get_window_size()
|
||||
self.driver.swipe(
|
||||
start_x=size["width"] // 2,
|
||||
start_y=int(size["height"] * 0.7),
|
||||
end_x=size["width"] // 2,
|
||||
end_y=int(size["height"] * 0.3),
|
||||
duration=500,
|
||||
)
|
||||
|
||||
def screenshot(self, name: str) -> str:
|
||||
"""关键节点截图(存盘 + attach Allure),返回文件路径。"""
|
||||
scenario_dir = SCREENSHOTS_DIR / self.__class__.__name__
|
||||
scenario_dir.mkdir(parents=True, exist_ok=True)
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
path = str(scenario_dir / f"{name}_{self.platform}_{ts}.png")
|
||||
self.driver.save_screenshot(path)
|
||||
attach_png(path, name=name)
|
||||
return path
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Web Page Object 基类 — 封装所有页面共享的通用操作 (Playwright)。
|
||||
|
||||
每个具体页面应继承 BasePage,在 __init__ 中声明所有定位器。
|
||||
|
||||
Usage:
|
||||
class LoginPage(BasePage):
|
||||
def __init__(self, page):
|
||||
super().__init__(page)
|
||||
self.phone_input = page.get_by_placeholder("请输入用户名")
|
||||
self.login_btn = page.get_by_role("button", name="登录")
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from playwright.sync_api import Page, Locator, expect
|
||||
|
||||
from tests.core.allure_support import attach_png
|
||||
from tests.core.paths import SCREENSHOTS_DIR
|
||||
|
||||
|
||||
class BasePage:
|
||||
"""Web Page Object 基类。"""
|
||||
|
||||
def __init__(self, page: Page, timeout_ms: int = 30_000):
|
||||
self.page = page
|
||||
self.timeout = timeout_ms
|
||||
|
||||
# ── 通用等待 ──
|
||||
|
||||
def wait_visible(self, locator: Locator, timeout_ms: Optional[int] = None) -> None:
|
||||
"""等待元素可见。"""
|
||||
expect(locator).to_be_visible(timeout=timeout_ms or self.timeout)
|
||||
|
||||
def wait_hidden(self, locator: Locator, timeout_ms: Optional[int] = None) -> None:
|
||||
"""等待元素消失(如 Loading 遮罩)。"""
|
||||
expect(locator).to_be_hidden(timeout=timeout_ms or self.timeout)
|
||||
|
||||
def wait_for_text(self, text: str, timeout_ms: Optional[int] = None) -> None:
|
||||
"""等待页面上出现指定文本。"""
|
||||
expect(self.page.get_by_text(text)).to_be_visible(timeout=timeout_ms or self.timeout)
|
||||
|
||||
# ── 通用操作 ──
|
||||
|
||||
def fill_field(self, locator: Locator, value: str) -> None:
|
||||
"""安全填写输入框(先等待可见,再清空后填写)。"""
|
||||
self.wait_visible(locator)
|
||||
locator.clear()
|
||||
locator.fill(value)
|
||||
|
||||
def click_when_ready(self, locator: Locator) -> None:
|
||||
"""安全点击(等待可见 + 可操作)。"""
|
||||
self.wait_visible(locator)
|
||||
locator.click()
|
||||
|
||||
def get_text(self, locator: Locator) -> str:
|
||||
"""获取元素的 inner text。"""
|
||||
self.wait_visible(locator)
|
||||
return locator.inner_text()
|
||||
|
||||
# ── 截图 ──
|
||||
|
||||
def screenshot(self, name: str, full_page: bool = False) -> str:
|
||||
"""关键节点截图(存盘 + attach Allure),返回文件路径。"""
|
||||
scenario_dir = SCREENSHOTS_DIR / self.__class__.__name__
|
||||
scenario_dir.mkdir(parents=True, exist_ok=True)
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
path = str(scenario_dir / f"{name}_{ts}.png")
|
||||
self.page.screenshot(path=path, full_page=full_page)
|
||||
attach_png(path, name=name)
|
||||
return path
|
||||
|
||||
# ── 导航 ──
|
||||
|
||||
def navigate(self, url: str) -> None:
|
||||
"""导航到指定 URL 并等待页面加载完成。"""
|
||||
self.page.goto(url, wait_until="domcontentloaded")
|
||||
@@ -0,0 +1 @@
|
||||
"""司机 APP 页面对象。"""
|
||||
@@ -0,0 +1,212 @@
|
||||
"""司机 APP 货运流程 Page Object — 货源→接单→装货→卸货→获取单号。
|
||||
|
||||
基于 resources/selectors/正常流程-司机.md 中的元素定位。
|
||||
使用 Appium UiAutomator2 定位策略。
|
||||
"""
|
||||
|
||||
from appium.webdriver.common.appiumby import AppiumBy
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
from selenium.webdriver.support import expected_conditions as EC
|
||||
|
||||
from tests.pages.base.app_base_page import AppBasePage
|
||||
|
||||
|
||||
class DriverFreightFlowPage(AppBasePage):
|
||||
"""司机端 APP - 货运完整流程(货源→接单→装货→卸货→获取单号)。"""
|
||||
|
||||
def __init__(self, driver, platform: str = "android"):
|
||||
super().__init__(driver, platform)
|
||||
|
||||
# ── 底部导航 ──
|
||||
# 0=首页, 1=货源, 2=消息, 3=我的
|
||||
self.freight_source_tab = (
|
||||
AppiumBy.ANDROID_UIAUTOMATOR,
|
||||
'new UiSelector().resourceId("com.arpa.ynchenggangdriver:id/fixed_bottom_navigation_icon").instance(1)',
|
||||
)
|
||||
|
||||
# ── 货源单列表 ──
|
||||
self.freight_order_item = (
|
||||
AppiumBy.ANDROID_UIAUTOMATOR,
|
||||
'new UiSelector().className("android.widget.RelativeLayout").instance(3)',
|
||||
)
|
||||
|
||||
# ── 数量 ──
|
||||
self.quantity_input = (AppiumBy.ID, "com.arpa.ynchenggangdriver:id/et_num")
|
||||
|
||||
# ── 协议勾选 ──
|
||||
self.agreement_checkbox = (AppiumBy.ID, "com.arpa.ynchenggangdriver:id/check_xieyi")
|
||||
|
||||
# ── 接单 ──
|
||||
self.accept_order_btn = (AppiumBy.ID, "com.arpa.ynchenggangdriver:id/tv_bian")
|
||||
|
||||
# ── 系统弹窗取消 ──
|
||||
self.cancel_btn = (AppiumBy.ID, "android:id/button2")
|
||||
|
||||
# ── 吨位 ──
|
||||
self.weight_input = (AppiumBy.ID, "com.arpa.ynchenggangdriver:id/et_dun")
|
||||
|
||||
# ── 上传图片 ──
|
||||
self.select_image_btn = (
|
||||
AppiumBy.ANDROID_UIAUTOMATOR,
|
||||
'new UiSelector().className("android.widget.ImageView").instance(2)',
|
||||
)
|
||||
self.album_btn = (AppiumBy.ANDROID_UIAUTOMATOR, 'new UiSelector().text("相册")')
|
||||
self.image_checkbox = (AppiumBy.ID, "com.arpa.ynchenggangdriver:id/cb_check")
|
||||
self.done_btn = (AppiumBy.ID, "com.arpa.ynchenggangdriver:id/btn_ok")
|
||||
|
||||
# ── 装货/卸货确认 ──
|
||||
self.load_unload_confirm = (AppiumBy.ID, "com.arpa.ynchenggangdriver:id/tv_nick")
|
||||
|
||||
# ── 合同 ──
|
||||
self.contract_webview = (AppiumBy.ID, "com.arpa.ynchenggangdriver:id/textHtml")
|
||||
self.acknowledge_btn = (AppiumBy.ID, "com.arpa.ynchenggangdriver:id/tv_affirm")
|
||||
self.confirm_btn = (AppiumBy.ID, "com.arpa.ynchenggangdriver:id/ok_tv3")
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 基础操作封装
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
def _wait_and_click(self, locator, timeout_s: float = 10.0):
|
||||
wait = WebDriverWait(self.driver, timeout=timeout_s)
|
||||
wait.until(EC.visibility_of_element_located(locator))
|
||||
self.driver.find_element(*locator).click()
|
||||
|
||||
def _wait_and_fill(self, locator, value: str, timeout_s: float = 10.0):
|
||||
wait = WebDriverWait(self.driver, timeout=timeout_s)
|
||||
wait.until(EC.visibility_of_element_located(locator))
|
||||
el = self.driver.find_element(*locator)
|
||||
el.clear()
|
||||
el.send_keys(value)
|
||||
|
||||
def _tap_by_text(self, text: str, timeout_s: float = 10.0):
|
||||
locator = (AppiumBy.ANDROID_UIAUTOMATOR, f'new UiSelector().text("{text}")')
|
||||
self._wait_and_click(locator, timeout_s)
|
||||
|
||||
def _safe_tap_by_text(self, text: str, timeout_s: float = 5.0):
|
||||
"""容错点击:元素不存在时静默跳过。"""
|
||||
try:
|
||||
self._tap_by_text(text, timeout_s)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _swipe_up(self, locator=None, duration_ms: int = 800):
|
||||
"""向上滑动。指定 locator 时在元素内滑动,否则全屏滑动。"""
|
||||
if locator:
|
||||
try:
|
||||
wait = WebDriverWait(self.driver, timeout=5.0)
|
||||
el = wait.until(EC.presence_of_element_located(locator))
|
||||
size = el.size
|
||||
loc = el.location
|
||||
start_x = loc["x"] + size["width"] // 2
|
||||
start_y = loc["y"] + int(size["height"] * 0.8)
|
||||
end_y = loc["y"] + int(size["height"] * 0.2)
|
||||
self.driver.swipe(start_x, start_y, start_x, end_y, duration_ms)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
# 全屏滑动
|
||||
size = self.driver.get_window_size()
|
||||
self.driver.swipe(
|
||||
start_x=size["width"] // 2,
|
||||
start_y=int(size["height"] * 0.7),
|
||||
end_x=size["width"] // 2,
|
||||
end_y=int(size["height"] * 0.3),
|
||||
duration=duration_ms,
|
||||
)
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 接单流程 (步骤 1-5)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
def accept_order_flow(self, quantity: str = "1"):
|
||||
"""接单完整流程:货源 tab → 货源单 → 数量 → 协议 → 接单。"""
|
||||
self._wait_and_click(self.freight_source_tab) # ① 点击货源
|
||||
self.driver.implicitly_wait(1)
|
||||
self._wait_and_click(self.freight_order_item) # ② 点击货源单
|
||||
self.driver.implicitly_wait(2)
|
||||
self._wait_and_fill(self.quantity_input, quantity) # ③ 填写数量
|
||||
self._wait_and_click(self.agreement_checkbox) # ④ 勾选协议
|
||||
self._wait_and_click(self.accept_order_btn) # ⑤ 接单
|
||||
self.driver.implicitly_wait(2)
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 装货流程 (步骤 6-17)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
def load_goods_flow(self, weight: str = "10"):
|
||||
"""装货完整流程。"""
|
||||
self._tap_by_text("装货") # ⑥ 点击装货
|
||||
self.driver.implicitly_wait(1)
|
||||
self._wait_and_click(self.cancel_btn) # ⑦ 点击取消(系统弹窗)
|
||||
self.driver.implicitly_wait(1)
|
||||
self._wait_and_fill(self.weight_input, weight) # ⑧ 输入吨位
|
||||
self._wait_and_click(self.select_image_btn) # ⑨ 选择图片
|
||||
self.driver.implicitly_wait(1)
|
||||
self._wait_and_click(self.album_btn) # ⑩ 相册
|
||||
self.driver.implicitly_wait(1)
|
||||
self._wait_and_click(self.image_checkbox) # ⑪ 勾选图片
|
||||
self._wait_and_click(self.done_btn) # ⑫ 完成
|
||||
self.driver.implicitly_wait(2)
|
||||
self._wait_and_click(self.agreement_checkbox) # ⑬ 勾选协议
|
||||
self._wait_and_click(self.load_unload_confirm) # ⑭ 装货确认
|
||||
self.driver.implicitly_wait(1)
|
||||
self._swipe_up(self.contract_webview) # ⑮ 滑动合同
|
||||
self.driver.implicitly_wait(1)
|
||||
self._safe_tap_by_text("已知晓", timeout_s=5.0) # ⑯ 已知晓(容错)
|
||||
self._safe_tap_by_text("确定", timeout_s=5.0) # ⑰ 确定(容错)
|
||||
self.driver.implicitly_wait(2)
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 卸货流程 (步骤 19-27)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
def unload_goods_flow(self, weight: str = "10"):
|
||||
"""卸货完整流程。"""
|
||||
self._tap_by_text("卸货") # ⑲ 点击卸货
|
||||
self.driver.implicitly_wait(1)
|
||||
self._wait_and_click(self.cancel_btn) # ⑳ 点击取消(系统弹窗)
|
||||
self.driver.implicitly_wait(1)
|
||||
self._wait_and_fill(self.weight_input, weight) # ㉑ 输入吨位
|
||||
self._wait_and_click(self.select_image_btn) # ㉒ 选择图片
|
||||
self.driver.implicitly_wait(1)
|
||||
self._wait_and_click(self.album_btn) # ㉓ 相册
|
||||
self.driver.implicitly_wait(1)
|
||||
self._wait_and_click(self.image_checkbox) # ㉔ 勾选图片
|
||||
self._wait_and_click(self.done_btn) # ㉕ 完成
|
||||
self.driver.implicitly_wait(2)
|
||||
self._wait_and_click(self.load_unload_confirm) # ㉖ 卸货确认
|
||||
self.driver.implicitly_wait(1)
|
||||
self._safe_tap_by_text("确定", timeout_s=5.0) # ㉗ 确定(容错)
|
||||
self.driver.implicitly_wait(2)
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 获取单号 (步骤 28)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
def get_transport_order_no(self, timeout_s: float = 10.0) -> str:
|
||||
"""获取运输单号。从"运输单号:RHJC..."中正则提取纯单号。"""
|
||||
import re
|
||||
|
||||
locator = (
|
||||
AppiumBy.ANDROID_UIAUTOMATOR,
|
||||
'new UiSelector().textContains("运输单号")',
|
||||
)
|
||||
wait = WebDriverWait(self.driver, timeout=timeout_s)
|
||||
el = wait.until(EC.visibility_of_element_located(locator))
|
||||
full_text = el.text
|
||||
match = re.search(r"RHJC\S+", full_text)
|
||||
if match:
|
||||
return match.group()
|
||||
return full_text.replace("运输单号:", "").replace("运输单号:", "").strip()
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 全流程
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
def full_driver_flow(self, quantity: str = "1", load_weight: str = "10",
|
||||
unload_weight: str = "10") -> str:
|
||||
"""执行完整司机端流程:接单→装货→卸货→返回运输单号。"""
|
||||
self.accept_order_flow(quantity)
|
||||
self.load_goods_flow(load_weight)
|
||||
self.unload_goods_flow(unload_weight)
|
||||
return self.get_transport_order_no()
|
||||
@@ -0,0 +1,26 @@
|
||||
"""司机端 Page Object — 首页。"""
|
||||
|
||||
from tests.pages.base.app_base_page import AppBasePage
|
||||
|
||||
|
||||
class DriverHomePage(AppBasePage):
|
||||
"""司机端 APP 首页。"""
|
||||
|
||||
def __init__(self, driver, platform: str = "android"):
|
||||
super().__init__(driver, platform)
|
||||
self.order_tab = "tab_orders"
|
||||
self.mine_tab = "tab_mine"
|
||||
self.home_tab = "tab_home"
|
||||
|
||||
def is_displayed(self) -> bool:
|
||||
try:
|
||||
self.wait_visible_by_id(self.home_tab, timeout_s=10)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def go_to_order_list(self) -> None:
|
||||
self.tap_by_id(self.order_tab)
|
||||
|
||||
def go_to_mine(self) -> None:
|
||||
self.tap_by_id(self.mine_tab)
|
||||
Reference in New Issue
Block a user