diff --git a/tests/app/pages/base_page.py b/tests/app/_base_page.py similarity index 66% rename from tests/app/pages/base_page.py rename to tests/app/_base_page.py index 92b2e27..1100023 100644 --- a/tests/app/pages/base_page.py +++ b/tests/app/_base_page.py @@ -1,19 +1,9 @@ -"""App Page Object 基类 — 封装移动端页面共享的通用操作。 - -每个 App 页面应继承 AppBasePage,在 __init__ 中声明所有定位器。 - -Usage: - class DriverHomePage(AppBasePage): - def __init__(self, driver, platform): - super().__init__(driver, platform) - self.order_tab = driver.find_element(AppiumBy.ACCESSIBILITY_ID, "tab_orders") -""" +"""通用 App Page Object 基类。""" from datetime import datetime from pathlib import Path from appium.webdriver.common.appiumby import AppiumBy -from appium.webdriver.webdriver import WebDriver from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC @@ -21,42 +11,33 @@ SCREENSHOTS_DIR = Path("output/screenshots") class AppBasePage: - """App Page Object 基类。""" + """App Page Object 基类 — 各端通用。""" - def __init__(self, driver: WebDriver, platform: str, timeout_ms: int = 30_000): + def __init__(self, driver, platform: str, timeout_ms: int = 30_000): self.driver = driver - self.platform = platform # "android" | "ios" - self.timeout = timeout_ms / 1000 # WebDriverWait 使用秒 - - # ── 通用等待 ── + 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: - """等待 accessibility_id 对应的元素可见。""" 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}']" + (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: - """按 accessibility_id 点击元素(推荐优先使用)。""" 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}']" @@ -68,14 +49,12 @@ class AppBasePage: self.driver.find_element(strategy, locator).click() def fill_by_id(self, accessibility_id: str, value: str) -> None: - """按 accessibility_id 找到输入框并填入内容。""" 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: - """向下滑动(从屏幕 70% 滑到 30%)。""" size = self.driver.get_window_size() self.driver.swipe( start_x=size["width"] // 2, @@ -85,10 +64,7 @@ class AppBasePage: duration=500, ) - # ── 截图 ── - def screenshot(self, name: str) -> str: - """关键节点截图,返回文件路径。""" 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") diff --git a/tests/app/_shared.py b/tests/app/_shared.py new file mode 100644 index 0000000..03e7762 --- /dev/null +++ b/tests/app/_shared.py @@ -0,0 +1,125 @@ +"""App 端公共模块 — driver 创建 + ensure_logged_in 工厂。 + +所有 App 端 conftest 调用 share_* 函数即可,避免重复代码。 +""" + +from datetime import datetime +from pathlib import Path + +import pytest + +SCREENSHOTS_DIR = Path("output/screenshots") + + +def create_driver(section_config: dict, platform: str): + """创建 Appium driver。 + + Args: + section_config: 配置中对应端的 section(如 config["driver_android"]) + platform: "android" | "ios" + """ + try: + from appium import webdriver + from appium.options.android import UiAutomator2Options + from appium.options.ios import XCUITestOptions + except ImportError: + pytest.skip("Appium-Python-Client 未安装,跳过 App 测试。") + + appium_host = section_config.get("appium_host", "http://localhost:4723") + + if platform == "android": + options = UiAutomator2Options() + options.device_name = section_config["device_name"] + options.app_package = section_config["app_package"] + options.app_activity = section_config["app_activity"] + options.no_reset = section_config.get("no_reset", True) + options.new_command_timeout = section_config.get("new_command_timeout", 120) + options.automation_name = "UiAutomator2" + else: + options = XCUITestOptions() + options.device_name = section_config["device_name"] + options.bundle_id = section_config["bundle_id"] + options.no_reset = section_config.get("no_reset", True) + options.new_command_timeout = section_config.get("new_command_timeout", 120) + options.automation_name = "XCUITest" + + return webdriver.Remote(appium_host, options=options) + + +def make_ensure_logged_in( + section_config: dict, + config: dict, + account_role: str, + home_indicator: str = "tab_home", +): + """创建一个 ensure_logged_in 闭包。 + + Args: + section_config: App 端配置 + config: 全局配置 + account_role: 默认账号角色名(如 "driver"、"shipper") + home_indicator: 首页元素的 accessibility_id + """ + from selenium.webdriver.support.ui import WebDriverWait + from selenium.webdriver.support import expected_conditions as EC + from appium.webdriver.common.appiumby import AppiumBy + + def _ensure_logged_in(driver, timeout_s: float = 10.0) -> None: + skip_login = section_config.get("skip_login", True) + wait = WebDriverWait(driver, timeout=timeout_s) + + try: + wait.until( + EC.visibility_of_element_located((AppiumBy.ACCESSIBILITY_ID, home_indicator)) + ) + return + except Exception: + pass + + try: + login_input = driver.find_element(AppiumBy.ACCESSIBILITY_ID, "login_phone_input") + if login_input.is_displayed(): + if skip_login: + raise RuntimeError( + "APP 在登录页,未检测到登录态!请在设备上手动完成一次登录(含验证码)," + "之后重新运行用例即可。\n如需自动化登录,请设置 skip_login: false。" + ) + account = config["accounts"].get(account_role, {}) + login_input.send_keys(account.get("phone", "")) + driver.find_element( + AppiumBy.ACCESSIBILITY_ID, "login_password_input" + ).send_keys(account.get("password", "")) + driver.find_element(AppiumBy.ACCESSIBILITY_ID, "login_submit_btn").click() + wait.until( + EC.visibility_of_element_located((AppiumBy.ACCESSIBILITY_ID, home_indicator)) + ) + return + except RuntimeError: + raise + except Exception: + pass + + raise RuntimeError("无法确定 APP 的登录状态!请确保 APP 已启动且首页或登录页可见。") + + return _ensure_logged_in + + +def save_failure_screenshot(driver, section_config, platform, request): + """失败自动截图(各端 conftest teardown 中调用)。""" + if ( + section_config.get("screenshot_on_failure", True) + and 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_{platform}_{ts}.png") + driver.save_screenshot(path) + + +@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/app/conftest.py b/tests/app/conftest.py deleted file mode 100644 index 2ae4442..0000000 --- a/tests/app/conftest.py +++ /dev/null @@ -1,198 +0,0 @@ -""" -App 端根级 fixture:Appium Driver 实例 + 登录态复用 + 失败自动截图。 - -核心机制 — 手动登录后复用: - Appium noReset=true 确保 app 数据不重置,登录态持久保留在设备上。 - 只需手动在设备上登录一次,后续用例通过 ensure_logged_in 检查首页是否已登录态。 - 如果检测到未登录(在登录页),给出明确指引让用户先去设备上手动登录。 - -配置 app.skip_login: true 时: - - 用例通过 ensure_logged_in() 检查是否已登录 - - 未登录则抛出明确错误,引导用户先去设备上手动登录 - - 已登录则直接开始执行 - -配置 app.skip_login: false 时: - - 每次用例尝试完整 UI 登录(有验证码时会失败) - -提供: - app_config — session 级 App 配置 - driver — Appium WebDriver(每个用例独立,失败自动截图) - platform — 当前平台 -""" - -from datetime import datetime -from pathlib import Path - -import pytest - -SCREENSHOTS_DIR = Path("output/screenshots") - - -@pytest.fixture(scope="session") -def app_config(config): - """session 级:App 配置段。""" - return config["app"] - - -@pytest.fixture -def platform(app_config) -> str: - """当前执行的平台。""" - return app_config.get("platforms", ["android"])[0] - - -@pytest.fixture -def driver(app_config, platform, request): - """每个用例独立的 Appium driver,失败自动截图。 - - 前提: Appium Server 已启动(appium --log-level info)。 - noReset=true → app 不重置 → 登录态持久保留。 - """ - try: - from appium import webdriver - from appium.options.android import UiAutomator2Options - from appium.options.ios import XCUITestOptions - except ImportError: - pytest.skip("Appium-Python-Client 未安装,跳过 App 测试。") - - appium_host = app_config.get("appium_host", "http://localhost:4723") - screenshot_on_failure = app_config.get("screenshot_on_failure", True) - - driver = None - try: - if platform == "android": - android_cfg = app_config["android"] - options = UiAutomator2Options() - options.device_name = android_cfg["device_name"] - options.app_package = android_cfg["app_package"] - options.app_activity = android_cfg["app_activity"] - options.no_reset = android_cfg.get("no_reset", True) - options.new_command_timeout = android_cfg.get("new_command_timeout", 120) - options.automation_name = "UiAutomator2" - else: - ios_cfg = app_config["ios"] - options = XCUITestOptions() - options.device_name = ios_cfg["device_name"] - options.bundle_id = ios_cfg["bundle_id"] - options.no_reset = ios_cfg.get("no_reset", True) - options.new_command_timeout = ios_cfg.get("new_command_timeout", 120) - options.automation_name = "XCUITest" - - driver = webdriver.Remote(appium_host, options=options) - - yield driver - - # ── 失败自动截图 ── - if ( - screenshot_on_failure - and hasattr(request.node, "rep_call") - and request.node.rep_call.failed - and driver is not None - ): - 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_{platform}_{ts}.png") - driver.save_screenshot(path) - - finally: - if driver is not None: - driver.quit() - - -@pytest.fixture -def ensure_logged_in(driver, platform, app_config, config): - """确保 APP 已处于登录状态。 - - 使用方式: - def test_xxx(driver, ensure_logged_in): - ensure_logged_in() # 检查登录态,未登录则报错 - # 后面直接写业务断言 - """ - - def _ensure_logged_in(timeout_s: float = 10.0) -> None: - skip_login = app_config.get("skip_login", True) - - from selenium.webdriver.support.ui import WebDriverWait - from selenium.webdriver.support import expected_conditions as EC - from appium.webdriver.common.appiumby import AppiumBy - - # ⚠️ home_tab 的 accessibility_id 需根据实际 APP UI 确认 - HOME_INDICATOR_ID = "tab_home" # 首页 tab 的 accessibility_id - - wait = WebDriverWait(driver, timeout=timeout_s) - - try: - wait.until( - EC.visibility_of_element_located((AppiumBy.ACCESSIBILITY_ID, HOME_INDICATOR_ID)) - ) - # 已登录,无需操作 - return - except Exception: - pass - - # ── 未检测到首页元素,判断是否在登录页 ── - try: - # 尝试找登录页元素 - login_input = driver.find_element(AppiumBy.ACCESSIBILITY_ID, "login_phone_input") - if login_input.is_displayed(): - if skip_login: - raise RuntimeError( - "APP 当前在登录页,未检测到登录态!\n" - "请在设备上手动完成一次登录(包含验证码),之后重新运行用例即可。\n" - "如需通过自动化登录,请设置 app.skip_login: false(不推荐,有验证码)。" - ) - # skip_login: false — 走自动化登录 - account = config["accounts"].get("driver") - if not account: - raise RuntimeError("配置中未找到 driver 账号") - - login_input.send_keys(account["phone"]) - driver.find_element(AppiumBy.ACCESSIBILITY_ID, "login_password_input").send_keys( - account["password"] - ) - driver.find_element(AppiumBy.ACCESSIBILITY_ID, "login_submit_btn").click() - - # 等待登录完成 - wait.until( - EC.visibility_of_element_located( - (AppiumBy.ACCESSIBILITY_ID, HOME_INDICATOR_ID) - ) - ) - return - except RuntimeError: - raise # 重新抛出明确的错误提示 - except Exception: - pass - - raise RuntimeError( - f"无法确定 APP 的登录状态!请确保 APP 已启动且首页或登录页可见。\n" - f"当前平台: {platform}" - ) - - return _ensure_logged_in - - -@pytest.fixture -def is_logged_in(driver, platform, app_config) -> bool: - """快速检查当前是否已登录(不执行登录操作)。""" - from selenium.webdriver.support.ui import WebDriverWait - from selenium.webdriver.support import expected_conditions as EC - from appium.webdriver.common.appiumby import AppiumBy - - try: - wait = WebDriverWait(driver, timeout=5) - wait.until( - EC.visibility_of_element_located((AppiumBy.ACCESSIBILITY_ID, "tab_home")) - ) - return True - except Exception: - return False - - -# ── pytest 报告 hook ── -@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/app/driver_android/conftest.py b/tests/app/driver_android/conftest.py new file mode 100644 index 0000000..a61eb60 --- /dev/null +++ b/tests/app/driver_android/conftest.py @@ -0,0 +1,27 @@ +"""司机安卓端 fixture — Appium + 登录态复用。""" + +import pytest +from tests.app._shared import create_driver, make_ensure_logged_in, save_failure_screenshot + +from tests.app._shared import pytest_runtest_makereport # noqa: F401 — report hook + + +@pytest.fixture +def driver(driver_android_config, request): + """司机安卓端 driver (noReset=true)。""" + 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: + d.quit() + + +@pytest.fixture +def ensure_logged_in(driver_android_config, config): + """确保已登录态(司机角色)。""" + fn = make_ensure_logged_in(driver_android_config, config, account_role="driver") + return lambda d: fn(d) diff --git a/tests/app/driver_android/pages/driver_home_page.py b/tests/app/driver_android/pages/driver_home_page.py new file mode 100644 index 0000000..c78476c --- /dev/null +++ b/tests/app/driver_android/pages/driver_home_page.py @@ -0,0 +1,26 @@ +"""司机安卓端 Page Object — 首页。""" + +from tests.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) diff --git a/tests/app/driver_android/tests/test_smoke.py b/tests/app/driver_android/tests/test_smoke.py new file mode 100644 index 0000000..f8157ff --- /dev/null +++ b/tests/app/driver_android/tests/test_smoke.py @@ -0,0 +1,18 @@ +"""司机安卓端 — 冒烟测试。""" + +import pytest + +pytestmark = [pytest.mark.driver_android] + + +class TestDriverAndroidSmoke: + """司机安卓端基础验证。""" + + @pytest.mark.p0 + @pytest.mark.smoke + def test_home_page_loaded(self, driver, ensure_logged_in): + """验证登录态有效,首页正常。""" + ensure_logged_in(driver) + from appium.webdriver.common.appiumby import AppiumBy + home = driver.find_element(AppiumBy.ACCESSIBILITY_ID, "tab_home") + assert home.is_displayed(), "首页 tab 应可见" diff --git a/tests/app/driver_ios/conftest.py b/tests/app/driver_ios/conftest.py new file mode 100644 index 0000000..341ec56 --- /dev/null +++ b/tests/app/driver_ios/conftest.py @@ -0,0 +1,27 @@ +"""司机苹果端 fixture — Appium + 登录态复用。""" + +import pytest +from tests.app._shared import create_driver, make_ensure_logged_in, save_failure_screenshot + +from tests.app._shared import pytest_runtest_makereport # noqa: F401 + + +@pytest.fixture +def driver(driver_ios_config, request): + """司机苹果端 driver。""" + cfg = driver_ios_config + d = None + try: + d = create_driver(cfg, platform="ios") + yield d + save_failure_screenshot(d, cfg, "ios", request) + finally: + if d is not None: + d.quit() + + +@pytest.fixture +def ensure_logged_in(driver_ios_config, config): + """确保已登录态(司机角色)。""" + fn = make_ensure_logged_in(driver_ios_config, config, account_role="driver") + return lambda d: fn(d) diff --git a/tests/app/driver_ios/pages/__init__.py b/tests/app/driver_ios/pages/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/app/driver_ios/tests/__init__.py b/tests/app/driver_ios/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/app/driver_ios/tests/test_smoke.py b/tests/app/driver_ios/tests/test_smoke.py new file mode 100644 index 0000000..e94cb61 --- /dev/null +++ b/tests/app/driver_ios/tests/test_smoke.py @@ -0,0 +1,18 @@ +"""司机苹果端 — 冒烟测试。""" + +import pytest + +pytestmark = [pytest.mark.driver_ios] + + +class TestDriverIosSmoke: + """司机苹果端基础验证。""" + + @pytest.mark.p0 + @pytest.mark.smoke + def test_home_page_loaded(self, driver, ensure_logged_in): + """验证登录态有效,首页正常。""" + ensure_logged_in(driver) + from appium.webdriver.common.appiumby import AppiumBy + home = driver.find_element(AppiumBy.ACCESSIBILITY_ID, "tab_home") + assert home.is_displayed(), "首页 tab 应可见" diff --git a/tests/app/pages/driver_home_page.py b/tests/app/pages/driver_home_page.py deleted file mode 100644 index 284086c..0000000 --- a/tests/app/pages/driver_home_page.py +++ /dev/null @@ -1,47 +0,0 @@ -"""司机端首页 — Page Object(App 端)。 - -Usage: - from tests.app.pages.driver_home_page import DriverHomePage - - home = DriverHomePage(driver, platform="android") - home.go_to_order_list() -""" - -from appium.webdriver.webdriver import WebDriver - -from tests.app.pages.base_page import AppBasePage - - -class DriverHomePage(AppBasePage): - """运八司机端 APP 首页。""" - - def __init__(self, driver: WebDriver, platform: str = "android"): - super().__init__(driver, platform) - # ⚠️ 以下 accessibility_id 需要根据实际 APP UI 确认 - self.order_tab = "tab_orders" # 运单 tab - self.mine_tab = "tab_mine" # 我的 tab - self.home_tab = "tab_home" # 首页 tab - self.notification_badge = "badge_notification" - - 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) - - def get_notification_count(self) -> str: - """获取通知角标数量。""" - element = self.driver.find_element( - self.driver.find_element.ACCESSIBILITY_ID, self.notification_badge - ) - return element.text if element else "0" diff --git a/tests/app/shipper_android/conftest.py b/tests/app/shipper_android/conftest.py new file mode 100644 index 0000000..80a8e9d --- /dev/null +++ b/tests/app/shipper_android/conftest.py @@ -0,0 +1,27 @@ +"""货主安卓端 fixture — Appium + 登录态复用。""" + +import pytest +from tests.app._shared import create_driver, make_ensure_logged_in, save_failure_screenshot + +from tests.app._shared import pytest_runtest_makereport # noqa: F401 + + +@pytest.fixture +def driver(shipper_android_config, request): + """货主安卓端 driver。""" + cfg = shipper_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: + d.quit() + + +@pytest.fixture +def ensure_logged_in(shipper_android_config, config): + """确保已登录态(货主角色)。""" + fn = make_ensure_logged_in(shipper_android_config, config, account_role="shipper") + return lambda d: fn(d) diff --git a/tests/app/shipper_android/pages/__init__.py b/tests/app/shipper_android/pages/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/app/shipper_android/tests/__init__.py b/tests/app/shipper_android/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/app/shipper_android/tests/test_smoke.py b/tests/app/shipper_android/tests/test_smoke.py new file mode 100644 index 0000000..fff8953 --- /dev/null +++ b/tests/app/shipper_android/tests/test_smoke.py @@ -0,0 +1,18 @@ +"""货主安卓端 — 冒烟测试。""" + +import pytest + +pytestmark = [pytest.mark.shipper_android] + + +class TestShipperAndroidSmoke: + """货主安卓端基础验证。""" + + @pytest.mark.p0 + @pytest.mark.smoke + def test_home_page_loaded(self, driver, ensure_logged_in): + """验证登录态有效,首页正常。""" + ensure_logged_in(driver) + from appium.webdriver.common.appiumby import AppiumBy + home = driver.find_element(AppiumBy.ACCESSIBILITY_ID, "tab_home") + assert home.is_displayed(), "首页 tab 应可见" diff --git a/tests/app/shipper_ios/conftest.py b/tests/app/shipper_ios/conftest.py new file mode 100644 index 0000000..5bea314 --- /dev/null +++ b/tests/app/shipper_ios/conftest.py @@ -0,0 +1,27 @@ +"""货主苹果端 fixture — Appium + 登录态复用。""" + +import pytest +from tests.app._shared import create_driver, make_ensure_logged_in, save_failure_screenshot + +from tests.app._shared import pytest_runtest_makereport # noqa: F401 + + +@pytest.fixture +def driver(shipper_ios_config, request): + """货主苹果端 driver。""" + cfg = shipper_ios_config + d = None + try: + d = create_driver(cfg, platform="ios") + yield d + save_failure_screenshot(d, cfg, "ios", request) + finally: + if d is not None: + d.quit() + + +@pytest.fixture +def ensure_logged_in(shipper_ios_config, config): + """确保已登录态(货主角色)。""" + fn = make_ensure_logged_in(shipper_ios_config, config, account_role="shipper") + return lambda d: fn(d) diff --git a/tests/app/shipper_ios/pages/__init__.py b/tests/app/shipper_ios/pages/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/app/shipper_ios/tests/__init__.py b/tests/app/shipper_ios/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/app/shipper_ios/tests/test_smoke.py b/tests/app/shipper_ios/tests/test_smoke.py new file mode 100644 index 0000000..94c7f71 --- /dev/null +++ b/tests/app/shipper_ios/tests/test_smoke.py @@ -0,0 +1,18 @@ +"""货主苹果端 — 冒烟测试。""" + +import pytest + +pytestmark = [pytest.mark.shipper_ios] + + +class TestShipperIosSmoke: + """货主苹果端基础验证。""" + + @pytest.mark.p0 + @pytest.mark.smoke + def test_home_page_loaded(self, driver, ensure_logged_in): + """验证登录态有效,首页正常。""" + ensure_logged_in(driver) + from appium.webdriver.common.appiumby import AppiumBy + home = driver.find_element(AppiumBy.ACCESSIBILITY_ID, "tab_home") + assert home.is_displayed(), "首页 tab 应可见" diff --git a/tests/app/test_login/test_login.py b/tests/app/test_login/test_login.py deleted file mode 100644 index 57cbb71..0000000 --- a/tests/app/test_login/test_login.py +++ /dev/null @@ -1,108 +0,0 @@ -"""App 登录功能测试用例。 - -覆盖: - - (skip_login: true) 验证预登录态有效,用例直接复用已登录状态 - - (skip_login: false) 完整 UI 登录流程 - -前置条件: - 1. Appium Server 已启动: appium --log-level info - 2. (推荐) 已在设备上手动登录一次,后续用例自动跳过登录 - 3. app.skip_login: true(默认)→ 用例通过 ensure_logged_in 检查登录态 - 4. app.skip_login: false → 每次执行完整 UI 登录(有验证码会失败) - -标记: - pytest -m app # 仅执行 App 用例 - pytest -m p0 # 仅执行 P0 冒烟 - pytest -m smoke # 仅执行冒烟 -""" - -import pytest -from appium.webdriver.common.appiumby import AppiumBy - -pytestmark = [pytest.mark.app] - - -# ═══════════════════════════════════════════════════════════════════ -# P0 — 冒烟测试(依赖手动登录后的持久化登录态) -# ═══════════════════════════════════════════════════════════════════ - -class TestAppSmoke: - """验证 App 登录态有效,核心页面可正常访问。""" - - @pytest.mark.p0 - @pytest.mark.smoke - def test_already_logged_in(self, driver, platform, ensure_logged_in): - """验证 APP 已处于登录状态(手动登录后 noReset 持久化)。""" - ensure_logged_in() - # 主页面 tab 应可见 - home_tab = driver.find_element(AppiumBy.ACCESSIBILITY_ID, "tab_home") - assert home_tab.is_displayed(), "登录后应展示首页 tab" - - @pytest.mark.p0 - @pytest.mark.smoke - def test_navigate_to_order_list(self, driver, platform, ensure_logged_in): - """验证登录后可导航到运单列表。""" - from tests.app.pages.driver_home_page import DriverHomePage - - ensure_logged_in() - home = DriverHomePage(driver, platform=platform) - home.go_to_order_list() - - # 验证运单列表可见 - # ⚠️ 定位需根据实际 APP UI 调整 - order_list = driver.find_element(AppiumBy.ACCESSIBILITY_ID, "order_list") - assert order_list.is_displayed(), "运单列表应可见" - - @pytest.mark.p0 - @pytest.mark.smoke - def test_tab_switching(self, driver, platform, ensure_logged_in): - """验证底部 tab 可正常切换。""" - from tests.app.pages.driver_home_page import DriverHomePage - - ensure_logged_in() - home = DriverHomePage(driver, platform=platform) - - # 点击"我的" - home.go_to_mine() - # ⚠️ mine_page 的 accessibility_id 需根据实际 APP UI 确认 - mine_view = driver.find_element(AppiumBy.ACCESSIBILITY_ID, "mine_page") - assert mine_view.is_displayed(), "应展示「我的」页面" - - # 切回首页 - home.tap_by_id("tab_home") - assert home.is_displayed(), "应切回首页" - - -# ═══════════════════════════════════════════════════════════════════ -# P1 — 登录页验证(仅在 skip_login: false 时有意义) -# ═══════════════════════════════════════════════════════════════════ - -class TestAppLoginPage: - """登录页基础验证。""" - - @pytest.mark.p1 - def test_login_page_elements(self, driver, platform, app_config): - """验证登录页元素存在(不执行登录)。""" - # ⚠️ 前提: app 当前在登录页(未登录状态) - # 如果已登录,此用例可能找不到登录页元素 - from tests.app.conftest import is_logged_in - - # 如果已登录,跳过此用例 - try: - if is_logged_in: - pytest.skip("APP 已登录,无法验证登录页元素") - except Exception: - pass - - # 验证登录页核心元素 - try: - phone_input = driver.find_element(AppiumBy.ACCESSIBILITY_ID, "login_phone_input") - assert phone_input.is_displayed(), "手机号输入框应可见" - - password_input = driver.find_element(AppiumBy.ACCESSIBILITY_ID, "login_password_input") - assert password_input.is_displayed(), "密码输入框应可见" - - login_btn = driver.find_element(AppiumBy.ACCESSIBILITY_ID, "login_submit_btn") - assert login_btn.is_displayed(), "登录按钮应可见" - except Exception: - pytest.skip("登录页元素未找到,APP 可能已登录或 UI 已变更") diff --git a/tests/config/test_config.py b/tests/config/test_config.py index 0cef662..ea1ba03 100644 --- a/tests/config/test_config.py +++ b/tests/config/test_config.py @@ -6,8 +6,12 @@ Usage: from tests.config.test_config import load_config config = load_config() - web_url = config["web"]["base_url"] + admin_url = config["admin"]["base_url"] admin_phone = config["accounts"]["super_admin"]["phone"] + +各端通过 fixture 直接引用对应 section: + def test_xxx(admin_config): + page.goto(admin_config["base_url"]) """ from pathlib import Path diff --git a/tests/config/test_config.yaml b/tests/config/test_config.yaml index 1589f65..3dc741b 100644 --- a/tests/config/test_config.yaml +++ b/tests/config/test_config.yaml @@ -1,9 +1,16 @@ # 运八网络货运平台 — 测试环境配置 # 数据来源: knowledge_base/00_project/project_profile.md -# 使用方式: from config.test_config import load_config +# 使用方式: from tests.config.test_config import load_config +# +# 覆盖端: +# Web: 管理端 admin / 司机端小程序 mini_program +# App: 司机安卓 driver_android / 货主安卓 shipper_android +# 司机苹果 driver_ios / 货主苹果 shipper_ios -# ── Web 管理端 ── -web: +# ═══════════════════════════════════════════════════════════════════ +# ── 管理端 (Web/Playwright) ── +# ═══════════════════════════════════════════════════════════════════ +admin: base_url: "https://ybxcx.ynyun8.com:8000/admin" api_base_url: "https://ybxcx.ynyun8.com:8000/" timeout_seconds: 120 @@ -12,71 +19,127 @@ web: browsers: - chromium - firefox - # WebKit 默认关闭(Windows 下问题较多),CI 环境可开启 viewport: width: 1920 height: 1080 - # 登录态复用(跳过图形验证码) - # storage_state 文件保存了浏览器登录后的 cookies + localStorage - # 首次使用需手动登录一次生成,流程: - # 1. 运行: python tests/web/save_auth.py - # 2. 脚本打开浏览器 → 人工完成验证码登录 → 登录态自动保存 - # 3. 后续执行用例时自动复用,无需重复登录 - # - # skip_login: true → 加载已有的 storage_state,跳过登录步骤 - # skip_login: false → 每次用例都通过 UI 执行完整登录流程 + # 登录态复用: python tests/web/admin/save_auth.py 生成后自动跳过验证码 skip_login: true - storage_state_dir: "tests/web/auth" # 登录态存储目录 + storage_state_dir: "tests/web/auth" -# ── App 移动端 ── -app: +# ═══════════════════════════════════════════════════════════════════ +# ── 司机端小程序 (Web/Playwright,模拟微信 WebView) ── +# ═══════════════════════════════════════════════════════════════════ +mini_program: + base_url: "https://ybxcx.ynyun8.com:8000/mini" # ⚠️ 请确认小程序 WebView 实际地址 + timeout_seconds: 120 + screenshot_on_failure: true + screenshot_on_step: false + browsers: + - chromium + # 模拟微信内置浏览器 UA + user_agent: "Mozilla/5.0 MicroMessenger/8.0.0" + viewport: + width: 375 + height: 812 + + # 小程序本质是 WebView,同样支持 storage_state 复用 + skip_login: true + storage_state_dir: "tests/web/auth" + +# ═══════════════════════════════════════════════════════════════════ +# ── 司机安卓端 (App/Appium) ── +# ═══════════════════════════════════════════════════════════════════ +driver_android: appium_host: "http://localhost:4723" timeout_seconds: 120 screenshot_on_failure: true - platforms: - - android - # - ios # iOS 仅在 macOS CI 环境下开启 + platform: android + skip_login: true # 手动登录一次后 noReset 自动复用 - # 登录态复用(跳过图形验证码) - # Appium noReset=true 已确保 app 数据不重置,登录态持久保留 - # 使用方式:手动启动 app 登录一次 → 后续用例自动跳过登录 - # - # skip_login: true → 用例直接检查首页是否已登录态,跳过登录步骤 - # skip_login: false → 每次用例都执行完整 UI 登录流程 + device_name: "Android Emulator" + app_package: "com.arpa.ynchenggangdriver" # ⚠️ 请确认实际包名 + app_activity: "com.arpa.ntocc.MainActivity" # ⚠️ 请确认实际启动 Activity + no_reset: true + new_command_timeout: 120 + +# ═══════════════════════════════════════════════════════════════════ +# ── 货主安卓端 (App/Appium) ── +# ═══════════════════════════════════════════════════════════════════ +shipper_android: + appium_host: "http://localhost:4723" + timeout_seconds: 120 + screenshot_on_failure: true + platform: android skip_login: true - android: - device_name: "Android Emulator" - app_package: "com.yunba.driver" # ⚠️ 请确认实际包名 - app_activity: ".MainActivity" # ⚠️ 请确认实际启动 Activity - no_reset: true - new_command_timeout: 120 + device_name: "Android Emulator" + app_package: "com.yunba.shipper" # ⚠️ 请确认实际包名 + app_activity: ".MainActivity" # ⚠️ 请确认实际启动 Activity + no_reset: true + new_command_timeout: 120 - ios: - device_name: "iPhone 15" - bundle_id: "com.yunba.driver" # ⚠️ 请确认实际 Bundle ID - no_reset: true - new_command_timeout: 120 +# ═══════════════════════════════════════════════════════════════════ +# ── 司机苹果端 (App/Appium,需 macOS) ── +# ═══════════════════════════════════════════════════════════════════ +driver_ios: + appium_host: "http://localhost:4723" + timeout_seconds: 120 + screenshot_on_failure: true + platform: ios + skip_login: true + device_name: "iPhone 15" + bundle_id: "com.arpa.ynchenggangdriver" # ⚠️ 请确认实际 Bundle ID + no_reset: true + new_command_timeout: 120 + +# ═══════════════════════════════════════════════════════════════════ +# ── 货主苹果端 (App/Appium,需 macOS) ── +# ═══════════════════════════════════════════════════════════════════ +shipper_ios: + appium_host: "http://localhost:4723" + timeout_seconds: 120 + screenshot_on_failure: true + platform: ios + skip_login: true + + device_name: "iPhone 15" + bundle_id: "com.yunba.shipper" # ⚠️ 请确认实际 Bundle ID + no_reset: true + new_command_timeout: 120 + +# ═══════════════════════════════════════════════════════════════════ # ── 测试账号 ── +# ═══════════════════════════════════════════════════════════════════ accounts: super_admin: phone: "super_admin" - password: "951260684NiAn.." # ⚠️ 若密码变更请同步修改 + password: "951260684NiAn.." role: "平台运营人员" + use_on: [admin] team_leader: phone: "13113113113" password: "88888888" role: "车队长" + use_on: [admin, mini_program] driver: phone: "15188888888" password: "88888888" role: "司机" + use_on: [mini_program, driver_android, driver_ios] + shipper: + phone: "13800000000" # ⚠️ 请确认实际货主账号 + password: "88888888" + role: "货主" + use_on: [shipper_android, shipper_ios] + +# ═══════════════════════════════════════════════════════════════════ # ── 报告 ── +# ═══════════════════════════════════════════════════════════════════ report: logs_dir: "output/logs" screenshots_dir: "output/screenshots" diff --git a/tests/conftest.py b/tests/conftest.py index bf917c6..757af34 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,27 +1,63 @@ -"""根级 conftest — 全局 fixture:配置加载、日志、测试数据清理。 +"""根级 conftest — 全局 fixture:配置加载、日志。 -自动应用于 tests/ 下所有测试模块。 +Usage: + def test_xxx(admin_config): # 管理端配置 + def test_xxx(driver_android_config): # 司机安卓配置 """ from pathlib import Path +import sys import pytest -# 将 tests/ 目录加入 Python path,确保 `from tests.xxx import yyy` 可用 -import sys sys.path.insert(0, str(Path(__file__).parent.parent)) @pytest.fixture(scope="session") def config(): - """全局配置 fixture(session 级别,只加载一次)。""" + """全局配置(session 级,只加载一次)。""" from tests.config.test_config import load_config return load_config() @pytest.fixture(scope="function") def logger(request): - """每个测试函数一个 logger,自动以模块名命名。""" + """每个测试函数的 logger。""" from tests.utils.logger import get_logger module_name = request.module.__name__.replace("tests.", "") return get_logger(module_name) + + +# ═══════════════════════════════════════════════════════════════════ +# 各端配置 fixture(从 config 中摘取对应 section) +# ═══════════════════════════════════════════════════════════════════ + +@pytest.fixture(scope="session") +def admin_config(config): + """管理端 (Playwright) 配置。""" + return config["admin"] + +@pytest.fixture(scope="session") +def mini_program_config(config): + """司机端小程序 (Playwright) 配置。""" + return config["mini_program"] + +@pytest.fixture(scope="session") +def driver_android_config(config): + """司机安卓端 (Appium) 配置。""" + return config["driver_android"] + +@pytest.fixture(scope="session") +def shipper_android_config(config): + """货主安卓端 (Appium) 配置。""" + return config["shipper_android"] + +@pytest.fixture(scope="session") +def driver_ios_config(config): + """司机苹果端 (Appium) 配置。""" + return config["driver_ios"] + +@pytest.fixture(scope="session") +def shipper_ios_config(config): + """货主苹果端 (Appium) 配置。""" + return config["shipper_ios"] diff --git a/tests/pytest.ini b/tests/pytest.ini index ce2abed..dbfc487 100644 --- a/tests/pytest.ini +++ b/tests/pytest.ini @@ -23,8 +23,14 @@ markers = p1: P1 主要功能 p2: P2 次要/异常流程 smoke: 冒烟测试 - web: Web UI 测试(Playwright) - app: App UI 测试(Appium) + admin: 管理端 (Playwright) + mini_program: 司机端小程序 (Playwright) + driver_android: 司机安卓端 (Appium) + shipper_android: 货主安卓端 (Appium) + driver_ios: 司机苹果端 (Appium) + shipper_ios: 货主苹果端 (Appium) + appium: 所有 Appium 测试 + playwright: 所有 Playwright 测试 # 日志格式 log_cli = true diff --git a/tests/web/admin/conftest.py b/tests/web/admin/conftest.py new file mode 100644 index 0000000..1ac10da --- /dev/null +++ b/tests/web/admin/conftest.py @@ -0,0 +1,126 @@ +"""管理端 fixture — Playwright 浏览器实例 + 登录态复用。 + +核心机制: + skip_login: true → 加载 storage_state,跳过验证码 + skip_login: false → 执行完整 UI 登录 + +初始化: + python tests/web/admin/save_auth.py +""" + +from datetime import datetime +from pathlib import Path + +import pytest +from playwright.sync_api import sync_playwright, Page + +SCREENSHOTS_DIR = Path("output/screenshots") + + +@pytest.fixture(params=["chromium", "firefox"]) +def browser_name(admin_config, request): + """每个浏览器执行一轮。""" + browsers = admin_config.get("browsers", ["chromium", "firefox"]) + request.param = browsers[len(getattr(request, "_index", 0))] + return request.param + + +@pytest.fixture +def page(admin_config, browser_name, request) -> Page: + """Playwright Page,加载登录态,失败自动截图。""" + viewport = admin_config.get("viewport", {"width": 1920, "height": 1080}) + timeout = admin_config.get("timeout_seconds", 120) * 1000 + skip_login = admin_config.get("skip_login", True) + storage_state_dir = Path(admin_config.get("storage_state_dir", "tests/web/auth")) + + with sync_playwright() as p: + browser_launcher = getattr(p, browser_name) + browser = browser_launcher.launch(headless=True) + + context_kwargs = { + "viewport": {"width": viewport["width"], "height": viewport["height"]}, + "locale": "zh-CN", + } + + loaded_role = None + if skip_login: + loaded_role = _load_auth_state(context_kwargs, storage_state_dir, browser_name) + + context = browser.new_context(**context_kwargs) + page = context.new_page() + page.set_default_timeout(timeout) + page._auth_role = loaded_role + + 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_{browser_name}_{ts}.png") + page.screenshot(path=path, full_page=True) + + context.close() + browser.close() + + +@pytest.fixture +def ensure_login(admin_config, page, config): + """确保管理端已登录。""" + + 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: + auth_role = getattr(page, "_auth_role", None) + if auth_role == role: + page.goto(base_url) + page.wait_for_timeout(2000) + if "#/login" not in page.url and "/login" not in page.url: + return + raise RuntimeError( + f"登录态已过期!请运行: python tests/web/admin/save_auth.py --role {role}" + ) + if auth_role and auth_role != role: + raise RuntimeError( + f"当前 storage_state 角色为 '{auth_role}',请求 '{role}'。\n" + f"请运行: python tests/web/admin/save_auth.py --role {role}" + ) + raise RuntimeError( + f"未找到角色 '{role}' 的登录态文件!请运行:\n" + f" python tests/web/admin/save_auth.py --role {role}" + ) + + # skip_login: false + page.goto(f"{base_url}/#/login") + page.get_by_placeholder("请输入用户名").fill(account["phone"]) + page.get_by_placeholder("请输入密码").fill(account["password"]) + page.get_by_role("button", name="登录").click() + page.wait_for_timeout(3000) + if "#/login" in page.url or "/login" in page.url: + raise RuntimeError( + "登录失败!可能存在图形验证码。请改为手动登录: python tests/web/admin/save_auth.py" + ) + + return _ensure_login + + +def _load_auth_state(context_kwargs: dict, storage_dir: Path, browser: str) -> str | None: + auth_files = list(storage_dir.glob(f"*_{browser}.json")) if storage_dir.exists() else [] + if not auth_files: + return None + auth_file = auth_files[0] + context_kwargs["storage_state"] = str(auth_file) + return auth_file.stem.replace(f"_{browser}", "") + + +@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/web/pages/base_page.py b/tests/web/admin/pages/base_page.py similarity index 100% rename from tests/web/pages/base_page.py rename to tests/web/admin/pages/base_page.py diff --git a/tests/web/pages/components/navbar.py b/tests/web/admin/pages/components/navbar.py similarity index 92% rename from tests/web/pages/components/navbar.py rename to tests/web/admin/pages/components/navbar.py index 530c4a3..415e211 100644 --- a/tests/web/pages/components/navbar.py +++ b/tests/web/admin/pages/components/navbar.py @@ -3,7 +3,7 @@ 跨页面复用的顶部/侧边导航操作。 Usage: - from tests.web.pages.components.navbar import NavBar + from tests.web.admin.pages.components.navbar import NavBar navbar = NavBar(page) navbar.go_to("运输管理") @@ -11,7 +11,7 @@ Usage: from playwright.sync_api import Page, expect -from tests.web.pages.base_page import BasePage +from tests.web.admin.pages.base_page import BasePage class NavBar(BasePage): diff --git a/tests/web/pages/login_page.py b/tests/web/admin/pages/login_page.py similarity index 94% rename from tests/web/pages/login_page.py rename to tests/web/admin/pages/login_page.py index 58e9a32..6aa4283 100644 --- a/tests/web/pages/login_page.py +++ b/tests/web/admin/pages/login_page.py @@ -1,7 +1,7 @@ """登录页面 — Page Object。 Usage: - from tests.web.pages.login_page import LoginPage + from tests.web.admin.pages.login_page import LoginPage login_page = LoginPage(page) login_page.login(phone="super_admin", password="951260684NiAn..") @@ -9,7 +9,7 @@ Usage: from playwright.sync_api import Page, expect -from tests.web.pages.base_page import BasePage +from tests.web.admin.pages.base_page import BasePage class LoginPage(BasePage): diff --git a/tests/web/save_auth.py b/tests/web/admin/save_auth.py similarity index 95% rename from tests/web/save_auth.py rename to tests/web/admin/save_auth.py index 3349a4e..9e2b735 100644 --- a/tests/web/save_auth.py +++ b/tests/web/admin/save_auth.py @@ -5,7 +5,7 @@ cookies + localStorage 序列化到 JSON 文件,后续用例自动复用。 用法: - python tests/web/save_auth.py + python tests/web/admin/save_auth.py 流程: 1. 脚本打开浏览器到登录页 @@ -44,7 +44,7 @@ def main(): args = parser.parse_args() config = load_config() - web_config = config["web"] + web_config = config["admin"] base_url = web_config["base_url"] login_url = f"{base_url}/#/login" @@ -92,7 +92,7 @@ def main(): browser.close() - print(f"\n🎉 现在可以跑了: pytest tests/web/ -m p0") + print(f"\n🎉 现在可以跑了: pytest tests/web/admin/ -m p0") if __name__ == "__main__": diff --git a/tests/web/admin/test_login/test_login.py b/tests/web/admin/test_login/test_login.py new file mode 100644 index 0000000..6034538 --- /dev/null +++ b/tests/web/admin/test_login/test_login.py @@ -0,0 +1,74 @@ +"""管理端登录测试用例。 + +标记: + pytest -m admin # 仅管理端用例 + pytest -m p0 # P0 冒烟 + pytest -m smoke # 冒烟 +""" + +import pytest +from playwright.sync_api import Page + +pytestmark = [pytest.mark.admin] + + +class TestAdminSmoke: + """管理端冒烟 — 验证预登录态。""" + + @pytest.mark.p0 + @pytest.mark.smoke + def test_admin_access_with_saved_auth(self, page: Page, admin_config, ensure_login): + """验证预登录态可直接访问管理后台。""" + ensure_login("super_admin") + page.goto(admin_config["base_url"]) + page.wait_for_timeout(2000) + assert "#/login" not in page.url and "/login" not in page.url, ( + "预登录态失效!请运行: python tests/web/admin/save_auth.py" + ) + + @pytest.mark.p0 + @pytest.mark.smoke + def test_team_leader_access(self, page: Page, admin_config, ensure_login): + """验证车队长预登录态有效。""" + ensure_login("team_leader") + page.goto(admin_config["base_url"]) + page.wait_for_timeout(2000) + assert "#/login" not in page.url and "/login" not in page.url + + @pytest.mark.p0 + @pytest.mark.smoke + def test_dashboard_loads(self, page: Page, admin_config, ensure_login): + """验证首页可正常加载。""" + ensure_login("super_admin") + page.goto(admin_config["base_url"]) + assert page.title(), "首页应显示页面标题" + + +class TestLoginPage: + """登录页交互验证。""" + + @pytest.mark.p1 + def test_login_page_accessible(self, page: Page, admin_config): + """验证登录页可正常访问。""" + page.goto(f"{admin_config['base_url']}/#/login") + assert page.get_by_placeholder("请输入用户名").is_visible() + + @pytest.mark.p1 + def test_login_page_elements(self, page: Page, admin_config): + """验证登录页元素完整。""" + page.goto(f"{admin_config['base_url']}/#/login") + assert page.get_by_placeholder("请输入用户名").is_visible() + assert page.get_by_placeholder("请输入密码").is_visible() + assert page.get_by_role("button", name="登录").is_visible() + + @pytest.mark.p1 + def test_session_redirect(self, page: Page, admin_config, ensure_login): + """验证已登录用户访问登录页被重定向。""" + ensure_login("super_admin") + page.goto(admin_config["base_url"]) + page.wait_for_timeout(2000) + assert "#/login" not in page.url + + page.goto(f"{admin_config['base_url']}/#/login") + page.wait_for_timeout(2000) + assert "#/login" not in page.url and "/login" not in page.url diff --git a/tests/web/auth/super_admin_chromium.json b/tests/web/auth/super_admin_chromium.json new file mode 100644 index 0000000..26d6fd7 --- /dev/null +++ b/tests/web/auth/super_admin_chromium.json @@ -0,0 +1 @@ +{"cookies": [{"name": "GouldAMap", "value": "d58bb5f7da1aa1d6b78741ded2450f2d", "domain": "ybxcx.ynyun8.com", "path": "/", "expires": -1, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "Admin-Token", "value": "d5cb7b227e9fb37e4e0f0caca00bxc39c18", "domain": "ybxcx.ynyun8.com", "path": "/", "expires": -1, "httpOnly": false, "secure": false, "sameSite": "Lax"}], "origins": [{"origin": "https://ybxcx.ynyun8.com:8000", "localStorage": [{"name": "_AMap_AMap.Geolocation", "value": "{\"version\":\"1768801478717\",\"script\":\"g.Vn=g.da.extend({ka:[g.va],Ni:\\\".amap-geolocation-con .amap-geo{background:#fff url(../../theme/v1.3/markers/b/loc_gray.png) 50% 50% no-repeat;width:35px;height:35px;border:1px solid #ccc;border-radius:3px;right:4px}.amap-locate-loading .amap-geo{background-image:url(../../theme/v1.3/loading.gif)}.amap-locate{position:absolute;width:18px;height:18px;background:url(../../theme/v1.3/map_view.png);_background:url(../../theme/v1.3/map_view.gif);background-position:-130px -185px;cursor:pointer}\\\",A:function(a){this.CLASS_NAME= \\\"AMap.Geolocation\\\";g.c.ya(this,a);this.options={enableHighAccuracy:!1,timeout:5E3,maximumAge:0,GeoLocationFirst:!1,convert:!0,convertUrl:g.o.fd+\\\"/v3/assistant/coordinate/convert?coordsys=gps&output=json&s=rsv3&locations={x},{y}&key=\\\"+g.o.key,showButton:!0,buttonDom:'
',buttonPosition:\\\"LB\\\",buttonOffset:new g.H(10,20),showMarker:!0,markerOptions:{innerOverlay:!0,offset:new g.H(-11,-11),content:\\\"\\\"},showCircle:!0,circleOptions:{innerOverlay:!0,strokeColor:\\\"#0093FF\\\",noSelect:!0,strokeOpacity:0.5,strokeWeight:1,fillColor:\\\"#02B0FF\\\",fillOpacity:0.25},panToLocation:!0,zoomToAccuracy:!1,noIpLocate:0,noGeoLocation:0,useNative:!1,extensions:\\\"base\\\",stopWhenPermissionDenied:!0};if(a)for(var b in a)b in this.options&&(this.options[b]=a[b]);switch(this.options.noGeoLocation){case 0:this.kA=!0;break;case 1:this.kA=!g.l.ba;break;case 2:this.kA=g.l.ba;break;case 3:this.kA=!1;break; default:this.kA=!0}switch(this.options.noIpLocate){case 0:this.Ew=!0;break;case 1:this.Ew=!g.l.ba;break;case 2:this.Ew=g.l.ba;break;case 3:this.Ew=!1;break;case !0:this.Ew=!1;break;default:this.Ew=!0}this.Lb=this.Tb=this.MM=this.rx=this.hi=this.e=null;this.bY=g.event.addListener(this,\\\"complete\\\",this.Oua,this);this.uY=g.event.addListener(this,\\\"error\\\",this.Pua,this)},isSupported:function(){return!!navigator.geolocation},Ul:function(a){new g.kb.Ab(g.o.Cc+\\\"://webapi.amap.com/count?\\\"+[\\\"type=loc\\\",\\\"k=\\\"+ g.o.key,\\\"u=\\\"+g.o.Ao,\\\"m=\\\"+(g.l.ba?1:0),\\\"pf=\\\"+g.l.Fz,\\\"suc=\\\"+a].join(\\\"&\\\"))},Hqa:function(a){function b(b){b.status=1;d.Lv(b,function(b,c){c.info=\\\"SUCCESS\\\";a&&a(\\\"complete\\\",c);g.event.O(d,\\\"complete\\\",c)})}function c(b){function c(f){f.message=b.message+f.message;f.status=1;d.Lv(f,function(b,c){c.info=\\\"SUCCESS\\\";a&&a(\\\"complete\\\",c);g.event.O(d,\\\"complete\\\",c)})}function h(c){b.message+=c.message;b.info=\\\"FAILED\\\";b.status=0;a&&a(\\\"error\\\",b);g.event.O(d,\\\"error\\\",b)}d.kA?d.i4(c,h):h({message:\\\"GeoLocation is closed by noGeoLocation option,pass Geolocation.\\\"})} var d=this;d.Ew?d.n4(b,c):c({message:\\\"IpLocation is closed by noIpLocate option,pass IpLocation.\\\"})},i4:function(a,b){if(this.isSupported()){var c=this;g.a.HR(function(d,e){if(d)c.Ul(\\\"err\\\"),b({message:d?d.message:\\\"\\\"});else{var f=setTimeout(function(){f=null;b({message:\\\"Get geolocation time out.\\\"});c.Ul(\\\"waitetimeout\\\");h=!0},c.options.timeout),h=!1;e.getCurrentPosition(function(d){if(!h)if(clearTimeout(f),d.coords&&d.coords.longitude){c.Ul(\\\"success\\\");d={position:new g.U(d.coords.longitude,d.coords.latitude), location_type:\\\"html5\\\",message:\\\"Get geolocation success.\\\",accuracy:d.coords.accuracy};var e=!1;g.l.Q3&&(e=!0);c.options.convert?e?(d.isConverted=!0,d.message+=\\\"Don't need convert.\\\",a(d)):c.sI(d,function(b){a(b)}):(e?(d.message+=\\\"Don't need convert.\\\",d.isConverted=!0):(d.isConverted=!1,d.message+=\\\"convert option is false,pass convert.\\\"),a(d))}else c.Ul(\\\"failed\\\"),b({code:2,info:\\\"POSITION_UNAVAILABLE\\\",message:\\\"Get geolocation failed.\\\"})},function(a){h||(clearTimeout(f),1===a.code?(a={code:1,info:\\\"PERMISSION_DENIED\\\", message:\\\"Geolocation permission denied.\\\"},c.Ul(\\\"denied\\\"),b(a)):3===a.code?(a={code:3,info:\\\"TIME_OUT\\\",message:\\\"Get geolocation time out.\\\"},c.Ul(\\\"timeout\\\"),b(a)):2===a.code&&(a={code:3,info:\\\"POSITION_UNAVAILABLE\\\",message:\\\"Get geolocation failed.\\\"},c.Ul(\\\"failed\\\"),b(a)))},this.options)}},{timeout:c.options.timeout})}else b({message:\\\"Browser not Support html5 geolocation.\\\"})},sI:function(a,b){var c=a.position,c=this.options.convertUrl.replace(\\\"{x}\\\",c.R).replace(\\\"{y}\\\",c.Q);g.o.Xa&&(c+=\\\"&jscode=\\\"+g.o.Xa); c=new g.kb.Ab(c,{callback:\\\"callback\\\"});g.event.addListener(c,\\\"complete\\\",function(c){1===parseInt(c.status)&&c.locations?(c=c.locations.split(\\\",\\\"),c=new g.U(parseFloat(c[0]),parseFloat(c[1])),a.position=c,a.isConverted=!0,a.message+=\\\"Convert Success.\\\"):(a.isConverted=!1,a.message+=\\\"Convert failed.\\\");b(a)},this);g.event.addListener(c,\\\"error\\\",function(){a.message+=\\\"Convert request failed.\\\";a.isConverted=!1;b(a)},this)},n4:function(a,b){var c=new g.kb.Ab(g.o.Cc+\\\"://webapi.amap.com/maps/ipLocation?key=\\\"+ g.o.key,{callback:\\\"callback\\\"});c.h(\\\"complete\\\",function(c){c.status&&c.lng&&c.lat?(c.position=g.a.Moa(c.lng,c.lat),delete c.lng,delete c.lat,c.message=\\\"Get ipLocation success.\\\",c.location_type=\\\"ip\\\",c.accuracy=null,c.isConverted=!0,a(c)):b({message:\\\"Get ipLocation failed.\\\"})},this);c.h(\\\"error\\\",function(){b({message:\\\"Request ipLocation failed.\\\"})},this)},Gqa:function(a){function b(b){function e(e){e.message=b.message+e.message;e.status=1;c.Lv(e,function(b,d){d.info=\\\"SUCCESS\\\";a&&a(\\\"complete\\\",d);g.event.O(c, \\\"complete\\\",d)})}function f(e){b.message+=e.message;if(1===e.code)b.info=\\\"FAILED\\\",b.status=0,a&&a(\\\"error\\\",b),g.event.O(c,\\\"error\\\",b);else{e=function(e){b.message+=e.message;b.info=\\\"FAILED\\\";b.status=0;a&&a(\\\"error\\\",b);g.event.O(c,\\\"error\\\",b)};var f=function(e){e.status=1;e.message=b.message+e.message;c.Lv(e,function(b,d){d.info=\\\"SUCCESS\\\";a&&a(\\\"complete\\\",d);g.event.O(c,\\\"complete\\\",d)})};c.Ew?c.n4(f,e):e({message:\\\"IpLocation is closed by noIpLocate option,pass ipLocation.\\\"})}}c.kA?c.i4(e,f):f({message:\\\"Geolocation is closed by noGeoLocation option,pass Geolocation.\\\"})} var c=this;this.options.useNative&&g.l.Zl?this.Uta(function(b){b.status=1;c.Lv(b,function(b,d){d.info=\\\"SUCCESS\\\";a&&a(\\\"complete\\\",d);g.event.O(c,\\\"complete\\\",d)})},function(a){b(a)}):b({message:\\\"\\\"})},getCurrentPosition:function(a){g.c.add(this,\\\"getCurrentPosition\\\");this.jO=!0;g.f.Wa(this.hi,\\\"amap-locate-loading\\\");g.l.ba||this.options.GeoLocationFirst?this.Gqa(a):this.Hqa(a)},getCityInfo:function(a){g.c.add(this,\\\"getCityInfo\\\");var b=new g.kb.Ab(g.o.Cc+\\\"://webapi.amap.com/maps/ipCity?key=\\\"+g.o.key,{callback:\\\"callback\\\"}); b.h(\\\"complete\\\",function(b){b.center&&1===b.status?(b.isConverted=!0,b.info=\\\"SUCCESS\\\",b.message=\\\"Get city by ip success.\\\",a&&a(\\\"complete\\\",b)):(b.info=\\\"FAILED\\\",b.message=\\\"Get city by ip failed.\\\",a&&a(\\\"error\\\",b))},this);b.h(\\\"error\\\",function(){a&&a(\\\"error\\\",{status:0,message:\\\"Request ipCity failed.\\\",info:\\\"FAILED\\\"})},this)},watchPosition:function(){if(this.options.useNative&&window.AMapAndroidLoc)this.dAa();else if(g.c.add(this,\\\"watchPosition\\\"),this.isSupported()){if(g.l.ba){var a=this;return g.a.HR(function(b, c,d){if(b)g.event.O(a,\\\"error\\\",{status:0,info:\\\"FAILED\\\",message:\\\"Get geolocation error on watch.\\\"});else return b=c.watchPosition(function(b){if(b.coords&&b.coords.longitude){b={position:new g.U(b.coords.longitude,b.coords.latitude),location_type:\\\"html5\\\",message:\\\"Watch position success by GeoLocation.\\\",status:1,accuracy:b.coords.accuracy};var c=!1;g.l.Q3&&(c=!0);var d=function(b){a.Lv(b,function(b,c){c.info=\\\"SUCCESS\\\";g.event.O(a,\\\"complete\\\",c)})};a.options.convert?c?(b.message+=\\\"Don't need convert.\\\", b.isConverted=!0,d(b)):a.sI(b,function(a){d(a)}):(c?(b.message+=\\\"Don't need convert.\\\",b.isConverted=!0):(b.message+=\\\"convert option is false,pass convert.\\\",b.isConverted=!1),d(b))}else g.event.O(a,\\\"error\\\",{status:0,info:\\\"FAILED\\\",message:\\\"Watch position failed by GeoLocation,incorrect position.\\\"})},function(){g.event.O(a,\\\"error\\\",{status:0,info:\\\"FAILED\\\",message:\\\"Watch position failed by GeoLocation.\\\"})},a.options),d&&g.a.Mxa(d,b),b},{aAa:!0})}g.event.O(this,\\\"error\\\",{status:0,info:\\\"NOT_SUPPORT\\\",message:\\\"Forbidden watchPosition on PC device.\\\"})}else return g.event.O(this, \\\"error\\\",{status:0,info:\\\"NOT_SUPPORT\\\",message:\\\"Browser not Support html5 GeoLocation.\\\"}),null},clearWatch:function(a){g.c.add(this,\\\"clearWatch\\\");if(this.options.useNative&&window.AMapAndroidLoc)g.Vn.Mw.Bi.mya(this);else if(this.isSupported())return g.a.HR(function(b,c){b||g.a.$za(a,function(b,e){b||(g.a.Jna(a),c.clearWatch(e))})})},zu:function(a,b){if(!this.hi){var c=document.createElement(\\\"div\\\");this.jO&&g.f.Wa(c,\\\"amap-locate-loading\\\");g.f.Wa(c,\\\"amap-geolocation-con\\\");\\\"object\\\"===typeof this.options.buttonDom? c.appendChild(this.options.buttonDom):c.innerHTML=this.options.buttonDom;c.style.position=\\\"absolute\\\";c.style.zIndex=\\\"9999\\\";this.options.showButton||(c.style.visibility=\\\"hidden\\\");this.cC(c,this.options.buttonPosition,this.options.buttonOffset);this.hi=c}this.e=a;b.appendChild(this.hi);this.rx=b;this.MM=g.event.Y(this.hi,\\\"click\\\",this.Nua,this)},Yv:function(){g.event.removeListener(this.bY);this.bY=null;g.event.removeListener(this.uY);this.uY=null;g.event.removeListener(this.MM);this.MM=null;this.rx.removeChild(this.hi); this.e=this.rx=null;this.Tb&&(this.Tb.setMap(null),this.Tb=null);this.Lb&&(this.Lb.setMap(null),this.Lb=null)},cC:function(a,b,c){\\\"LT\\\"!==b&&\\\"RT\\\"!==b&&\\\"LB\\\"!==b&&\\\"RB\\\"!==b&&(b=\\\"LT\\\");switch(b){case \\\"LT\\\":g.f.ab(a,{left:c.x+\\\"px\\\",top:c.y+\\\"px\\\",right:\\\"\\\",bottom:\\\"\\\"});break;case \\\"RT\\\":g.f.ab(a,{right:c.x+\\\"px\\\",top:c.y+\\\"px\\\",left:\\\"\\\",bottom:\\\"\\\"});break;case \\\"LB\\\":g.f.ab(a,{left:c.x+\\\"px\\\",bottom:c.y+\\\"px\\\",right:\\\"\\\",top:\\\"\\\"});break;case \\\"RB\\\":g.f.ab(a,{right:c.x+\\\"px\\\",bottom:c.y+\\\"px\\\",left:\\\"\\\",top:\\\"\\\"})}this.rx&&(this.rx.removeChild(a), this.rx.appendChild(a))},$xa:function(a,b){if(this.e){var c=this.options;c.showMarker&&(this.Tb||(this.Tb=new z.B.wb(c.markerOptions),this.Tb.Da=!0),this.Tb.setPosition(a),this.Tb.setMap(this.e));c.showCircle&&(this.Lb||(this.Lb=new z.B.hh(c.circleOptions)),this.Lb.setCenter(a),this.Lb.setRadius(b||0),this.Lb.setMap(this.e),this.Lb.Da=!0);c.panToLocation&&this.e.panTo(a);c.zoomToAccuracy&&this.Lb&&this.e.setFitView([this.Lb])}},cFa:function(){this.Tb&&this.Tb.setMap(null);this.Lb&&this.Lb.setMap(null)}, Lv:function(a,b){var c=this,d=a.position;g.ub.load(\\\"AMap.Geocoder\\\",function(){c.R3||(c.R3=new g.lW({extensions:c.options.extensions}));c.R3.getAddress(d,function(c,d){\\\"complete\\\"===c?(g.extend(a,d.regeocode),a.message+=\\\"Get address success.\\\"):a.message+=\\\"Get address fail,check your key or network.\\\";b(\\\"complete\\\",a)})})},Nua:function(){this.getCurrentPosition()},Oua:function(a){this.jO=!1;g.f.fb(this.hi,\\\"amap-locate-loading\\\");this.$xa(a.position,a.accuracy)},Pua:function(){this.jO=!1;g.f.fb(this.hi, \\\"amap-locate-loading\\\")},Uta:function(a,b){function c(a){1!==a&&d.Ul(\\\"sdkerror\\\");b&&b.call&&b.call(d,{message:\\\"Get sdkLocation failed.\\\"})}if(window.AMapAndroidLoc)this.Vta(a,b);else{var d=this,e=new g.kb.Ab(\\\"http://127.0.0.1:43689/geolocation?to=3000&_=\\\"+(new Date).getTime(),{callback:\\\"callback\\\"});e.h(\\\"complete\\\",function(b){a&&a.call&&b.location&&b.location.x&&b.location.y?(a.call(d,{position:new g.U(b.location.x,b.location.y),accuracy:b.location.precision,message:\\\"Get sdkLocation success.\\\",location_type:\\\"sdk\\\", isConverted:!0}),d.Ul(\\\"sdksuccess\\\")):(c(1),d.Ul(\\\"sdkfail\\\"))});e.h(\\\"error\\\",c)}},Vta:function(a,b){var c=this;g.Vn.Mw.Bi.gra(this,function(d){a&&a.call&&d.result&&d.result.x&&d.result.y?(a.call(c,{position:new g.U(d.result.x,d.result.y),accuracy:d.result.precision,message:\\\"Get sdkLocation success.\\\",location_type:\\\"sdk\\\",isConverted:!0}),c.Ul(\\\"sdksuccess\\\")):(b&&b.call&&b.call(c,{sdkErrorInfo:d.errorInfo,sdkLocationDetail:d.locationDetail,message:\\\"Get sdkLocation failed.\\\"}),c.Ul(\\\"sdkfail\\\"))})},dAa:function(){var a= this;g.Vn.Mw.Bi.cAa(this,function(b){b.result&&b.result.x&&b.result.y?(b={position:new g.U(b.result.x,b.result.y),location_type:\\\"sdk\\\",message:\\\"Watch position success by Android SDK.\\\",status:1,accuracy:b.result.precision},b.message+=\\\"Don't need convert.\\\",b.isConverted=!0,function(b){a.Lv(b,function(b,c){c.info=\\\"SUCCESS\\\";g.event.O(a,\\\"complete\\\",c)})}(b)):g.event.O(a,\\\"error\\\",{status:0,info:\\\"FAILED\\\",message:\\\"Watch position failed by Android SDK,incorrect position.\\\"})})}});window.AMap.Geolocation=g.Vn; g.Vn.cbk=function(a){a=JSON.parse(a);var b=g.Vn.Mw.Bi;b.r(\\\"get\\\",a);b.r(\\\"watch\\\",a)}; g.Vn.Mw=g.da.extend({ka:[g.va],A:function(){},gra:function(a,b){var c=window.AMapAndroidLoc;this.h(\\\"get\\\",b,a,!0);this.we(\\\"watch\\\")||c.getLocation(JSON.stringify({to:a.options.timeout,useGPS:1,watch:0,callback:\\\"AMap.Geolocation.cbk\\\"}))},cAa:function(a,b){this.we(\\\"watch\\\")||window.AMapAndroidLoc.getLocation(JSON.stringify({to:a.options.timeout,useGPS:1,watch:1,interval:a.options.watchInterval||5,callback:\\\"AMap.Geolocation.cbk\\\"}));this.h(\\\"watch\\\",b,a)},mya:function(a){var b=window.AMapAndroidLoc;this.fK(\\\"watch\\\", a);this.we(\\\"watch\\\")||b.stopLocation()}});g.Vn.Mw.Bi=new g.Vn.Mw; \"}"}, {"name": "_AMap_mouse", "value": "{\"version\":\"1768801478717\",\"script\":\"g.Sb.Ib({rN:\\\"_docMsMov\\\",Kca:function(){var a=this.Ya.ad;g.F.h(a,\\\"mousedown\\\",this.S4,this);g.F.h(document,\\\"mousedown\\\",this.Y4,this);this.Jpa||g.l.Ve||(this.hw=g.a.ND(this.hw,150,this),this.Jpa=!0);this.v7();g.F.h(document,\\\"mouseout\\\",this.hw,this);this.h(this.rN,this.N6,this);g.F.h(a,\\\"mouseover\\\",this.T4,this);g.F.h(a,\\\"mouseup\\\",this.U4,this);g.F.h(a,\\\"contextmenu\\\",this.KF,this);g.F.h(a,\\\"rightclick\\\",this.KF,this)},Eha:function(){var a=this.Ya.ad;g.F.G(a,\\\"mousedown\\\",this.S4,this);g.F.G(document,\\\"mousedown\\\", this.Y4,this);this.Ona();g.F.G(document,\\\"mouseout\\\",this.hw,this);this.G(this.rN,this.N6,this);g.F.G(a,\\\"mouseover\\\",this.T4,this);g.F.G(a,\\\"mouseup\\\",this.U4,this);g.F.G(a,\\\"contextmenu\\\",this.KF,this);g.F.G(a,\\\"rightclick\\\",this.KF,this)},Mca:function(){g.F.h(this.Ya.ad,\\\"mousewheel\\\",this.R4,this)},r_:function(){g.F.G(this.Ya.ad,\\\"mousewheel\\\",this.R4,this)},Pna:function(){g.F.G(document,\\\"mousemove\\\",this.hw,this);g.F.G(this.Ya.ad,\\\"mousemove\\\",this.$R,this)},v7:function(){g.F.h(document,\\\"mousemove\\\",this.hw,this); g.F.h(this.Ya.ad,\\\"mousemove\\\",this.$R,this)},Ona:function(){g.F.G(document,\\\"mousemove\\\",this.hw,this);g.F.G(this.Ya.ad,\\\"mousemove\\\",this.$R,this)},T4:function(){this.IG=!0},Y4:function(){this.IG=!1},hw:function(a){if(!this.Kf&&!this.sg&&this.Ya){var b=this.Mi(a,!0,!0);this.r(this.rN,{event:a,TJ:b})}},Lca:function(){this.h(\\\"mousemove\\\",this.P_);this.h(\\\"mouseout\\\",this.Q_);this.h(\\\"mouseover\\\",this.R_);this.h(\\\"mouseup\\\",this.S_);this.h(\\\"mousedown\\\",this.O_);this.h(\\\"rightclick\\\",this.U_);this.h(\\\"contextmenu\\\", this.G_);this.h(\\\"rdblclick\\\",this.V_)},Gha:function(){this.G(\\\"mousemove\\\",this.P_);this.G(\\\"mouseout\\\",this.Q_);this.G(\\\"mouseover\\\",this.R_);this.G(\\\"mouseup\\\",this.S_);this.G(\\\"mousedown\\\",this.O_);this.G(\\\"rightclick\\\",this.U_);this.G(\\\"contextmenu\\\",this.G_);this.G(\\\"rdblclick\\\",this.V_)},keyboardEnableChanged:function(){this.get(\\\"keyboardEnable\\\")?this.Ica():this.CO()},scrollWheelChanged:function(){this.get(\\\"scrollWheel\\\")?this.h(\\\"mousewheel\\\",this.T_):this.G(\\\"mousewheel\\\",this.T_)},KF:function(a){g.F.preventDefault(a)}, vha:function(a){var b=this.Mi(a,!1,!0),c=0;\\\"3D\\\"==this.D.view.type?(this.qa(\\\"mousewheel\\\",b),a.wheelDelta?(c=a.wheelDelta,window.opera&&g.a.Qh(window.opera.version)&&9.5>parseFloat(window.opera.version())&&(c=-c),0==c/120%1&&(c/=2)):a.detail&&(c=-a.detail,120!==c&&-120!==c&&(c*=20)),a=c/800,a=this.get(\\\"zoom\\\")+a,this.x2(a,b.Ta,!0)):(a.wheelDelta?(c=a.wheelDelta/120,window.opera&&g.a.Qh(window.opera.version)&&9.5>parseFloat(window.opera.version())&&(c=-c)):a.detail&&(c=-a.detail),this.V.VC=(this.V.VC|| 0)+c,this.r(\\\"mousewheel\\\",b))},R4:function(a){\\\"info\\\"!=a.af&&this.get(\\\"scrollWheel\\\")&&this.vha(a)},cS:function(a,b){b=this.Mi(a,!1,!0);if(!this.Sv(b.Db,this.V.Hx)){var c=this.V;c.Hx=b.Db;if(c.Cr){if(!c.Cd&&(b.Db.x!==c.jo.x||b.Db.y!==c.jo.y)){var d;this.$N(c.au)&&c.au.get(\\\"draggable\\\")&&(d=c.au);!d&&this.get(\\\"dragEnable\\\")&&(d=this);d&&(c.Cd=!0,c.kq=d,c.gg=c.jo,c.jq=c.NT)}c.Cd&&(c.bk=b,b.button=a.buttons||a.button||0,this.UO())}}},qJ:function(){var a=this.TJ;g.f.fb(this.K,\\\"amap-drag\\\");g.f.Gpa();g.f.t3(); this.V&&(this.V.Cd=!1);this.V.Gp&&(this.V.Gp=!1,this.V.kq.r(\\\"dragend\\\",a));this.V.Cr=!1;this.V.Hx=null;this.V.gg=null;g.F.G(document,\\\"mousemove\\\",this.cS,this);g.F.G(document,\\\"mouseup\\\",this.qJ,this);try{g.F.G(window.parent.document,\\\"mouseup\\\",this.qJ,this)}catch(b){}this.Ya&&(this.Kf&&(this.sg=this.Kf=!1),g.F.Cza(this.Ya.ad),this.v7())},Rra:function(a,b){b=b||this.Mi(a,!1,!1,a.srcElement||a.target);var c=this.get(\\\"size\\\"),d;0<=b.Db.x&&b.Db.x<=c.width&&0<=b.Db.y&&b.Db.y<=c.height&&(d=!0);this.L5&&!d?this.r(\\\"mouseout\\\", b):!this.L5&&d&&this.r(\\\"mouseover\\\",b);this.L5=d},$R:function(a){if(!this.Kf&&!this.sg&&this.Ya&&\\\"info\\\"!=a.af&&(g.F.preventDefault(a),g.l.Gs&&(a=window.event),a)){a=this.Mi(a,!1,!1,a.srcElement||a.target);var b=this.V,c=a.kd,d;if(c){this.V.Cr||(c instanceof g.B.Fh?d=c.Mb.get(\\\"cursor\\\"):c instanceof g.q.Zb&&(d=c.get(\\\"cursor\\\")),d&&c!==this||(d=this.D.De.defaultCursor),d=this.D.get(\\\"optimalCursor\\\")||d,this.K.style.cursor=d||\\\"\\\");d=b.Wga;var e=a.uu;d!==e&&(d&&b.ZZ&&b.ZZ.r(\\\"mouseout\\\",a),e&&c&&(c.r(\\\"mouseover\\\", a),c.get(\\\"topWhenMouseOver\\\")&&(c.Mb.C=!0,c.Mb.setTop(!0),c.Mb.C=!1)));a.kd.r(\\\"mousemove\\\",a);a.kd!==this&&this.r(\\\"mousemove\\\",a);b.Wga=a.uu||null;b.ZZ=a.kd||null}}},S4:function(a){if(\\\"info\\\"!=a.af&&(this.IG=!0,g.f.Xoa(),g.f.g3(),this.V.Hx=null,this.V.gg=null,\\\"info\\\"!=a.af)){var b=this.Mi(a,!0,!1,a.srcElement||a.target);if(b.kd){this.V.k3=new Date;this.V.au=b.kd;this.V.jo=b.Db;this.V.Sga=b.button;this.V.Cr=!0;this.V.Tga=b;this.V.Cd||(this.V.NT=b);this.Ie&&this.Ie.stop();var c=b.kd;!c.Mb&&c.get(\\\"dragEnable\\\")&& (this.V.Cr=!0);g.f.Wa(this.K,\\\"amap-drag\\\");c=!1;b.kd.r(\\\"mousedown\\\",b);b.kd!==this&&(this.r(\\\"mousedown\\\",b),c=b.kd.get(\\\"draggable\\\"));c=c||this.get(\\\"dragEnable\\\");g.F.Bxa(this.Ya.ad);g.F.h(document,\\\"mousemove\\\",this.cS,this);g.F.h(document,\\\"mouseup\\\",this.qJ,this);try{window.parent.document&&g.l.Gs&&g.F.h(window.parent.document,\\\"mouseup\\\",this.qJ,this)}catch(d){}g.F.stopPropagation(a);c&&this.Pna()}}},N6:function(a){this.Rra(a.event,a.TJ)},JDa:function(a){this.cS(a.event,a.TJ)},U4:function(a){if(\\\"info\\\"!= a.af){this.TJ=a=this.Mi(a,!1,!1,a.srcElement||a.target);var b=a.kd;if(b){var c=this.D.get(\\\"allBubble\\\")||b.get(\\\"bubble\\\");b!==document&&(b.r(\\\"mouseup\\\",a),a.kd!==this&&c&&this.r(\\\"mouseup\\\",a));this.V.jo&&this.Sv(a.Db,this.V.jo)&&(this.bg&&this.Sv(this.V.Ex,a.Db)&&a.button===this.V.Sga?(clearTimeout(this.bg),this.bg=null,g.F.R5(a)?c?(b.r(\\\"dblclick\\\",a),b!==this&&this.r(\\\"dblclick\\\",a)):b.r(\\\"dblclick\\\",a):b.r(\\\"rdblclick\\\",a),this.V.Ex=null):(g.F.R5(a)?(this.r(\\\"clickstart\\\",a),c?(b.r(\\\"click\\\",a),b!==this&&this.r(\\\"click\\\", a)):(b.r(\\\"click\\\",a),this.r(\\\"closeOverlays\\\",a)),b.get(\\\"topWhenClick\\\")&&(b.Mb.C=!0,b.Mb.setTop(!0),b.Mb.C=!1),this.r(\\\"clickend\\\",a)):c?(b.r(\\\"rightclick\\\",a),b!==this&&this.r(\\\"rightclick\\\",a),this.r(\\\"contextmenu\\\",a)):(b.r(\\\"rightclick\\\",a),b.r(\\\"contextmenu\\\",a)),this.V.Ex=a.Db,this.bg&&clearTimeout(this.bg),this.bg=setTimeout(g.a.bind(function(){clearTimeout(this.bg);this.bg=null;this.V.Ex=null},this),260)))}}}});g.Sb.Ib({T_:function(a){this.qa(\\\"mousewheel\\\",a);var b=this;if(1<=Math.abs(this.V.VC)&&!this.T$){var c=this.V.VC;if(1<=c)c=1;else if(-1>=c)c=-1;else return;b.eH(a.Ta,c);this.V.VC=0;this.T$=!0;setTimeout(function(){b.T$=!1;b.V.VC=0},g.l.pz?50:40)}},P_:function(a){this.qa(\\\"mousemove\\\",a)},Q_:function(a){this.qa(\\\"mouseout\\\",a)},R_:function(a){this.qa(\\\"mouseover\\\",a)},S_:function(a){this.qa(\\\"mouseup\\\",a)},O_:function(a){this.qa(\\\"mousedown\\\",a)},U_:function(a){this.qa(\\\"rightclick\\\",a)},G_:function(a){this.qa(\\\"contextmenu\\\", a)},V_:function(a){this.get(\\\"doubleClickZoom\\\")&&this.get(\\\"zoomEnable\\\")&&this.eH(a.Ta,-1);this.qa(\\\"rdblclick\\\",a)}});g.Sb.Ib({yga:function(){this.BB={left:[37],right:[39],IQ:[40],dF:[38],Qi:[187,107,61],Ri:[189,109,173]};this.uda={left:this.by(this.cH(100,0)),right:this.by(this.cH(-100,0)),IQ:this.by(this.cH(0,-100)),dF:this.by(this.cH(0,100)),Qi:this.by(this.L1(1)),Ri:this.by(this.L1(-1))}},Ica:function(){this.get(\\\"keyboardEnable\\\")&&(this.BB||this.yga(),g.F.h(document,\\\"keydown\\\",this.aH,this),g.F.h(document,\\\"keyup\\\",this.C_,this))},CO:function(){g.F.G(document,\\\"keydown\\\",this.aH,this);g.F.G(document,\\\"keyup\\\",this.C_, this)},C_:function(a){var b=a.keyCode;!a.ctrlKey||37!==b&&39!==b||this.set(\\\"refresh\\\",1)},aH:function(a){var b=a.keyCode,c;for(c in this.BB)if(this.BB.hasOwnProperty(c))for(var d=0;d