From 6a1be3fdf19e848aeaac0241c133e1bf68cc4a33 Mon Sep 17 00:00:00 2001 From: 0xWelt Date: Sun, 10 May 2026 17:34:32 +0800 Subject: [PATCH] build: configure ty and fix type annotations --- .pre-commit-config.yaml | 10 +++ AGENTS.md | 79 ++++++++++------------ autowsgr/combat/engine.py | 4 +- autowsgr/combat/handlers.py | 4 +- autowsgr/combat/history.py | 2 +- autowsgr/combat/rules.py | 12 ++-- autowsgr/context/game_context.py | 2 +- autowsgr/emulator/os_control/windows.py | 1 + autowsgr/image_resources/keys.py | 2 + autowsgr/infra/config.py | 2 +- autowsgr/infra/logger.py | 8 +-- autowsgr/ops/decisive/handlers.py | 4 +- autowsgr/ops/decisive/state.py | 2 +- autowsgr/ops/event_fight.py | 2 +- autowsgr/scheduler/launcher.py | 1 + autowsgr/scheduler/scheduler.py | 8 +-- autowsgr/server/routes/task.py | 2 +- autowsgr/ui/battle/detection.py | 8 ++- autowsgr/ui/battle/fleet_change.py | 10 +-- autowsgr/ui/battle/fleet_change/_change.py | 4 +- autowsgr/ui/choose_ship_page.py | 9 +-- autowsgr/ui/decisive/fleet_ocr.py | 3 +- autowsgr/ui/decisive/map_controller.py | 8 ++- autowsgr/ui/decisive/preparation.py | 5 +- autowsgr/ui/map/panels/sortie.py | 21 +++--- autowsgr/ui/mission_page/page.py | 1 + autowsgr/ui/mission_page/recognition.py | 6 +- autowsgr/ui/navigation.py | 10 +-- autowsgr/ui/page.py | 2 +- autowsgr/ui/tabbed_page.py | 2 + autowsgr/ui/utils/ship_list.py | 2 +- autowsgr/vision/pixel.py | 2 +- examples/week.py | 4 +- pyproject.toml | 21 ++++++ testing/emulator/test_controller.py | 22 +++--- testing/ops/build.py | 6 +- testing/ops/campaign.py | 4 +- testing/ops/cook.py | 6 +- testing/ops/decisive_battle.py | 7 +- testing/ops/destroy.py | 6 +- testing/ops/event_fight.py | 4 +- testing/ops/exercise.py | 6 +- testing/ops/expedition.py | 6 +- testing/ops/normal_fight.py | 10 +-- testing/ops/repair.py | 6 +- testing/ops/reward.py | 6 +- testing/ops/scheduler.py | 4 +- testing/ops/startup.py | 8 +-- testing/ui/_framework.py | 48 +++++++++---- testing/ui/backyard_page/e2e.py | 9 +-- testing/ui/bath_page/e2e.py | 8 ++- testing/ui/battle_preparation/e2e.py | 13 ++-- testing/ui/battle_preparation/test_unit.py | 2 + testing/ui/build_page/e2e.py | 8 ++- testing/ui/canteen_page/e2e.py | 8 ++- testing/ui/decisive_battle_page/e2e.py | 10 +-- testing/ui/event_page/e2e.py | 20 +++--- testing/ui/friend_page/e2e.py | 5 +- testing/ui/intensify_page/e2e.py | 8 ++- testing/ui/main_page/e2e.py | 12 ++-- testing/ui/map_page/e2e.py | 5 +- testing/ui/mission_page/e2e.py | 5 +- testing/ui/run_all_e2e.py | 4 +- testing/ui/sidebar_page/e2e.py | 6 +- testing/vision/test_image_checker.py | 8 +-- testing/vision/test_matcher.py | 4 +- testing/vision/test_ocr.py | 7 +- tools/debug_screenshot.py | 13 ++-- tools/pixel_marker.py | 8 +-- tools/update_shipnames.py | 7 +- 70 files changed, 338 insertions(+), 244 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a0ce9ba0..e703b8b5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,3 +27,13 @@ repos: hooks: - id: codespell additional_dependencies: [".[toml]"] + - repo: local + hooks: + - id: ty-check + name: ty type check + description: Run Astral's ty type checker. + entry: uvx ty check + language: system + pass_filenames: false + require_serial: true + types: [python] diff --git a/AGENTS.md b/AGENTS.md index b4bc6706..b0865d92 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Agent Guidelines -## 开发环境 +## 安装 ```bash git clone git@github.com:OpenWSGR/AutoWSGR.git @@ -14,70 +14,65 @@ pre-commit install ```bash source .venv/bin/activate # Linux/macOS # .venv\Scripts\activate # Windows -pytest -pre-commit run --all-files ``` -## 代码风格 - -- Python 版本:3.12+ -- 格式化与 lint:**Ruff**(已覆盖 isort / black 功能),配置见 `pyproject.toml` -- 目标行宽 100,单引号字符串 -- 禁止相对导入(`ban-relative-imports = all`) -- 英语拼写检查:**codespell**,忽略词表见 `docs/spelling_wordlist.txt` - -提交前务必运行: +## pytest ```bash -pre-commit run --all-files +pytest -n auto ``` -## 测试 +测试目录结构: + +| 目录 | 说明 | +|------|------| +| `tests/unit/` | pytest 自动运行的单元测试 | +| `tests/manual/` | 需真实设备的手动 e2e 测试 | -- 单元测试:`pytest`(测试目录 `testing/`) -- 功能测试:运行 `examples/` 目录中的脚本进行端到端验证 +## pre-commit 检查 + +提交前务必运行: ```bash -pytest +pre-commit run --all-files ``` -## 约定式提交(Conventional Commits) +包含 **Ruff**(格式化与 lint)和 **ty**(类型检查)。 -提交信息格式: +## 类型检查 -``` -(): <简短描述> +本项目使用 **ty**(Astral 出品的 Python 类型检查器)进行静态类型检查。 -<正文> -``` +- 优先通过正确的类型注解、返回值标注和类型窄化来消除类型错误。 +- **禁止**在工作代码中使用 `typing.cast` 来掩盖类型问题;`cast` 只允许在测试文件的 Mock 场景中使用。 +- 若类型检查器因容器型变(如 `list` 的 invariant)报错,优先考虑将函数参数改为 `Sequence`、`Mapping` 等协变抽象基类,而非使用 `cast`。 +- 修复类型问题时尽量保持最小改动,避免不必要的重构。 -常用类型: +### `ty: ignore` 注释规范 -- `feat`:新功能 -- `fix`:修复 -- `build`:构建系统或依赖变更 -- `docs`:文档 -- `style`:不影响代码逻辑的格式调整 -- `refactor`:重构 -- `test`:测试 +当必须通过注释忽略类型错误时,**必须使用 ty 原生格式**: -示例: +```python +# 正确 +c.r = 10 # ty: ignore[invalid-assignment] +ctrl._device.shell.assert_called_once_with('input tap 480 270') # ty: ignore[unresolved-attribute] -``` -build: migrate from setuptools to hatchling +# 错误 —— ty 无法识别 mypy 的 error code +# type: ignore[invalid-assignment] +# type: ignore[misc] -- Replace setuptools with hatchling as build backend -- Remove obsolete MANIFEST.in +# 错误 —— 裸 ignore 会被 ruff PGH003 拦截,且无法精确控制 +# type: ignore +# type: ignore # noqa: PGH003 ``` -## 构建与打包 +> 项目已启用 `unused-type-ignore-comment = "error"`,未使用的 `# ty: ignore[...]` 会导致 CI 失败。 -- Build backend:**hatchling** -- 包数据(图片、YAML、JAR 等)位于 `autowsgr/data/`,由 hatchling 自动包含,无需 `MANIFEST.in` +## 单元测试要求 -```bash -uv build -``` +新增功能或修改核心逻辑时,必须在 `tests/unit/` 下提供对应的 pytest 单元测试。测试文件应与被测源文件一一对应。 + +## 约定式提交 ## 文档 diff --git a/autowsgr/combat/engine.py b/autowsgr/combat/engine.py index d9931f9a..cf9f74dd 100644 --- a/autowsgr/combat/engine.py +++ b/autowsgr/combat/engine.py @@ -62,7 +62,7 @@ def __init__( # 运行时状态 (由 fight() 重置) self._plan: CombatPlan = CombatPlan(name='', mode=CombatMode.BATTLE) - self._recognizer: CombatRecognizer = None # type: ignore[assignment] # set in fight() + self._recognizer: CombatRecognizer | None = None # set in fight() self._phase = CombatPhase.PROCEED self._last_action = 'yes' self._node = '0' @@ -83,7 +83,7 @@ def __init__( def fight( # noqa: PLR0912 self, plan: CombatPlan, - initial_ship_stats: list[ShipDamageState], + initial_ship_stats: list[ShipDamageState] | None, ) -> CombatResult: """执行一次完整的战斗循环。 diff --git a/autowsgr/combat/handlers.py b/autowsgr/combat/handlers.py index e8682508..408339bb 100644 --- a/autowsgr/combat/handlers.py +++ b/autowsgr/combat/handlers.py @@ -163,7 +163,9 @@ def _handle_spot_enemy(self) -> ConditionFlag: # noqa: PLR0912 # ── 信息采集 ── mode = 'exercise' if self._plan.mode == CombatMode.EXERCISE else 'fight' enemies = get_enemy_info(self._device, mode=mode) - enemy_formation = get_enemy_formation(self._device, self._ocr) + enemy_formation = ( + get_enemy_formation(self._device, self._ocr) if self._ocr is not None else '' + ) _log.info('[Combat] 敌方编成: {} 阵型: {}', enemies, enemy_formation) decision = self._get_current_decision() diff --git a/autowsgr/combat/history.py b/autowsgr/combat/history.py index 1d2e752f..ceb4af6e 100644 --- a/autowsgr/combat/history.py +++ b/autowsgr/combat/history.py @@ -94,7 +94,7 @@ class CombatEvent: extra: dict[str, Any] = field(default_factory=dict) def __str__(self) -> str: - parts = [f'[{self.event_type.name}]'] + parts: list[str] = [f'[{self.event_type.name}]'] if self.node: parts.append(f'节点={self.node}') if self.action: diff --git a/autowsgr/combat/rules.py b/autowsgr/combat/rules.py index a99b5ea9..76e9062d 100644 --- a/autowsgr/combat/rules.py +++ b/autowsgr/combat/rules.py @@ -30,12 +30,16 @@ import re from dataclasses import dataclass, field from enum import Enum, auto -from typing import Any +from typing import TYPE_CHECKING, Any from autowsgr.infra.logger import get_logger from autowsgr.types import Formation +if TYPE_CHECKING: + from collections.abc import Mapping + + # 允许在规则中出现的舰种标识符 _log = get_logger('combat.recognition') @@ -134,7 +138,7 @@ def __post_init__(self) -> None: if self.op not in _OPERATORS: raise ValueError(f"不支持的操作符: '{self.op}',支持: {list(_OPERATORS)}") - def evaluate(self, context: dict[str, int | float]) -> bool: + def evaluate(self, context: Mapping[str, int | float]) -> bool: """在给定上下文中评估此条件。""" if '+' in self.field: parts = [p.strip() for p in self.field.split('+')] @@ -159,7 +163,7 @@ class Rule: conditions: list[Condition] action: RuleAction - def evaluate(self, context: dict[str, int | float]) -> bool: + def evaluate(self, context: Mapping[str, int | float]) -> bool: """所有条件是否均满足。""" return all(c.evaluate(context) for c in self.conditions) @@ -184,7 +188,7 @@ class RuleEngine: rules: list[Rule] = field(default_factory=list) default: RuleAction = field(default_factory=RuleAction.no_action) - def evaluate(self, context: dict[str, int | float]) -> RuleAction: + def evaluate(self, context: Mapping[str, int | float]) -> RuleAction: """对敌方编成上下文评估所有规则。 Parameters diff --git a/autowsgr/context/game_context.py b/autowsgr/context/game_context.py index 4269b619..9c0650cf 100644 --- a/autowsgr/context/game_context.py +++ b/autowsgr/context/game_context.py @@ -66,7 +66,7 @@ class GameContext: # ── 基础设施引用 (可选) ── - ocr: OCREngine + ocr: OCREngine | None = None """OCR 引擎实例 (章节/阵型识别等)。""" # ── 游戏运行时状态 ── diff --git a/autowsgr/emulator/os_control/windows.py b/autowsgr/emulator/os_control/windows.py index f040794b..841aa01d 100644 --- a/autowsgr/emulator/os_control/windows.py +++ b/autowsgr/emulator/os_control/windows.py @@ -93,6 +93,7 @@ def stop(self) -> None: _log.info('云手机无需关闭') return case _: + assert self._process_name is not None subprocess.run( # noqa: S603 ['taskkill', '-f', '-im', self._process_name], # noqa: S607 check=True, diff --git a/autowsgr/image_resources/keys.py b/autowsgr/image_resources/keys.py index 90cf90b2..40fb09ed 100644 --- a/autowsgr/image_resources/keys.py +++ b/autowsgr/image_resources/keys.py @@ -106,6 +106,8 @@ def _build_map() -> dict[TemplateKey, list[ImageTemplate]]: TemplateKey.END_EXERCISE_PAGE: [T.END_EXERCISE_PAGE], # 船坞已满 TemplateKey.DOCK_FULL: [T.DOCK_FULL], + # 战役次数耗尽 + TemplateKey.BATTLE_TIMES_EXCEED: [T.BATTLE_TIMES_EXCEED], # 战果评级 TemplateKey.GRADE_SS: [T.Result.SS], TemplateKey.GRADE_S: [T.Result.S], diff --git a/autowsgr/infra/config.py b/autowsgr/infra/config.py index dcdaaac0..0025dcee 100644 --- a/autowsgr/infra/config.py +++ b/autowsgr/infra/config.py @@ -486,7 +486,7 @@ def load(path: str | Path | None = None) -> UserConfig: return UserConfig() except ValidationError: # WSL/Linux 下默认配置缺少 serial/path 无法通过验证,提供占位值 - return UserConfig(emulator={'serial': '', 'path': ''}) + return UserConfig(emulator=EmulatorConfig(serial='', path='')) config = UserConfig.from_yaml(path) _log.info('已加载配置: {}', path) return config diff --git a/autowsgr/infra/logger.py b/autowsgr/infra/logger.py index bc490e3d..112c6670 100644 --- a/autowsgr/infra/logger.py +++ b/autowsgr/infra/logger.py @@ -66,7 +66,7 @@ from collections.abc import Callable import numpy as np - from loguru import Logger + from loguru import Logger, Record # ═══════════════════════════════════════════════════════════════════════════════ @@ -103,7 +103,7 @@ # ═══════════════════════════════════════════════════════════════════════════════ -def _src_patcher(record: dict) -> None: +def _src_patcher(record: Record) -> None: """将 record["file"].path 转为以项目根目录为基准的相对路径,并存入 extra["src"]。 格式示例:``emulator/controller.py:346`` @@ -382,11 +382,11 @@ def save_image( 保存的文件路径,未保存时返回 None。 """ - target_dir = str(img_dir or _image_dir) - target_dir = Path(target_dir) + target_dir = img_dir or _image_dir if target_dir is None: raise ValueError('未配置图片保存目录,请在 setup_logger 中设置 log_dir 并启用 save_images') + target_dir = Path(target_dir) target_dir.mkdir(parents=True, exist_ok=True) ts = _time.strftime('%H%M%S') + f'_{int(_time.monotonic() * 1000) % 1000:03d}' filename = f'{tag}_{ts}.png' diff --git a/autowsgr/ops/decisive/handlers.py b/autowsgr/ops/decisive/handlers.py index e7fd10c1..b832edea 100644 --- a/autowsgr/ops/decisive/handlers.py +++ b/autowsgr/ops/decisive/handlers.py @@ -205,8 +205,8 @@ def _handle_use_last_fleet(self) -> None: def _handle_dock_full(self) -> None: """船坞已满: 自动解装 → ENTER_MAP。""" _log.warning('[决战] 处理船坞已满') - self._do_dock_full_destroy() # type: ignore[attr-defined] # from DecisiveChapterOps - self._prepare_entry_state() # type: ignore[attr-defined] # from DecisiveChapterOps + self._do_dock_full_destroy() # from DecisiveChapterOps + self._prepare_entry_state() # from DecisiveChapterOps self._state.phase = DecisivePhase.ENTER_MAP # ── 舰队与地图 ──────────────────────────────────────────────────────── diff --git a/autowsgr/ops/decisive/state.py b/autowsgr/ops/decisive/state.py index c3d1eb2f..139852dc 100644 --- a/autowsgr/ops/decisive/state.py +++ b/autowsgr/ops/decisive/state.py @@ -45,7 +45,7 @@ class DecisiveState: def reset(self) -> None: """重置状态 (保留 chapter)。""" chapter = self.chapter - self.__init__() # type: ignore[misc] + self.__init__() self.chapter = chapter def is_begin(self) -> bool: diff --git a/autowsgr/ops/event_fight.py b/autowsgr/ops/event_fight.py index ed337bdb..5cdbd0a5 100644 --- a/autowsgr/ops/event_fight.py +++ b/autowsgr/ops/event_fight.py @@ -234,7 +234,7 @@ def _enter_fight(self) -> None: # 委托 UI 层完成: 难度 / 节点 / 出击 event_page = BaseEventPage(self._ctx) - entrance: Literal['alpha', 'beta'] | None = self._entrance # type: ignore[assignment] + entrance: Literal['alpha', 'beta'] | None = self._entrance event_page.start_fight(self._map_code, entrance, self._skip_check) # ── 出征准备 ── diff --git a/autowsgr/scheduler/launcher.py b/autowsgr/scheduler/launcher.py index 6b1bbd13..6cec83e2 100644 --- a/autowsgr/scheduler/launcher.py +++ b/autowsgr/scheduler/launcher.py @@ -155,6 +155,7 @@ def build_context(self) -> GameContext: """ if self._ocr is None: self.create_ocr() + assert self._ocr is not None ctx = GameContext( ctrl=self.ctrl, diff --git a/autowsgr/scheduler/scheduler.py b/autowsgr/scheduler/scheduler.py index 6fce6c11..686c132c 100644 --- a/autowsgr/scheduler/scheduler.py +++ b/autowsgr/scheduler/scheduler.py @@ -24,7 +24,7 @@ import time from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Protocol, runtime_checkable +from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable from autowsgr.combat import CombatResult from autowsgr.infra.logger import get_logger @@ -64,13 +64,13 @@ class BatchRunnerAdapter: 每次 ``.run()`` 返回最后一场结果;若列表为空,返回默认成功。 """ - def __init__(self, inner: object) -> None: + def __init__(self, inner: Any) -> None: if not hasattr(inner, 'run'): raise TypeError(f'{type(inner).__name__} 没有 run() 方法') self._inner = inner def run(self) -> CombatResult: - results = self._inner.run() # type: ignore[union-attr] + results = self._inner.run() if isinstance(results, list): return ( results[-1] @@ -79,7 +79,7 @@ def run(self) -> CombatResult: flag=ConditionFlag.OPERATION_SUCCESS, ) ) - return results # type: ignore[return-value] + return results # ═══════════════════════════════════════════════════════════════════════════════ diff --git a/autowsgr/server/routes/task.py b/autowsgr/server/routes/task.py index 0c9e46db..53ec1b61 100644 --- a/autowsgr/server/routes/task.py +++ b/autowsgr/server/routes/task.py @@ -34,7 +34,7 @@ @router.post('/start', response_model=ApiResponse) -async def task_start(request: TaskRequestUnion) -> ApiResponse: # type: ignore[arg-type] +async def task_start(request: TaskRequestUnion) -> ApiResponse: """启动任务 (异步执行,立即返回)。""" if task_manager.is_running: raise HTTPException(status_code=409, detail='已有任务正在运行') diff --git a/autowsgr/ui/battle/detection.py b/autowsgr/ui/battle/detection.py index 889f35c2..5d02ca3d 100644 --- a/autowsgr/ui/battle/detection.py +++ b/autowsgr/ui/battle/detection.py @@ -25,6 +25,8 @@ if TYPE_CHECKING: + from collections.abc import Sequence + import numpy as np from autowsgr.context.ship import Ship @@ -49,7 +51,7 @@ class FleetInfo: ship_damage: dict[int, ShipDamageState] = field(default_factory=dict) """槽位号 (0-5) → 血量状态。""" - def to_ships(self, names: list[str | None] | None = None) -> list[Ship]: + def to_ships(self, names: Sequence[str | None] | None = None) -> list[Ship]: """将舰队信息转换为 Ship 列表。 Parameters @@ -71,8 +73,8 @@ def to_ships(self, names: list[str | None] | None = None) -> list[Ship]: if damage == ShipDamageState.NO_SHIP: continue name = '' - if names and i < len(names) and names[i] is not None: - name = names[i] + if names and i < len(names): + name = names[i] or '' ships.append( Ship( name=name, diff --git a/autowsgr/ui/battle/fleet_change.py b/autowsgr/ui/battle/fleet_change.py index f0b49a12..7a8813dc 100644 --- a/autowsgr/ui/battle/fleet_change.py +++ b/autowsgr/ui/battle/fleet_change.py @@ -13,9 +13,9 @@ from autowsgr.infra.logger import get_logger from autowsgr.types import ShipDamageState - -from ..utils import wait_for_page -from .base import BaseBattlePreparation +from autowsgr.ui.battle.base import BaseBattlePreparation +from autowsgr.ui.battle.detection import DetectionMixin +from autowsgr.ui.utils import wait_for_page if TYPE_CHECKING: @@ -25,7 +25,7 @@ _log = get_logger('ui.preparation') -class FleetChangeMixin(BaseBattlePreparation): +class FleetChangeMixin(DetectionMixin, BaseBattlePreparation): """舰队编成更换 Mixin。 依赖 :class:`~autowsgr.ui.battle.base.BaseBattlePreparation` 提供的 @@ -100,7 +100,7 @@ def _change_single_ship( slot_occupied: bool = True, ) -> None: """更换/移除指定位置的单艘舰船。""" - from ..choose_ship_page import ChooseShipPage + from autowsgr.ui.choose_ship_page import ChooseShipPage if name is None and not slot_occupied: return diff --git a/autowsgr/ui/battle/fleet_change/_change.py b/autowsgr/ui/battle/fleet_change/_change.py index 5549ce95..c93f3874 100644 --- a/autowsgr/ui/battle/fleet_change/_change.py +++ b/autowsgr/ui/battle/fleet_change/_change.py @@ -15,7 +15,7 @@ import re import time from collections import Counter -from typing import TYPE_CHECKING, TypedDict +from typing import TYPE_CHECKING, Any, TypedDict from autowsgr.infra.logger import get_logger from autowsgr.ui.battle.constants import CLICK_SHIP_SLOT @@ -292,7 +292,7 @@ def _normalize_ship_name(value: object) -> str | None: return name or None @staticmethod - def _extract_selector(slot: object | None) -> dict | None: + def _extract_selector(slot: Any | None) -> dict | None: if slot is None or isinstance(slot, str): return None diff --git a/autowsgr/ui/choose_ship_page.py b/autowsgr/ui/choose_ship_page.py index 22335e4f..f8537b85 100644 --- a/autowsgr/ui/choose_ship_page.py +++ b/autowsgr/ui/choose_ship_page.py @@ -15,7 +15,7 @@ import re import time -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from autowsgr.infra.logger import get_logger from autowsgr.vision import ( @@ -320,9 +320,10 @@ def _normalize_hit_entry(hit: object) -> tuple[str, float, float, float]: if len(hit) < 3: raise ValueError(f'unsupported hit entry length: {hit!r}') - matched = str(hit[0]).strip() - cx = float(hit[1]) - cy = float(hit[2]) + hit_seq: tuple[Any, ...] | list[Any] = hit + matched = str(hit_seq[0]).strip() + cx = float(hit_seq[1]) + cy = float(hit_seq[2]) if len(hit) >= 4 and isinstance(hit[3], (int, float)): row_key = round(float(hit[3]), 4) diff --git a/autowsgr/ui/decisive/fleet_ocr.py b/autowsgr/ui/decisive/fleet_ocr.py index 60bc1c28..6ef196f3 100644 --- a/autowsgr/ui/decisive/fleet_ocr.py +++ b/autowsgr/ui/decisive/fleet_ocr.py @@ -59,7 +59,8 @@ def _prepare_text_roi(image: np.ndarray, *, scale: int = 4) -> np.ndarray: gray = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY) gray = cv2.resize(gray, None, fx=scale, fy=scale, interpolation=cv2.INTER_CUBIC) gray = cv2.GaussianBlur(gray, (3, 3), 0) - norm = cv2.normalize(gray, None, 0, 255, cv2.NORM_MINMAX) + norm = gray.copy() + cv2.normalize(gray, norm, 0, 255, cv2.NORM_MINMAX) binary = cv2.threshold(norm, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)[1] return cv2.cvtColor(binary, cv2.COLOR_GRAY2RGB) diff --git a/autowsgr/ui/decisive/map_controller.py b/autowsgr/ui/decisive/map_controller.py index 810df595..27f54d12 100644 --- a/autowsgr/ui/decisive/map_controller.py +++ b/autowsgr/ui/decisive/map_controller.py @@ -325,6 +325,7 @@ def recognize_fleet_options( """OCR 识别战备舰队获取界面的可选项。""" if screen is None: screen = self.wait_for_fleet_overlay_stable() + assert self._ocr is not None return _fleet_ocr.recognize_fleet_options( self._ocr, screen, @@ -359,6 +360,7 @@ def detect_last_offer_name( """读取战备舰队最后一张卡的名称,用于首节点判定修正。""" if screen is None: screen = self._ctrl.screenshot() + assert self._ocr is not None return _fleet_ocr.detect_last_offer_name(self._ocr, screen) def buy_fleet_option(self, click_position: tuple[float, float]) -> None: @@ -423,6 +425,7 @@ def click_use_last_fleet(self) -> None: def use_skill(self) -> list[str]: """在地图页使用一次副官技能并返回识别到的舰船。""" + assert self._ocr is not None return _fleet_ocr.use_skill(self._ctrl, self._ocr) def check_fleet( @@ -476,6 +479,7 @@ def check_fleet( time.sleep(0.3) # 等待选船列表内容稳定 ship_list_screen = self._ctrl.screenshot() + assert self._ocr is not None available = _recognize_ships(self._ocr, ship_list_screen) _log.debug('[地图控制器] 选船列表识别结果: {}', sorted(available)) @@ -620,9 +624,11 @@ def confirm_stage_clear(self) -> list[str]: # noqa: PLR0912 if detail is None: break + assert self._ocr is not None ship_drop = recognize_ship_drop(screen, ocr=self._ocr) _log.info(f'[地图控制器] 检测到掉落: {ship_drop.ship_name}({ship_drop.ship_type})') - collected.append(ship_drop.ship_name) + if ship_drop.ship_name is not None: + collected.append(ship_drop.ship_name) self._ctrl.click(0.953, 0.954) time.sleep(0.5) confirm_operation(self._ctrl, timeout=1.0) diff --git a/autowsgr/ui/decisive/preparation.py b/autowsgr/ui/decisive/preparation.py index 90eb5e99..fe2b3602 100644 --- a/autowsgr/ui/decisive/preparation.py +++ b/autowsgr/ui/decisive/preparation.py @@ -54,5 +54,8 @@ def __init__( ocr: OCREngine | None = None, ) -> None: super().__init__(ctx, ocr) - self._ocr: OCREngine = ocr or ctx.ocr # type: ignore[assignment] + _ocr = ocr if ocr is not None else ctx.ocr + if _ocr is None: + raise ValueError('DecisivePreparation requires an OCR engine') + self._ocr: OCREngine = _ocr self._config = config diff --git a/autowsgr/ui/map/panels/sortie.py b/autowsgr/ui/map/panels/sortie.py index 3d727e6a..6e2317b3 100644 --- a/autowsgr/ui/map/panels/sortie.py +++ b/autowsgr/ui/map/panels/sortie.py @@ -31,7 +31,7 @@ if TYPE_CHECKING: import numpy as np - from autowsgr.vision import EasyOCREngine + from autowsgr.vision import OCREngine _log = get_logger('ui') @@ -74,7 +74,7 @@ class LootShipCount: """OCR 字符白名单。包含 ``/`` 和 ``|`` 使 OCR 正确识别斜线而非误读为 ``1``。""" -def _parse_numerator(text: str, max_val: int) -> int: +def _parse_numerator(text: str, max_val: int) -> int | None: """从 ``"X/Y"`` 格式的 OCR 文本中提取分子 (``/`` 前的数字)。 - 优先按 ``/`` 或 ``|`` 分割取第一段。 @@ -104,7 +104,7 @@ def _parse_numerator(text: str, max_val: int) -> int: return None -def recognize_loot_count(screen: np.ndarray, ocr: EasyOCREngine) -> int | None: +def recognize_loot_count(screen: np.ndarray, ocr: OCREngine) -> int | None: """识别出征面板战利品 (胖次) 已获取数量。 OCR ``X/50`` 区域并提取 ``/`` 前的数字, 上限固定为 50。 @@ -115,16 +115,16 @@ def recognize_loot_count(screen: np.ndarray, ocr: EasyOCREngine) -> int | None: _log.warning('[UI] 战利品数量 OCR 无结果') return None count = _parse_numerator(text, LOOT_MAX) + if count is None: + _log.warning("[UI] 战利品数量 OCR 解析失败: '{}'", text) + return None if count > 50 and str(count).endswith('1'): count = int(str(count)[:-1]) # 可能 OCR 把 "/50" 识别成 "150" - if count is not None: - _log.info('[UI] 战利品数量: {}/{}', count, LOOT_MAX) - else: - _log.warning("[UI] 战利品数量 OCR 解析失败: '{}'", text) + _log.info('[UI] 战利品数量: {}/{}', count, LOOT_MAX) return count -def recognize_ship_count(screen: np.ndarray, ocr: EasyOCREngine) -> int | None: +def recognize_ship_count(screen: np.ndarray, ocr: OCREngine) -> int | None: """识别出征面板舰船已获取数量。 OCR ``X/500`` 区域并提取 ``/`` 前的数字, 上限固定为 500。 @@ -196,6 +196,7 @@ def navigate_to_chapter(self, target: int) -> int | None: # noqa: C901, PLR0912 raise ValueError(f'章节编号必须为 1-{TOTAL_CHAPTERS},收到: {target}') if self._ocr is None: raise RuntimeError('需要 OCR 引擎才能导航到指定章节') + _ocr = self._ocr def _read_chapter( samples: int = 3, delay: float = 0.15 @@ -206,7 +207,7 @@ def _read_chapter( for i in range(samples): screen = self._ctrl.screenshot() last_screen = screen - info = self.recognize_map(screen, self._ocr) + info = self.recognize_map(screen, _ocr) if info is not None: chapters.append(info.chapter) if i < samples - 1: @@ -295,6 +296,8 @@ def _read_chapter( def navigate_to_map(self, map_num: int | str) -> None: """通过 OCR 识别当前地图编号并左右翻页至目标。""" + if self._ocr is None: + raise RuntimeError('需要 OCR 引擎才能导航到指定地图') map_num = int(map_num) screen = self._ctrl.screenshot() info = self.recognize_map(screen, self._ocr) diff --git a/autowsgr/ui/mission_page/page.py b/autowsgr/ui/mission_page/page.py index e9f7aacb..ee743cef 100644 --- a/autowsgr/ui/mission_page/page.py +++ b/autowsgr/ui/mission_page/page.py @@ -179,6 +179,7 @@ def recognize_missions(self, screen: np.ndarray) -> list[MissionInfo]: candidates = get_all_mission_names() missions: list[MissionInfo] = [] for anchor_y, btn_type in rows: + assert self._ctx.ocr is not None info = recognize_row(screen, anchor_y, btn_type, self._ctx.ocr, candidates) if info is None: _log.debug('[UI] 任务识别: 跳过无效行 (anchor_y={:.3f})', anchor_y) diff --git a/autowsgr/ui/mission_page/recognition.py b/autowsgr/ui/mission_page/recognition.py index 625fc90c..d65dceb5 100644 --- a/autowsgr/ui/mission_page/recognition.py +++ b/autowsgr/ui/mission_page/recognition.py @@ -202,7 +202,11 @@ def match_mission_name( and min(len(name), len(ocr_text)) >= 0.7 * max(len(name), len(ocr_text)) ] if substring_hits: - return max(substring_hits, key=len) + longest = substring_hits[0] + for name in substring_hits[1:]: + if len(name) > len(longest): + longest = name + return longest # Levenshtein 模糊匹配 from autowsgr.vision.ocr import _edit_distance diff --git a/autowsgr/ui/navigation.py b/autowsgr/ui/navigation.py index 27b9e310..2ff30a93 100644 --- a/autowsgr/ui/navigation.py +++ b/autowsgr/ui/navigation.py @@ -258,11 +258,13 @@ def find_path(source: str, target: str) -> list[NavEdge] | None: list[NavEdge] | None 路径上的边列表;``source == target`` 时返回空列表;不可达返回 ``None``。 """ - if source == target: + source_name = PageName(source) + target_name = PageName(target) + if source_name == target_name: return [] - visited: set[str] = {source} - queue: deque[tuple[str, list[NavEdge]]] = deque([(source, [])]) + visited: set[PageName] = {source_name} + queue: deque[tuple[PageName, list[NavEdge]]] = deque([(source_name, [])]) while queue: current, path = queue.popleft() @@ -270,7 +272,7 @@ def find_path(source: str, target: str) -> list[NavEdge] | None: if edge.target in visited: continue new_path = [*path, edge] - if edge.target == target: + if edge.target == target_name: return new_path visited.add(edge.target) queue.append((edge.target, new_path)) diff --git a/autowsgr/ui/page.py b/autowsgr/ui/page.py index 6af6fa18..30b083f2 100644 --- a/autowsgr/ui/page.py +++ b/autowsgr/ui/page.py @@ -46,7 +46,7 @@ def register_page(name: str, checker: Callable[[np.ndarray], bool]) -> None: """注册页面识别函数。""" # Python 3.13+ 中 StrEnum 的 str()/format() 返回 'ClassName.MEMBER' 而非值, # 显式提取 .value 确保 key 始终为纯 str,避免日志和比较中出现意外格式。 - key: str = name.value if hasattr(name, 'value') else name + key: str = getattr(name, 'value', name) if key in _PAGE_REGISTRY: _log.warning("[UI] 页面 '{}' 已注册,将覆盖", key) _PAGE_REGISTRY[key] = checker diff --git a/autowsgr/ui/tabbed_page.py b/autowsgr/ui/tabbed_page.py index 38d09c79..d8bdbe52 100644 --- a/autowsgr/ui/tabbed_page.py +++ b/autowsgr/ui/tabbed_page.py @@ -179,6 +179,8 @@ def _load_templates() -> dict[TabbedPageType, np.ndarray]: path = _TEMPLATE_DIR / filename buf = np.frombuffer(path.read_bytes(), np.uint8) img = cv2.imdecode(buf, cv2.IMREAD_UNCHANGED) + if img is None: + raise RuntimeError(f'无法加载模板: {path}') result[page_type] = img > 0 return result diff --git a/autowsgr/ui/utils/ship_list.py b/autowsgr/ui/utils/ship_list.py index 84adee23..8c4123fb 100644 --- a/autowsgr/ui/utils/ship_list.py +++ b/autowsgr/ui/utils/ship_list.py @@ -307,7 +307,7 @@ def collect_levels(img: np.ndarray) -> None: gray = cv2.cvtColor(roi, cv2.COLOR_RGB2GRAY) up = cv2.resize(gray, None, fx=3, fy=3, interpolation=cv2.INTER_CUBIC) - norm = cv2.normalize(up, None, 0, 255, cv2.NORM_MINMAX) + norm = cv2.normalize(up, up, 0, 255, cv2.NORM_MINMAX) binary = cv2.threshold(norm, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)[1] binary_rgb = cv2.cvtColor(binary, cv2.COLOR_GRAY2RGB) collect_levels(binary_rgb) diff --git a/autowsgr/vision/pixel.py b/autowsgr/vision/pixel.py index dfb3ec42..7f036274 100644 --- a/autowsgr/vision/pixel.py +++ b/autowsgr/vision/pixel.py @@ -138,7 +138,7 @@ def from_dict(cls, d: dict) -> PixelRule: """ color = d['color'] if isinstance(color, (list, tuple)): - c = Color.from_rgb_tuple(tuple(color)) # type: ignore[arg-type] + c = Color.from_rgb_tuple(tuple(color)) elif isinstance(color, dict): c = Color(r=color['r'], g=color['g'], b=color['b']) else: diff --git a/examples/week.py b/examples/week.py index abe28454..b22361d9 100644 --- a/examples/week.py +++ b/examples/week.py @@ -15,4 +15,6 @@ plan, fleet_id=2, ) -runner.run_for_times_condition(1, last_point[i]) +point = last_point[i] +assert point is not None +runner.run_for_times_condition(1, point) diff --git a/pyproject.toml b/pyproject.toml index f464f67d..4549fbdc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,7 @@ ignore-words = "docs/spelling_wordlist.txt" [tool.pytest.ini_options] testpaths = ["testing"] +addopts = ["--import-mode=importlib"] [tool.ruff] target-version = "py312" @@ -210,3 +211,23 @@ inline-quotes = "single" [tool.ruff.lint.flake8-tidy-imports] ban-relative-imports = "all" + +[tool.ty.environment] +python-version = "3.12" + +[tool.ty.src] +exclude = [ + ".venv/**", + "dist/**", + "build/**", + ".git/**", + ".pytest_cache/**", + ".ruff_cache/**", +] + +[tool.ty.rules] +# Treat unresolved-attribute as warning to reduce noise from platform-specific +# modules (e.g. winreg on Linux) and third-party C extensions during beta. +unresolved-attribute = "warn" +# Ban invalid / unused type-ignore comments. +unused-type-ignore-comment = "error" diff --git a/testing/emulator/test_controller.py b/testing/emulator/test_controller.py index 869d7afd..c6b907c2 100644 --- a/testing/emulator/test_controller.py +++ b/testing/emulator/test_controller.py @@ -56,36 +56,36 @@ def ctrl(self) -> ScrcpyController: def test_click_center(self, ctrl: ScrcpyController): ctrl.click(0.5, 0.5) - ctrl._device.shell.assert_called_once_with('input tap 480 270') + ctrl._device.shell.assert_called_once_with('input tap 480 270') # type: ignore # noqa: PGH003 def test_click_top_left(self, ctrl: ScrcpyController): ctrl.click(0.0, 0.0) - ctrl._device.shell.assert_called_once_with('input tap 0 0') + ctrl._device.shell.assert_called_once_with('input tap 0 0') # type: ignore # noqa: PGH003 def test_click_bottom_right(self, ctrl: ScrcpyController): ctrl.click(1.0, 1.0) - ctrl._device.shell.assert_called_once_with('input tap 960 540') + ctrl._device.shell.assert_called_once_with('input tap 960 540') # type: ignore # noqa: PGH003 def test_click_quarter(self, ctrl: ScrcpyController): ctrl.click(0.25, 0.75) - ctrl._device.shell.assert_called_once_with('input tap 240 405') + ctrl._device.shell.assert_called_once_with('input tap 240 405') # type: ignore # noqa: PGH003 def test_swipe_default_duration(self, ctrl: ScrcpyController): ctrl.swipe(0.1, 0.2, 0.9, 0.8) - ctrl._device.shell.assert_called_once_with('input swipe 96 108 864 432 500') + ctrl._device.shell.assert_called_once_with('input swipe 96 108 864 432 500') # type: ignore # noqa: PGH003 def test_swipe_custom_duration(self, ctrl: ScrcpyController): ctrl.swipe(0.0, 0.0, 1.0, 1.0, duration=1.0) - ctrl._device.shell.assert_called_once_with('input swipe 0 0 960 540 1000') + ctrl._device.shell.assert_called_once_with('input swipe 0 0 960 540 1000') # type: ignore # noqa: PGH003 def test_swipe_short_duration(self, ctrl: ScrcpyController): ctrl.swipe(0.5, 0.5, 0.6, 0.6, duration=0.2) - ctrl._device.shell.assert_called_once_with('input swipe 480 270 576 324 200') + ctrl._device.shell.assert_called_once_with('input swipe 480 270 576 324 200') # type: ignore # noqa: PGH003 def test_long_tap_delegates_to_swipe(self, ctrl: ScrcpyController): """long_tap 通过 swipe(x, y, x, y, duration) 实现。""" ctrl.long_tap(0.5, 0.5, duration=2.0) - ctrl._device.shell.assert_called_once_with('input swipe 480 270 480 270 2000') + ctrl._device.shell.assert_called_once_with('input swipe 480 270 480 270 2000') # type: ignore # noqa: PGH003 def test_high_resolution(self): """1920x1080 分辨率下的转换。""" @@ -112,7 +112,7 @@ def test_screenshot_returns_last_frame(self): ctrl._resolution = (4, 3) # mock 视频流,避免启动真实 scrcpy 连接 - ctrl._ensure_stream_alive = MagicMock() + ctrl._ensure_stream_alive = MagicMock() # type: ignore # noqa: PGH003 ctrl._alive = True img = np.zeros((3, 4, 3), dtype=np.uint8) @@ -128,7 +128,7 @@ def test_screenshot_timeout(self): ctrl._resolution = (4, 3) # mock 视频流,避免启动真实 scrcpy 连接 - ctrl._ensure_stream_alive = MagicMock() + ctrl._ensure_stream_alive = MagicMock() # type: ignore # noqa: PGH003 ctrl._alive = True ctrl._last_frame = None # 始终无帧 @@ -141,7 +141,7 @@ def test_screenshot_retry_on_initial_none(self): ctrl._resolution = (2, 2) # mock 视频流,避免启动真实 scrcpy 连接 - ctrl._ensure_stream_alive = MagicMock() + ctrl._ensure_stream_alive = MagicMock() # type: ignore # noqa: PGH003 ctrl._alive = True img = np.zeros((2, 2, 3), dtype=np.uint8) diff --git a/testing/ops/build.py b/testing/ops/build.py index 9a396e38..35ec542e 100644 --- a/testing/ops/build.py +++ b/testing/ops/build.py @@ -18,8 +18,8 @@ try: if hasattr(sys.stdout, 'reconfigure'): - sys.stdout.reconfigure(encoding='utf-8', errors='replace') - sys.stderr.reconfigure(encoding='utf-8', errors='replace') + sys.stdout.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 + sys.stderr.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 except Exception: # noqa: S110 pass from loguru import logger @@ -56,7 +56,7 @@ def main() -> None: from autowsgr.ops.build import collect_built_ships - result = collect_built_ships(ctrl, build_type='ship', allow_fast_build=False) + result = collect_built_ships(ctx, build_type='ship', allow_fast_build=False) logger.info(f'collect_built_ships() 返回: {result}') print(f' [OK] collect_built_ships() = {result} 艘') except Exception as exc: diff --git a/testing/ops/campaign.py b/testing/ops/campaign.py index de67c199..4a5d02ee 100644 --- a/testing/ops/campaign.py +++ b/testing/ops/campaign.py @@ -36,8 +36,8 @@ # ── UTF-8 输出兼容(Windows 终端)── try: if hasattr(sys.stdout, 'reconfigure'): - sys.stdout.reconfigure(encoding='utf-8', errors='replace') # type: ignore[union-attr] - sys.stderr.reconfigure(encoding='utf-8', errors='replace') # type: ignore[union-attr] + sys.stdout.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 + sys.stderr.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 except Exception: try: if isinstance(sys.stdout, io.TextIOWrapper): diff --git a/testing/ops/cook.py b/testing/ops/cook.py index 8bf942ba..b304a760 100644 --- a/testing/ops/cook.py +++ b/testing/ops/cook.py @@ -18,8 +18,8 @@ try: if hasattr(sys.stdout, 'reconfigure'): - sys.stdout.reconfigure(encoding='utf-8', errors='replace') - sys.stderr.reconfigure(encoding='utf-8', errors='replace') + sys.stdout.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 + sys.stderr.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 except Exception: # noqa: S110 pass from loguru import logger @@ -61,7 +61,7 @@ def main() -> None: from autowsgr.ops.cook import cook - result = cook(ctrl, position=recipe, force_cook=False) + result = cook(ctx, position=recipe, force_cook=False) logger.info(f'cook() 返回: {result}') print(f' [OK] cook() = {result}') except Exception as exc: diff --git a/testing/ops/decisive_battle.py b/testing/ops/decisive_battle.py index b0533b92..3c29ea85 100644 --- a/testing/ops/decisive_battle.py +++ b/testing/ops/decisive_battle.py @@ -30,8 +30,8 @@ # ── UTF-8 输出兼容 (Windows 终端) ────────────────────────────────────────── try: if hasattr(sys.stdout, 'reconfigure'): - sys.stdout.reconfigure(encoding='utf-8', errors='replace') # type: ignore[union-attr] - sys.stderr.reconfigure(encoding='utf-8', errors='replace') # type: ignore[union-attr] + sys.stdout.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 + sys.stderr.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 except Exception: # noqa: S110 pass from loguru import logger @@ -148,7 +148,6 @@ def main() -> None: try: ctx = launch_for_test(args.serial, log_dir=log_dir, with_ocr=True) ctrl = ctx.ctrl - ocr = ctx.ocr logger.info('已连接: {}', ctrl.serial) print(f' [OK] 已连接: {ctrl.serial}') print(' [OK] OCR 引擎已就绪') @@ -165,7 +164,7 @@ def main() -> None: flagship_priority=args.flagship, repair_level=args.repair_level, ) - controller = DecisiveController(ctx, config, ocr=ocr) + controller = DecisiveController(ctx, config) logger.info('DecisiveController 构建完成 (章节 {})', args.chapter) print(f' [OK] 控制器就绪 (章节 {args.chapter})') print() diff --git a/testing/ops/destroy.py b/testing/ops/destroy.py index 005d8a18..4158ad69 100644 --- a/testing/ops/destroy.py +++ b/testing/ops/destroy.py @@ -26,8 +26,8 @@ try: if hasattr(sys.stdout, 'reconfigure'): - sys.stdout.reconfigure(encoding='utf-8', errors='replace') - sys.stderr.reconfigure(encoding='utf-8', errors='replace') + sys.stdout.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 + sys.stderr.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 except Exception: # noqa: S110 pass from loguru import logger @@ -104,7 +104,7 @@ def main() -> None: from autowsgr.ops.destroy import destroy_ships destroy_ships( - ctrl, + ctx, ship_types=ship_types, remove_equipment=args.remove_equipment, ) diff --git a/testing/ops/event_fight.py b/testing/ops/event_fight.py index 26c927ef..4ba0e84b 100644 --- a/testing/ops/event_fight.py +++ b/testing/ops/event_fight.py @@ -39,8 +39,8 @@ # ── UTF-8 输出兼容(Windows 终端)── try: if hasattr(sys.stdout, 'reconfigure'): - sys.stdout.reconfigure(encoding='utf-8', errors='replace') # type: ignore[union-attr] - sys.stderr.reconfigure(encoding='utf-8', errors='replace') # type: ignore[union-attr] + sys.stdout.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 + sys.stderr.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 except Exception: try: if isinstance(sys.stdout, io.TextIOWrapper): diff --git a/testing/ops/exercise.py b/testing/ops/exercise.py index fac2bb93..595ecb98 100644 --- a/testing/ops/exercise.py +++ b/testing/ops/exercise.py @@ -22,8 +22,8 @@ try: if hasattr(sys.stdout, 'reconfigure'): - sys.stdout.reconfigure(encoding='utf-8', errors='replace') # type: ignore[union-attr] - sys.stderr.reconfigure(encoding='utf-8', errors='replace') # type: ignore[union-attr] + sys.stdout.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 + sys.stderr.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 except Exception: # noqa: S110 pass from loguru import logger @@ -94,7 +94,7 @@ def main() -> None: from autowsgr.ops.exercise import run_exercise - results = run_exercise(ctrl, fleet_id=args.fleet, rival=args.rival) + results = run_exercise(ctx, fleet_id=args.fleet, rival=args.rival) logger.info('run_exercise() 返回 {} 场结果', len(results)) print(f' [OK] 完成 {len(results)} 场演习') print() diff --git a/testing/ops/expedition.py b/testing/ops/expedition.py index d4630779..c523d0e1 100644 --- a/testing/ops/expedition.py +++ b/testing/ops/expedition.py @@ -18,8 +18,8 @@ try: if hasattr(sys.stdout, 'reconfigure'): - sys.stdout.reconfigure(encoding='utf-8', errors='replace') - sys.stderr.reconfigure(encoding='utf-8', errors='replace') + sys.stdout.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 + sys.stderr.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 except Exception: # noqa: S110 pass from loguru import logger @@ -56,7 +56,7 @@ def main() -> None: from autowsgr.ops.expedition import collect_expedition - result = collect_expedition(ctrl) + result = collect_expedition(ctx) logger.info(f'collect_expedition() 返回: {result}') print(f' [OK] collect_expedition() = {result}') except Exception as exc: diff --git a/testing/ops/normal_fight.py b/testing/ops/normal_fight.py index 005af99f..3c796000 100644 --- a/testing/ops/normal_fight.py +++ b/testing/ops/normal_fight.py @@ -32,8 +32,8 @@ # ── UTF-8 输出兼容 (Windows 终端) ── try: if hasattr(sys.stdout, 'reconfigure'): - sys.stdout.reconfigure(encoding='utf-8', errors='replace') # type: ignore[union-attr] - sys.stderr.reconfigure(encoding='utf-8', errors='replace') # type: ignore[union-attr] + sys.stdout.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 + sys.stderr.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 except Exception: try: if isinstance(sys.stdout, io.TextIOWrapper): @@ -74,7 +74,7 @@ def _build_7_4_6ss_plan() -> CombatPlan: formation=Formation.double_column, night=False, proceed=True, - proceed_stop=[2, 2, 2, 2, 2, 2], + proceed_stop=[RepairMode.severe_damage] * 6, enemy_rules=default_rules, ) @@ -87,7 +87,7 @@ def _build_7_4_6ss_plan() -> CombatPlan: formation=Formation.single_column, night=False, proceed=True, - proceed_stop=[2, 2, 2, 2, 2, 2], + proceed_stop=[RepairMode.severe_damage] * 6, enemy_rules=b_rules, ) @@ -102,7 +102,7 @@ def _build_7_4_6ss_plan() -> CombatPlan: formation=Formation.single_column, night=True, proceed=True, - proceed_stop=[2, 2, 2, 2, 2, 2], + proceed_stop=[RepairMode.severe_damage] * 6, enemy_rules=m_rules, ) diff --git a/testing/ops/repair.py b/testing/ops/repair.py index de1f7819..0c066ae3 100644 --- a/testing/ops/repair.py +++ b/testing/ops/repair.py @@ -18,8 +18,8 @@ try: if hasattr(sys.stdout, 'reconfigure'): - sys.stdout.reconfigure(encoding='utf-8', errors='replace') - sys.stderr.reconfigure(encoding='utf-8', errors='replace') + sys.stdout.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 + sys.stderr.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 except Exception: # noqa: S110 pass from loguru import logger @@ -56,7 +56,7 @@ def main() -> None: from autowsgr.ops.repair import repair_in_bath - repair_in_bath(ctrl) + repair_in_bath(ctx) logger.info('repair_in_bath() 已执行') print(' [OK] repair_in_bath() 已执行') except Exception as exc: diff --git a/testing/ops/reward.py b/testing/ops/reward.py index 4d16ea20..c0bd2748 100644 --- a/testing/ops/reward.py +++ b/testing/ops/reward.py @@ -18,8 +18,8 @@ try: if hasattr(sys.stdout, 'reconfigure'): - sys.stdout.reconfigure(encoding='utf-8', errors='replace') - sys.stderr.reconfigure(encoding='utf-8', errors='replace') + sys.stdout.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 + sys.stderr.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 except Exception: # noqa: S110 pass from loguru import logger @@ -56,7 +56,7 @@ def main() -> None: from autowsgr.ops.reward import collect_rewards - result = collect_rewards(ctrl) + result = collect_rewards(ctx) logger.info(f'collect_rewards() 返回: {result}') print(f' [OK] collect_rewards() = {result}') except Exception as exc: diff --git a/testing/ops/scheduler.py b/testing/ops/scheduler.py index 4179313f..bbce5255 100644 --- a/testing/ops/scheduler.py +++ b/testing/ops/scheduler.py @@ -25,8 +25,8 @@ # ── UTF-8 输出兼容(Windows 终端)── try: if hasattr(sys.stdout, 'reconfigure'): - sys.stdout.reconfigure(encoding='utf-8', errors='replace') # type: ignore[union-attr] - sys.stderr.reconfigure(encoding='utf-8', errors='replace') # type: ignore[union-attr] + sys.stdout.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 + sys.stderr.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 except Exception: try: if isinstance(sys.stdout, io.TextIOWrapper): diff --git a/testing/ops/startup.py b/testing/ops/startup.py index a18ee99e..e30b092f 100644 --- a/testing/ops/startup.py +++ b/testing/ops/startup.py @@ -30,8 +30,8 @@ # ── UTF-8 输出兼容 (Windows 终端) ── try: if hasattr(sys.stdout, 'reconfigure'): - sys.stdout.reconfigure(encoding='utf-8', errors='replace') # type: ignore[union-attr] - sys.stderr.reconfigure(encoding='utf-8', errors='replace') # type: ignore[union-attr] + sys.stdout.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 + sys.stderr.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 except Exception: try: if isinstance(sys.stdout, io.TextIOWrapper): @@ -46,7 +46,7 @@ pass from loguru import logger -from autowsgr.emulator import ADBController +from autowsgr.emulator import ScrcpyController from autowsgr.infra import ConfigManager, setup_logger from autowsgr.ops.startup import restart_game from autowsgr.types import GameAPP @@ -105,7 +105,7 @@ def main() -> None: # ── 连接设备 ── logger.info('正在连接设备{}...', f' ({serial})' if serial else ' (自动检测)') - ctrl = ADBController(serial=serial or cfg.emulator.serial) + ctrl = ScrcpyController(serial=serial or cfg.emulator.serial) try: dev_info = ctrl.connect() logger.info( diff --git a/testing/ui/_framework.py b/testing/ui/_framework.py index 4a97a217..456a14ab 100644 --- a/testing/ui/_framework.py +++ b/testing/ui/_framework.py @@ -47,8 +47,8 @@ def run_test(runner: UIControllerTestRunner) -> None: try: if hasattr(sys.stdout, 'reconfigure'): - sys.stdout.reconfigure(encoding='utf-8', errors='replace') - sys.stderr.reconfigure(encoding='utf-8', errors='replace') + sys.stdout.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 + sys.stderr.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 except Exception: try: if isinstance(sys.stdout, io.TextIOWrapper): @@ -67,7 +67,7 @@ def run_test(runner: UIControllerTestRunner) -> None: import numpy as np - from autowsgr.emulator import ADBController + from autowsgr.emulator import AndroidController, ScrcpyController # ═══════════════════════════════════════════════════════════════════════════════ @@ -212,7 +212,7 @@ class UIControllerTestRunner: Parameters ---------- ctrl: - 已连接的 ADBController 实例。 + 已连接的 ScrcpyController 实例。 controller_name: 被测控制器名称 (用于报告)。 log_dir: @@ -225,7 +225,7 @@ class UIControllerTestRunner: def __init__( self, - ctrl: ADBController, + ctrl: ScrcpyController, controller_name: str = '', log_dir: Path | None = None, *, @@ -272,7 +272,7 @@ def execute_step( action: str, expected_page: str, checker: Callable[[np.ndarray], bool], - do_action: Callable[[], None], + do_action: Callable[[], object], *, screenshot_tag: str = '', ) -> StepRecord | None: @@ -478,7 +478,25 @@ def print_summary(self) -> None: # ═══════════════════════════════════════════════════════════════════════════════ -def reset_to_main_page(ctrl: ADBController, pause: float = 1.5) -> bool: # noqa: PLR0912 +def _make_test_ctx(ctrl: AndroidController) -> GameContext: + """创建一个带有 Mock OCR 的 GameContext,供端到端测试使用。""" + from autowsgr.context import GameContext + from autowsgr.infra import UserConfig + from autowsgr.vision import OCREngine, OCRResult + + class _MockOCR(OCREngine): + def recognize( + self, + image: np.ndarray, + allowlist: str = '', + ) -> list[OCRResult]: + _ = image, allowlist + return [] + + return GameContext(ctrl=ctrl, config=UserConfig(), ocr=_MockOCR()) + + +def reset_to_main_page(ctrl: AndroidController, pause: float = 1.5) -> bool: # noqa: PLR0912 """从任意已知页面导航回主页面。 按"叶页面 → 中间页面 → 主页面"顺序尝试每一级的返回操作,最多循环 5 次。 @@ -545,7 +563,7 @@ def reset_to_main_page(ctrl: ADBController, pause: float = 1.5) -> bool: # noqa def ensure_page( - ctrl: ADBController, + ctrl: ScrcpyController, checker: Callable[[np.ndarray], bool], navigate_fn: Callable[[], None] | None, page_name: str, @@ -688,11 +706,11 @@ def parse_e2e_args( ) -def connect_device(serial: str | None, *, timeout: float = 15.0) -> ADBController: +def connect_device(serial: str | None, *, timeout: float = 15.0) -> ScrcpyController: """创建并连接 ADB 控制器,失败时退出进程。""" - from autowsgr.emulator import ADBController + from autowsgr.emulator import ScrcpyController - ctrl = ADBController(serial=serial, screenshot_timeout=timeout) + ctrl = ScrcpyController(serial=serial, screenshot_timeout=timeout) try: dev_info = ctrl.connect() ok(f'已连接: {dev_info.serial} 分辨率: {dev_info.resolution[0]}x{dev_info.resolution[1]}') @@ -708,7 +726,7 @@ def connect_via_launcher( log_level: str, *, timeout: float = 15.0, -) -> ADBController: +) -> ScrcpyController: """通过 Launcher 加载配置并连接设备。 自动从 ``usersettings.yaml``(当前工作目录)加载用户配置, @@ -729,10 +747,10 @@ def connect_via_launcher( Returns ------- - ADBController + ScrcpyController 已建立连接的设备控制器。 """ - from autowsgr.emulator import ADBController + from autowsgr.emulator import ScrcpyController from autowsgr.infra import ConfigManager from autowsgr.infra.logger import setup_logger @@ -749,7 +767,7 @@ def connect_via_launcher( setup_logger(log_dir=log_dir, level=log_level, save_images=True, channels=channels) # 连接设备 - ctrl = ADBController(serial=cfg.emulator.serial, screenshot_timeout=timeout) + ctrl = ScrcpyController(serial=cfg.emulator.serial, screenshot_timeout=timeout) try: dev_info = ctrl.connect() ok(f'已连接: {dev_info.serial} 分辨率: {dev_info.resolution[0]}x{dev_info.resolution[1]}') diff --git a/testing/ui/backyard_page/e2e.py b/testing/ui/backyard_page/e2e.py index ee40a5a1..12cbd81c 100644 --- a/testing/ui/backyard_page/e2e.py +++ b/testing/ui/backyard_page/e2e.py @@ -26,6 +26,7 @@ from testing.ui._framework import ( UIControllerTestRunner, + _make_test_ctx, connect_via_launcher, ensure_page, info, @@ -44,9 +45,9 @@ def run_test(runner: UIControllerTestRunner) -> None: from autowsgr.ui.canteen_page import CanteenPage from autowsgr.ui.main_page import MainPage - backyard_page = BackyardPage(runner.ctrl) - bath_page = BathPage(runner.ctrl) - canteen_page = CanteenPage(runner.ctrl) + backyard_page = BackyardPage(runner.ctx) + bath_page = BathPage(runner.ctx) + canteen_page = CanteenPage(runner.ctx) # Step 0: 验证初始 runner.verify_current('初始验证: 后院页面', '后院页面', BackyardPage.is_current_page) @@ -112,7 +113,7 @@ def _navigate_to(ctrl: AndroidController, pause: float) -> None: return screen = ctrl.screenshot() if MainPage.is_current_page(screen): - MainPage(ctrl).navigate_to(MainPage.Target.HOME) + MainPage(_make_test_ctx(ctrl)).navigate_to(MainPage.Target.HOME) time.sleep(pause) diff --git a/testing/ui/bath_page/e2e.py b/testing/ui/bath_page/e2e.py index c690226d..604468e0 100644 --- a/testing/ui/bath_page/e2e.py +++ b/testing/ui/bath_page/e2e.py @@ -25,6 +25,7 @@ from testing.ui._framework import ( UIControllerTestRunner, + _make_test_ctx, connect_via_launcher, ensure_page, info, @@ -41,7 +42,7 @@ def run_test(runner: UIControllerTestRunner) -> None: from autowsgr.ui.backyard_page import BackyardPage from autowsgr.ui.bath_page import BathPage - bath_page = BathPage(runner.ctrl) + bath_page = BathPage(runner.ctx) runner.verify_current('初始验证: 浴室页面', '浴室页面', BathPage.is_current_page) if runner.aborted: @@ -69,13 +70,14 @@ def _navigate_to(ctrl: AndroidController, pause: float) -> None: if not reset_to_main_page(ctrl, pause): return + ctx = _make_test_ctx(ctrl) screen = ctrl.screenshot() if MainPage.is_current_page(screen): - MainPage(ctrl).navigate_to(MainPage.Target.HOME) + MainPage(ctx).navigate_to(MainPage.Target.HOME) time.sleep(pause) screen = ctrl.screenshot() if BackyardPage.is_current_page(screen): - BackyardPage(ctrl).go_to_bath() + BackyardPage(ctx).go_to_bath() time.sleep(pause) diff --git a/testing/ui/battle_preparation/e2e.py b/testing/ui/battle_preparation/e2e.py index c6a04f0f..523fdc03 100644 --- a/testing/ui/battle_preparation/e2e.py +++ b/testing/ui/battle_preparation/e2e.py @@ -27,6 +27,7 @@ from testing.ui._framework import ( UIControllerTestRunner, + _make_test_ctx, connect_via_launcher, ensure_page, info, @@ -42,7 +43,7 @@ def run_test(runner: UIControllerTestRunner) -> None: from autowsgr.ui.battle.preparation import BattlePreparationPage, Panel - bp_page = BattlePreparationPage(runner.ctrl) + bp_page = BattlePreparationPage(runner.ctx) runner.verify_current( '初始验证: 出征准备页面', '出征准备页面', BattlePreparationPage.is_current_page @@ -94,7 +95,7 @@ def run_test(runner: UIControllerTestRunner) -> None: runner.execute_step( '出征准备页面 → ◁ 返回', - None, # 返回目标页面因地图层级不同而变化,跳过严格验证 + '未知页面', # 返回目标页面因地图层级不同而变化,跳过严格验证 lambda _: True, bp_page.go_back, ) @@ -133,11 +134,13 @@ def _navigate_to( if not reset_to_main_page(ctrl, pause): return + ctx = _make_test_ctx(ctrl) + # 主页面 → 地图 screen = ctrl.screenshot() if not MainPage.is_current_page(screen): return - MainPage(ctrl).navigate_to(MainPage.Target.SORTIE) + MainPage(ctx).navigate_to(MainPage.Target.SORTIE) time.sleep(pause) # 确认在地图并切换到出征面板 @@ -145,11 +148,11 @@ def _navigate_to( if not MapPage.is_current_page(screen): return if MapPage.get_active_panel(screen) != MapPanel.SORTIE: - MapPage(ctrl).switch_panel(MapPanel.SORTIE) + MapPage(ctx).switch_panel(MapPanel.SORTIE) time.sleep(pause) # 章节导航 (无 OCR)。先向前滑到第 1 章,再向后到目标章 - map_page = MapPage(ctrl) + map_page = MapPage(ctx) for _ in range(TOTAL_CHAPTERS): if not map_page.click_prev_chapter(): break diff --git a/testing/ui/battle_preparation/test_unit.py b/testing/ui/battle_preparation/test_unit.py index c86459e8..a4f4b5eb 100644 --- a/testing/ui/battle_preparation/test_unit.py +++ b/testing/ui/battle_preparation/test_unit.py @@ -49,6 +49,8 @@ def _make_ctx(ctrl: AndroidController, ocr: OCREngine | None = None) -> GameContext: + if ocr is None: + ocr = MagicMock() """构造 GameContext,用于 BattlePreparationPage 初始化。""" return GameContext(ctrl=ctrl, config=MagicMock(), ocr=ocr) diff --git a/testing/ui/build_page/e2e.py b/testing/ui/build_page/e2e.py index d1f11271..8ede40b3 100644 --- a/testing/ui/build_page/e2e.py +++ b/testing/ui/build_page/e2e.py @@ -25,6 +25,7 @@ from testing.ui._framework import ( UIControllerTestRunner, + _make_test_ctx, connect_via_launcher, ensure_page, info, @@ -41,7 +42,7 @@ def run_test(runner: UIControllerTestRunner) -> None: from autowsgr.ui.build_page import BuildPage, BuildTab from autowsgr.ui.sidebar_page import SidebarPage - build_page = BuildPage(runner.ctrl) + build_page = BuildPage(runner.ctx) runner.verify_current('初始验证: 建造页面', '建造页面', BuildPage.is_current_page) if runner.aborted: @@ -79,13 +80,14 @@ def _navigate_to(ctrl: AndroidController, pause: float) -> None: if not reset_to_main_page(ctrl, pause): return + ctx = _make_test_ctx(ctrl) screen = ctrl.screenshot() if MainPage.is_current_page(screen): - MainPage(ctrl).navigate_to(MainPage.Target.SIDEBAR) + MainPage(ctx).navigate_to(MainPage.Target.SIDEBAR) time.sleep(pause) screen = ctrl.screenshot() if SidebarPage.is_current_page(screen): - SidebarPage(ctrl).go_to_build() + SidebarPage(ctx).go_to_build() time.sleep(pause) diff --git a/testing/ui/canteen_page/e2e.py b/testing/ui/canteen_page/e2e.py index 67e10977..e100dbc7 100644 --- a/testing/ui/canteen_page/e2e.py +++ b/testing/ui/canteen_page/e2e.py @@ -24,6 +24,7 @@ from testing.ui._framework import ( UIControllerTestRunner, + _make_test_ctx, connect_via_launcher, ensure_page, info, @@ -40,7 +41,7 @@ def run_test(runner: UIControllerTestRunner) -> None: from autowsgr.ui.backyard_page import BackyardPage from autowsgr.ui.canteen_page import CanteenPage - canteen_page = CanteenPage(runner.ctrl) + canteen_page = CanteenPage(runner.ctx) runner.verify_current('初始验证: 食堂页面', '食堂页面', CanteenPage.is_current_page) if runner.aborted: @@ -63,13 +64,14 @@ def _navigate_to(ctrl: AndroidController, pause: float) -> None: if not reset_to_main_page(ctrl, pause): return + ctx = _make_test_ctx(ctrl) screen = ctrl.screenshot() if MainPage.is_current_page(screen): - MainPage(ctrl).navigate_to(MainPage.Target.HOME) + MainPage(ctx).navigate_to(MainPage.Target.HOME) time.sleep(pause) screen = ctrl.screenshot() if BackyardPage.is_current_page(screen): - BackyardPage(ctrl).go_to_canteen() + BackyardPage(ctx).go_to_canteen() time.sleep(pause) diff --git a/testing/ui/decisive_battle_page/e2e.py b/testing/ui/decisive_battle_page/e2e.py index 54f001d2..f4e94635 100644 --- a/testing/ui/decisive_battle_page/e2e.py +++ b/testing/ui/decisive_battle_page/e2e.py @@ -25,6 +25,7 @@ from testing.ui._framework import ( UIControllerTestRunner, + _make_test_ctx, connect_via_launcher, ensure_page, info, @@ -41,7 +42,7 @@ def run_test(runner: UIControllerTestRunner) -> None: from autowsgr.ui.decisive.battle_page import DecisiveBattlePage from autowsgr.ui.main_page import MainPage - db_page = DecisiveBattlePage(runner.ctrl) + db_page = DecisiveBattlePage(runner.ctx) runner.verify_current('初始验证: 决战页面', '决战页面', DecisiveBattlePage.is_current_page) if runner.aborted: @@ -83,17 +84,18 @@ def _navigate_to(ctrl: AndroidController, pause: float) -> None: if not reset_to_main_page(ctrl, pause): return + ctx = _make_test_ctx(ctrl) screen = ctrl.screenshot() if MainPage.is_current_page(screen): - MainPage(ctrl).navigate_to(MainPage.Target.SORTIE) + MainPage(ctx).navigate_to(MainPage.Target.SORTIE) time.sleep(pause) screen = ctrl.screenshot() if MapPage.is_current_page(screen): if MapPage.get_active_panel(screen) != MapPanel.DECISIVE: - MapPage(ctrl).switch_panel(MapPanel.DECISIVE) + MapPage(ctx).switch_panel(MapPanel.DECISIVE) time.sleep(pause) screen = ctrl.screenshot() - MapPage(ctrl).enter_decisive() + MapPage(ctx).enter_decisive() time.sleep(pause) diff --git a/testing/ui/event_page/e2e.py b/testing/ui/event_page/e2e.py index 8b01a978..fa26a76e 100644 --- a/testing/ui/event_page/e2e.py +++ b/testing/ui/event_page/e2e.py @@ -46,6 +46,7 @@ from testing.ui._framework import ( UIControllerTestRunner, + _make_test_ctx, connect_via_launcher, ensure_page, info, @@ -65,14 +66,14 @@ # ═══════════════════════════════════════════════════════════════════════════════ +from autowsgr.ui.event.event_page import BaseEventPage + + def run_test(runner: UIControllerTestRunner) -> None: """执行活动地图页面控制器完整测试序列。""" - from autowsgr.context import GameContext - from autowsgr.infra import UserConfig - from autowsgr.ui.event.event_page import BaseEventPage from autowsgr.ui.main_page import MainPage - ctx = GameContext(ctrl=runner.ctrl, config=UserConfig()) + ctx = _make_test_ctx(runner.ctrl) event_page = BaseEventPage(ctx) main_page = MainPage(ctx) @@ -93,7 +94,7 @@ def run_test(runner: UIControllerTestRunner) -> None: runner.read_state( '活动地图状态', readers={ - '浮层 (进入页弹窗)': BaseEventPage._detect_overlay, + '浮层 (进入页弹窗)': event_page._detect_overlay, }, ) @@ -178,7 +179,7 @@ def run_test(runner: UIControllerTestRunner) -> None: ) -def _try_enter_node(event_page: object, node_id: int) -> None: +def _try_enter_node(event_page: BaseEventPage, node_id: int) -> None: """尝试选择一个节点。异常时静默处理(不中断测试)。""" try: event_page._enter_node(node_id) # type: ignore[attr-defined] @@ -193,8 +194,6 @@ def _try_enter_node(event_page: object, node_id: int) -> None: def _navigate_to(ctrl: AndroidController, pause: float) -> None: """从任意已知页面导航到活动地图页面。""" - from autowsgr.context import GameContext - from autowsgr.infra import UserConfig from autowsgr.ui.main_page import MainPage if not reset_to_main_page(ctrl, pause): @@ -202,8 +201,7 @@ def _navigate_to(ctrl: AndroidController, pause: float) -> None: time.sleep(pause) screen = ctrl.screenshot() if MainPage.is_current_page(screen): - ctx = GameContext(ctrl=ctrl, config=UserConfig()) - MainPage(ctx).navigate_to(MainPage.Target.EVENT) + MainPage(_make_test_ctx(ctrl)).navigate_to(MainPage.Target.EVENT) time.sleep(pause) @@ -223,8 +221,6 @@ def main() -> None: logger.info('=== 活动地图页面 e2e 测试开始 ===') - from autowsgr.ui.event.event_page import BaseEventPage - if not ensure_page( ctrl, BaseEventPage.is_current_page, diff --git a/testing/ui/friend_page/e2e.py b/testing/ui/friend_page/e2e.py index 8fa1a2f0..fd8c1cfd 100644 --- a/testing/ui/friend_page/e2e.py +++ b/testing/ui/friend_page/e2e.py @@ -24,6 +24,7 @@ from testing.ui._framework import ( UIControllerTestRunner, + _make_test_ctx, connect_via_launcher, ensure_page, info, @@ -58,14 +59,12 @@ def _navigate_to(ctrl: AndroidController, pause: float) -> None: """从任意已知页面导航到好友页面。""" import time - from autowsgr.context import GameContext - from autowsgr.infra import UserConfig from autowsgr.ui.main_page import MainPage from autowsgr.ui.sidebar_page import SidebarPage if not reset_to_main_page(ctrl, pause): return - ctx = GameContext(ctrl=ctrl, config=UserConfig()) + ctx = _make_test_ctx(ctrl) screen = ctrl.screenshot() if MainPage.is_current_page(screen): MainPage(ctx).navigate_to(MainPage.Target.SIDEBAR) diff --git a/testing/ui/intensify_page/e2e.py b/testing/ui/intensify_page/e2e.py index 8cd916ff..8e3e7108 100644 --- a/testing/ui/intensify_page/e2e.py +++ b/testing/ui/intensify_page/e2e.py @@ -25,6 +25,7 @@ from testing.ui._framework import ( UIControllerTestRunner, + _make_test_ctx, connect_via_launcher, ensure_page, info, @@ -41,7 +42,7 @@ def run_test(runner: UIControllerTestRunner) -> None: from autowsgr.ui.intensify_page import IntensifyPage, IntensifyTab from autowsgr.ui.sidebar_page import SidebarPage - intensify_page = IntensifyPage(runner.ctrl) + intensify_page = IntensifyPage(runner.ctx) runner.verify_current('初始验证: 强化页面', '强化页面', IntensifyPage.is_current_page) if runner.aborted: @@ -79,13 +80,14 @@ def _navigate_to(ctrl: AndroidController, pause: float) -> None: if not reset_to_main_page(ctrl, pause): return + ctx = _make_test_ctx(ctrl) screen = ctrl.screenshot() if MainPage.is_current_page(screen): - MainPage(ctrl).navigate_to(MainPage.Target.SIDEBAR) + MainPage(ctx).navigate_to(MainPage.Target.SIDEBAR) time.sleep(pause) screen = ctrl.screenshot() if SidebarPage.is_current_page(screen): - SidebarPage(ctrl).go_to_intensify() + SidebarPage(ctx).go_to_intensify() time.sleep(pause) diff --git a/testing/ui/main_page/e2e.py b/testing/ui/main_page/e2e.py index 5ad4e3be..5e5ce5c7 100644 --- a/testing/ui/main_page/e2e.py +++ b/testing/ui/main_page/e2e.py @@ -74,11 +74,11 @@ def run_test(runner: UIControllerTestRunner) -> None: # noqa: PLR0911, C901, PL from autowsgr.ui.mission_page import MissionPage from autowsgr.ui.sidebar_page import SidebarPage - main_page = MainPage(runner.ctrl) - map_page = MapPage(runner.ctrl) - mission_page = MissionPage(runner.ctrl) - sidebar_page = SidebarPage(runner.ctrl) - backyard_page = BackyardPage(runner.ctrl) + main_page = MainPage(runner.ctx) + map_page = MapPage(runner.ctx) + mission_page = MissionPage(runner.ctx) + sidebar_page = SidebarPage(runner.ctx) + backyard_page = BackyardPage(runner.ctx) # ═══════════════════════════════════════════════════════════════════════ # A. 页面识别与状态 @@ -228,7 +228,7 @@ def run_test(runner: UIControllerTestRunner) -> None: # noqa: PLR0911, C901, PL from autowsgr.ui.event.event_page import BaseEventPage if BaseEventPage.is_current_page(screen): - event_page = BaseEventPage(runner.ctrl) + event_page = BaseEventPage(runner.ctx) runner.execute_step( '活动地图 → ◁ 主页面', '主页面', diff --git a/testing/ui/map_page/e2e.py b/testing/ui/map_page/e2e.py index 7f705029..1d2672fe 100644 --- a/testing/ui/map_page/e2e.py +++ b/testing/ui/map_page/e2e.py @@ -28,6 +28,7 @@ from testing.ui._framework import ( UIControllerTestRunner, + _make_test_ctx, connect_via_launcher, ensure_page, info, @@ -46,7 +47,7 @@ def run_test(runner: UIControllerTestRunner) -> None: from autowsgr.ui.map.data import MapPanel from autowsgr.ui.map.page import MapPage - map_page = MapPage(runner.ctrl) + map_page = MapPage(runner.ctx) # ───── Step 0: 验证初始状态 ────────────────────────────────────── runner.verify_current('初始验证: 地图页面', '地图页面', MapPage.is_current_page) @@ -120,7 +121,7 @@ def _navigate_to(ctrl: AndroidController, pause: float) -> None: return screen = ctrl.screenshot() if MainPage.is_current_page(screen): - MainPage(ctrl).navigate_to(MainPage.Target.SORTIE) + MainPage(_make_test_ctx(ctrl)).navigate_to(MainPage.Target.SORTIE) time.sleep(pause) diff --git a/testing/ui/mission_page/e2e.py b/testing/ui/mission_page/e2e.py index 666524d0..ff8d7c88 100644 --- a/testing/ui/mission_page/e2e.py +++ b/testing/ui/mission_page/e2e.py @@ -24,6 +24,7 @@ from testing.ui._framework import ( UIControllerTestRunner, + _make_test_ctx, connect_via_launcher, ensure_page, info, @@ -40,7 +41,7 @@ def run_test(runner: UIControllerTestRunner) -> None: from autowsgr.ui.main_page import MainPage from autowsgr.ui.mission_page import MissionPage - mission_page = MissionPage(runner.ctrl) + mission_page = MissionPage(runner.ctx) runner.verify_current('初始验证: 任务页面', '任务页面', MissionPage.is_current_page) if runner.aborted: @@ -64,7 +65,7 @@ def _navigate_to(ctrl: AndroidController, pause: float) -> None: return screen = ctrl.screenshot() if MainPage.is_current_page(screen): - MainPage(ctrl).navigate_to(MainPage.Target.TASK) + MainPage(_make_test_ctx(ctrl)).navigate_to(MainPage.Target.TASK) time.sleep(pause) diff --git a/testing/ui/run_all_e2e.py b/testing/ui/run_all_e2e.py index 346ae10a..d4438cef 100755 --- a/testing/ui/run_all_e2e.py +++ b/testing/ui/run_all_e2e.py @@ -32,8 +32,8 @@ # 处理 Windows GBK 编码兼容性 try: - sys.stdout.reconfigure(encoding='utf-8', errors='replace') - sys.stderr.reconfigure(encoding='utf-8', errors='replace') + sys.stdout.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 + sys.stderr.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 except Exception: # noqa: S110 pass # 如果 reconfigure 不可用,继续使用默认编码 from datetime import UTC, datetime diff --git a/testing/ui/sidebar_page/e2e.py b/testing/ui/sidebar_page/e2e.py index 59d5dddb..61b0f451 100644 --- a/testing/ui/sidebar_page/e2e.py +++ b/testing/ui/sidebar_page/e2e.py @@ -27,6 +27,7 @@ from testing.ui._framework import ( UIControllerTestRunner, + _make_test_ctx, connect_via_launcher, ensure_page, info, @@ -151,16 +152,13 @@ def _navigate_to(ctrl: AndroidController, pause: float) -> None: """从任意已知页面导航到侧边栏。""" import time - from autowsgr.context import GameContext - from autowsgr.infra import UserConfig from autowsgr.ui.main_page import MainPage if not reset_to_main_page(ctrl, pause): return screen = ctrl.screenshot() if MainPage.is_current_page(screen): - ctx = GameContext(ctrl=ctrl, config=UserConfig()) - MainPage(ctx).navigate_to(MainPage.Target.SIDEBAR) + MainPage(_make_test_ctx(ctrl)).navigate_to(MainPage.Target.SIDEBAR) time.sleep(pause) diff --git a/testing/vision/test_image_checker.py b/testing/vision/test_image_checker.py index b9d8b774..c5c2212b 100644 --- a/testing/vision/test_image_checker.py +++ b/testing/vision/test_image_checker.py @@ -361,7 +361,7 @@ def test_1080p_template_scaled_to_540p_screen(self): # 模板采集自 1080p: 60x80 像素 → 在 540p 下应缩放为 30x40 rng = np.random.RandomState(81) - big_img = rng.randint(0, 256, (60, 80, 3), dtype=np.uint8) + big_img = rng.randint(0, 256, (60, 80, 3), dtype=np.uint8).astype(np.uint8) from autowsgr.vision import ImageTemplate tmpl_1080 = ImageTemplate( @@ -392,7 +392,7 @@ def test_540p_template_scaled_to_1080p_screen(self): # 模板采集自 540p: 30x40 像素 → 在 1080p 下应缩放为 60x80 rng = np.random.RandomState(82) - small_img = rng.randint(0, 256, (30, 40, 3), dtype=np.uint8) + small_img = rng.randint(0, 256, (30, 40, 3), dtype=np.uint8).astype(np.uint8) from autowsgr.vision import ImageTemplate tmpl_540 = ImageTemplate( @@ -423,7 +423,7 @@ def test_mixed_resolution_templates_on_same_screen(self): # 模板 A: 采集自 960x540 (30x40) → 在 720p 下缩放为 40x53 rng_a = np.random.RandomState(83) - img_a = rng_a.randint(0, 256, (30, 40, 3), dtype=np.uint8) + img_a = rng_a.randint(0, 256, (30, 40, 3), dtype=np.uint8).astype(np.uint8) tmpl_a = ImageTemplate( name='tmpl_a', image=img_a, @@ -441,7 +441,7 @@ def test_mixed_resolution_templates_on_same_screen(self): # 模板 B: 采集自 1920x1080 (60x80) → 在 720p 下缩放为 40x53 rng_b = np.random.RandomState(84) - img_b = rng_b.randint(0, 256, (60, 80, 3), dtype=np.uint8) + img_b = rng_b.randint(0, 256, (60, 80, 3), dtype=np.uint8).astype(np.uint8) tmpl_b = ImageTemplate( name='tmpl_b', image=img_b, diff --git a/testing/vision/test_matcher.py b/testing/vision/test_matcher.py index acb0b346..51991a85 100644 --- a/testing/vision/test_matcher.py +++ b/testing/vision/test_matcher.py @@ -104,7 +104,7 @@ def test_repr(self): def test_immutable(self): c = Color.of(10, 20, 30) with pytest.raises((AttributeError, TypeError)): - c.r = 99 # type: ignore[misc] + c.r = 99 # type: ignore # noqa: PGH003 # ───────────────────────────────────────────── @@ -156,7 +156,7 @@ def test_to_dict_round_trip(self): def test_immutable(self): r = PixelRule.of(0.0, 0.0, (0, 0, 0)) with pytest.raises((AttributeError, TypeError)): - r.x = 99 # type: ignore[misc] + r.x = 99 # type: ignore # noqa: PGH003 # ───────────────────────────────────────────── diff --git a/testing/vision/test_ocr.py b/testing/vision/test_ocr.py index 66967d89..53725780 100644 --- a/testing/vision/test_ocr.py +++ b/testing/vision/test_ocr.py @@ -26,9 +26,10 @@ def __init__(self, results: list[OCRResult]) -> None: def recognize( self, - _image: np.ndarray, - _allowlist: str = '', + image: np.ndarray, + allowlist: str = '', ) -> list[OCRResult]: + _ = image, allowlist return self._results @@ -45,7 +46,7 @@ class TestOCRResult: def test_immutable(self): r = OCRResult(text='x', confidence=0.5) with pytest.raises((AttributeError, TypeError)): - r.text = 'y' # type: ignore[misc] + r.text = 'y' # type: ignore # noqa: PGH003 # ───────────────────────────────────────────── diff --git a/tools/debug_screenshot.py b/tools/debug_screenshot.py index 0404b88b..9d1fca63 100644 --- a/tools/debug_screenshot.py +++ b/tools/debug_screenshot.py @@ -113,12 +113,6 @@ def check_page_signatures(screen: np.ndarray, page_name: str | None = None) -> N known_pages['decisive_battle'] = DECISIVE_SIG except ImportError: pass - try: - from autowsgr.ui.main_page import PAGE_SIGNATURE as MAIN_SIG - - known_pages['main'] = MAIN_SIG - except ImportError: - pass print('\n=== 页面签名检测 ===') for name, sig in known_pages.items(): @@ -228,7 +222,12 @@ def main() -> None: if len(parts) != 4: print('错误: --roi 需要 4 个逗号分隔的浮点数') sys.exit(1) - run_ocr_on_roi(screen, tuple(parts), allowlist=args.allowlist, out_dir=out_dir) + run_ocr_on_roi( + screen, + (parts[0], parts[1], parts[2], parts[3]), + allowlist=args.allowlist, + out_dir=out_dir, + ) if not args.roi and args.check_page is None and not args.pixel: print('\n提示: 使用 --roi / --check-page / --pixel 执行进一步分析') diff --git a/tools/pixel_marker.py b/tools/pixel_marker.py index 02267f19..ae8b49af 100644 --- a/tools/pixel_marker.py +++ b/tools/pixel_marker.py @@ -492,7 +492,7 @@ def _canvas_to_image(self, cx: int, cy: int) -> tuple[int, int] | None: return (px, py) return None - def _on_canvas_click(self, event: tk.Event) -> None: # type: ignore[type-arg] + def _on_canvas_click(self, event: tk.Event) -> None: """左键点击:添加标注点。""" pos = self._canvas_to_image(event.x, event.y) if pos is None or self._image is None: @@ -517,7 +517,7 @@ def _on_canvas_click(self, event: tk.Event) -> None: # type: ignore[type-arg] f'添加点 #{len(self._config.points)}: ({rx:.4f}, {ry:.4f}) RGB=({r},{g},{b})' ) - def _on_canvas_right_click(self, event: tk.Event) -> None: # type: ignore[type-arg] + def _on_canvas_right_click(self, event: tk.Event) -> None: """右键点击:删除最近的标注点。""" if not self._config.points: return @@ -543,7 +543,7 @@ def _on_canvas_right_click(self, event: tk.Event) -> None: # type: ignore[type- f'删除点: ({removed.rx:.4f}, {removed.ry:.4f}) RGB=({removed.r},{removed.g},{removed.b})' ) - def _on_canvas_motion(self, event: tk.Event) -> None: # type: ignore[type-arg] + def _on_canvas_motion(self, event: tk.Event) -> None: """鼠标移动:显示当前位置颜色。""" pos = self._canvas_to_image(event.x, event.y) if pos is None or self._image is None: @@ -689,7 +689,7 @@ def _on_save_yaml(self) -> None: Path(path).write_text(self._config.to_yaml_str(), encoding='utf-8') self._status_var.set(f'已保存: {path}') - def _on_ctrl_c(self, _event: tk.Event) -> None: # type: ignore[type-arg] + def _on_ctrl_c(self, _event: tk.Event) -> None: """Ctrl+C:如果导出框有内容就复制。""" content = self._export_text.get('1.0', tk.END).strip() if content: diff --git a/tools/update_shipnames.py b/tools/update_shipnames.py index e70c4a1b..7ce5ae12 100644 --- a/tools/update_shipnames.py +++ b/tools/update_shipnames.py @@ -27,6 +27,7 @@ import shutil import sys from pathlib import Path +from typing import Any import requests from bs4 import BeautifulSoup @@ -39,8 +40,10 @@ # ── UTF-8 输出兼容 (Windows 终端) ──────────────────────────────────────────── try: if hasattr(sys.stdout, 'reconfigure'): - sys.stdout.reconfigure(encoding='utf-8', errors='replace') # type: ignore[union-attr] - sys.stderr.reconfigure(encoding='utf-8', errors='replace') # type: ignore[union-attr] + _stdout: Any = sys.stdout + _stdout.reconfigure(encoding='utf-8', errors='replace') + _stderr: Any = sys.stderr + _stderr.reconfigure(encoding='utf-8', errors='replace') except Exception: # noqa: S110 pass # ────────────────────────────────────────────────────────────────────────────