From 28ffd138726c65fb88529b0e3626f963dc4da144 Mon Sep 17 00:00:00 2001 From: 0xWelt Date: Sun, 10 May 2026 17:51:44 +0800 Subject: [PATCH] test: separate manual e2e tests and add one-to-one unit test structure --- autowsgr/ops/README.md | 48 +- docs/developer/test_pkg.md | 4 +- pyproject.toml | 5 +- {testing => tests}/__init__.py | 0 {testing => tests}/conftest.py | 0 {testing/combat => tests/manual}/__init__.py | 0 {testing => tests/manual}/ops/__init__.py | 0 {testing => tests/manual}/ops/_framework.py | 0 {testing => tests/manual}/ops/build.py | 12 +- {testing => tests/manual}/ops/campaign.py | 14 +- {testing => tests/manual}/ops/cook.py | 12 +- .../manual}/ops/decisive_battle.py | 16 +- {testing => tests/manual}/ops/destroy.py | 14 +- {testing => tests/manual}/ops/event_fight.py | 16 +- {testing => tests/manual}/ops/exercise.py | 14 +- {testing => tests/manual}/ops/expedition.py | 12 +- {testing => tests/manual}/ops/normal_fight.py | 12 +- {testing => tests/manual}/ops/repair.py | 12 +- {testing => tests/manual}/ops/reward.py | 12 +- {testing => tests/manual}/ops/scheduler.py | 14 +- {testing => tests/manual}/ops/startup.py | 8 +- .../emulator => tests/manual/ui}/__init__.py | 0 {testing => tests/manual}/ui/_framework.py | 6 +- .../manual/ui/backyard_page}/__init__.py | 0 .../manual}/ui/backyard_page/e2e.py | 10 +- .../manual/ui/bath_page}/__init__.py | 0 {testing => tests/manual}/ui/bath_page/e2e.py | 10 +- .../manual/ui/battle_preparation}/__init__.py | 0 .../manual}/ui/battle_preparation/e2e.py | 10 +- .../manual/ui/build_page}/__init__.py | 0 .../manual}/ui/build_page/e2e.py | 10 +- .../manual/ui/canteen_page}/__init__.py | 0 .../manual}/ui/canteen_page/e2e.py | 10 +- .../ui/decisive_battle_page}/__init__.py | 0 .../manual}/ui/decisive_battle_page/e2e.py | 10 +- .../manual/ui/event_page}/__init__.py | 0 .../manual}/ui/event_page/e2e.py | 14 +- .../manual/ui/friend_page}/__init__.py | 0 .../manual}/ui/friend_page/e2e.py | 10 +- .../manual/ui/intensify_page}/__init__.py | 0 .../manual}/ui/intensify_page/e2e.py | 10 +- .../manual/ui/main_page}/__init__.py | 0 {testing => tests/manual}/ui/main_page/e2e.py | 12 +- .../manual/ui/map_page}/__init__.py | 0 {testing => tests/manual}/ui/map_page/e2e.py | 10 +- .../manual/ui/mission_page}/__init__.py | 0 .../manual}/ui/mission_page/e2e.py | 10 +- {testing => tests/manual}/ui/run_all_e2e.py | 8 +- .../manual/ui/sidebar_page}/__init__.py | 0 .../manual}/ui/sidebar_page/e2e.py | 10 +- .../mission_page => tests/unit}/__init__.py | 0 .../ui/page => tests/unit/combat}/__init__.py | 0 tests/unit/combat/test_actions.py | 277 ++++++++ {testing => tests/unit}/combat/test_combat.py | 0 tests/unit/combat/test_engine.py | 272 ++++++++ tests/unit/combat/test_handlers.py | 260 +++++++ tests/unit/combat/test_history.py | 138 ++++ tests/unit/combat/test_node_tracker.py | 207 ++++++ tests/unit/combat/test_plan.py | 275 ++++++++ tests/unit/combat/test_recognition.py | 166 +++++ tests/unit/combat/test_recognizer.py | 291 ++++++++ tests/unit/combat/test_rules.py | 271 ++++++++ tests/unit/combat/test_state.py | 88 +++ .../unit/constants}/__init__.py | 0 tests/unit/constants/test_shipnames.py | 81 +++ .../vision => tests/unit/context}/__init__.py | 0 tests/unit/context/test_build.py | 50 ++ tests/unit/context/test_equipment.py | 24 + tests/unit/context/test_expedition.py | 46 ++ tests/unit/context/test_fleet.py | 54 ++ tests/unit/context/test_game_context.py | 88 +++ tests/unit/context/test_resources.py | 33 + tests/unit/context/test_ship.py | 91 +++ tests/unit/emulator/__init__.py | 0 tests/unit/emulator/controller/__init__.py | 0 .../unit/emulator/controller/test_protocol.py | 171 +++++ tests/unit/emulator/controller/test_scrcpy.py | 10 + tests/unit/emulator/os_control/__init__.py | 0 tests/unit/emulator/os_control/test_base.py | 157 +++++ tests/unit/emulator/os_control/test_linux.py | 179 +++++ tests/unit/emulator/os_control/test_macos.py | 327 +++++++++ .../unit/emulator/os_control/test_windows.py | 419 ++++++++++++ .../unit}/emulator/test_controller.py | 22 +- tests/unit/emulator/test_detector.py | 313 +++++++++ .../unit}/emulator/test_os_control.py | 0 tests/unit/image_resources/__init__.py | 0 tests/unit/image_resources/test__lazy.py | 133 ++++ tests/unit/image_resources/test_combat.py | 93 +++ tests/unit/image_resources/test_keys.py | 83 +++ tests/unit/image_resources/test_ops.py | 136 ++++ tests/unit/infra/__init__.py | 0 {testing => tests/unit}/infra/test_config.py | 0 .../unit}/infra/test_exceptions.py | 0 .../unit}/infra/test_file_utils.py | 0 tests/unit/infra/test_logger.py | 104 +++ tests/unit/ops/__init__.py | 0 tests/unit/ops/decisive/__init__.py | 0 tests/unit/ops/decisive/test_base.py | 153 +++++ tests/unit/ops/decisive/test_chapter.py | 83 +++ tests/unit/ops/decisive/test_config.py | 68 ++ tests/unit/ops/decisive/test_controller.py | 246 +++++++ tests/unit/ops/decisive/test_handlers.py | 146 ++++ tests/unit/ops/decisive/test_logic.py | 465 +++++++++++++ tests/unit/ops/decisive/test_state.py | 51 ++ tests/unit/ops/test_build.py | 118 ++++ tests/unit/ops/test_campaign.py | 63 ++ tests/unit/ops/test_cook.py | 36 + tests/unit/ops/test_destroy.py | 47 ++ tests/unit/ops/test_event_fight.py | 290 ++++++++ tests/unit/ops/test_exercise.py | 50 ++ tests/unit/ops/test_expedition.py | 73 ++ tests/unit/ops/test_navigate.py | 179 +++++ tests/unit/ops/test_normal_fight.py | 351 ++++++++++ tests/unit/ops/test_repair.py | 71 ++ tests/unit/ops/test_reward.py | 67 ++ tests/unit/ops/test_startup.py | 240 +++++++ tests/unit/scheduler/__init__.py | 0 tests/unit/scheduler/test_launcher.py | 101 +++ tests/unit/scheduler/test_scheduler.py | 113 +++ tests/unit/server/__init__.py | 0 tests/unit/server/routes/__init__.py | 0 tests/unit/server/routes/test_game.py | 257 +++++++ tests/unit/server/routes/test_health.py | 85 +++ tests/unit/server/routes/test_ops.py | 641 ++++++++++++++++++ tests/unit/server/routes/test_system.py | 204 ++++++ tests/unit/server/routes/test_task.py | 297 ++++++++ tests/unit/server/test_main.py | 48 ++ tests/unit/server/test_schemas.py | 133 ++++ tests/unit/server/test_serializers.py | 119 ++++ tests/unit/server/test_task_manager.py | 390 +++++++++++ tests/unit/server/test_ws_manager.py | 123 ++++ tests/unit/test_types.py | 113 +++ {testing => tests/unit}/ui/README.md | 24 +- tests/unit/ui/__init__.py | 0 tests/unit/ui/backyard_page/__init__.py | 0 tests/unit/ui/bath_page/__init__.py | 0 tests/unit/ui/bath_page/test_recognition.py | 10 + tests/unit/ui/bath_page/test_signatures.py | 100 +++ tests/unit/ui/battle/__init__.py | 0 tests/unit/ui/battle/fleet_change/__init__.py | 0 .../ui/battle/fleet_change/test__change.py | 10 + .../ui/battle/fleet_change/test__detect.py | 10 + tests/unit/ui/battle/test_base.py | 115 ++++ tests/unit/ui/battle/test_blood.py | 57 ++ tests/unit/ui/battle/test_constants.py | 133 ++++ tests/unit/ui/battle/test_detection.py | 135 ++++ tests/unit/ui/battle/test_fleet_change.py | 10 + tests/unit/ui/battle/test_preparation.py | 10 + tests/unit/ui/battle/test_repair.py | 10 + tests/unit/ui/battle/test_supply.py | 10 + tests/unit/ui/battle_preparation/__init__.py | 0 .../unit}/ui/battle_preparation/test_unit.py | 0 tests/unit/ui/build_page/__init__.py | 0 tests/unit/ui/canteen_page/__init__.py | 0 tests/unit/ui/decisive/__init__.py | 0 tests/unit/ui/decisive/test_battle_page.py | 10 + tests/unit/ui/decisive/test_fleet_ocr.py | 10 + tests/unit/ui/decisive/test_map_controller.py | 10 + tests/unit/ui/decisive/test_overlay.py | 10 + tests/unit/ui/decisive/test_preparation.py | 10 + .../unit/ui/decisive_battle_page/__init__.py | 0 tests/unit/ui/event/__init__.py | 0 tests/unit/ui/event/test_event_page.py | 10 + tests/unit/ui/event_page/__init__.py | 0 tests/unit/ui/friend_page/__init__.py | 0 tests/unit/ui/intensify_page/__init__.py | 0 tests/unit/ui/main_page/__init__.py | 0 tests/unit/ui/main_page/test_constants.py | 103 +++ tests/unit/ui/main_page/test_controller.py | 10 + tests/unit/ui/main_page/test_event_nav.py | 10 + tests/unit/ui/main_page/test_overlays.py | 10 + tests/unit/ui/map/__init__.py | 0 tests/unit/ui/map/panels/__init__.py | 0 tests/unit/ui/map/panels/test_campaign.py | 10 + tests/unit/ui/map/panels/test_decisive.py | 10 + tests/unit/ui/map/panels/test_exercise.py | 10 + tests/unit/ui/map/panels/test_expedition.py | 10 + tests/unit/ui/map/panels/test_sortie.py | 10 + tests/unit/ui/map/test_base.py | 10 + tests/unit/ui/map/test_data.py | 127 ++++ tests/unit/ui/map_page/__init__.py | 0 .../unit}/ui/map_page/test_unit.py | 0 tests/unit/ui/mission_page/__init__.py | 0 tests/unit/ui/mission_page/test_data.py | 10 + .../unit/ui/mission_page/test_recognition.py | 10 + tests/unit/ui/page/__init__.py | 0 {testing => tests/unit}/ui/page/test_unit.py | 5 +- {testing => tests/unit}/ui/run_all_e2e.ps1 | 0 tests/unit/ui/sidebar_page/__init__.py | 0 tests/unit/ui/test_backyard_page.py | 10 + tests/unit/ui/test_build_page.py | 10 + tests/unit/ui/test_canteen_page.py | 10 + tests/unit/ui/test_choose_ship_page.py | 10 + tests/unit/ui/test_friend_page.py | 10 + tests/unit/ui/test_intensify_page.py | 10 + tests/unit/ui/test_navigation.py | 104 +++ tests/unit/ui/test_sidebar_page.py | 10 + tests/unit/ui/test_start_screen_page.py | 10 + tests/unit/ui/test_tabbed_page.py | 10 + tests/unit/ui/utils/__init__.py | 0 tests/unit/ui/utils/test_navigation.py | 73 ++ tests/unit/ui/utils/test_ship_list.py | 76 +++ tests/unit/vision/__init__.py | 0 {testing => tests/unit}/vision/_helpers.py | 0 tests/unit/vision/test_api_dll.py | 106 +++ .../unit}/vision/test_image_checker.py | 0 tests/unit/vision/test_image_matcher.py | 494 ++++++++++++++ .../unit}/vision/test_image_template.py | 0 .../unit}/vision/test_matcher.py | 4 +- {testing => tests/unit}/vision/test_ocr.py | 2 +- tests/unit/vision/test_pixel.py | 372 ++++++++++ {testing => tests/unit}/vision/test_roi.py | 0 uv.lock | 30 + 213 files changed, 12831 insertions(+), 214 deletions(-) rename {testing => tests}/__init__.py (100%) rename {testing => tests}/conftest.py (100%) rename {testing/combat => tests/manual}/__init__.py (100%) rename {testing => tests/manual}/ops/__init__.py (100%) rename {testing => tests/manual}/ops/_framework.py (100%) rename {testing => tests/manual}/ops/build.py (85%) rename {testing => tests/manual}/ops/campaign.py (94%) rename {testing => tests/manual}/ops/cook.py (86%) rename {testing => tests/manual}/ops/decisive_battle.py (93%) rename {testing => tests/manual}/ops/destroy.py (89%) rename {testing => tests/manual}/ops/event_fight.py (95%) rename {testing => tests/manual}/ops/exercise.py (89%) rename {testing => tests/manual}/ops/expedition.py (84%) rename {testing => tests/manual}/ops/normal_fight.py (95%) rename {testing => tests/manual}/ops/repair.py (84%) rename {testing => tests/manual}/ops/reward.py (84%) rename {testing => tests/manual}/ops/scheduler.py (92%) rename {testing => tests/manual}/ops/startup.py (96%) rename {testing/emulator => tests/manual/ui}/__init__.py (100%) rename {testing => tests/manual}/ui/_framework.py (99%) rename {testing/infra => tests/manual/ui/backyard_page}/__init__.py (100%) rename {testing => tests/manual}/ui/backyard_page/e2e.py (94%) rename {testing/ui => tests/manual/ui/bath_page}/__init__.py (100%) rename {testing => tests/manual}/ui/bath_page/e2e.py (93%) rename {testing/ui/backyard_page => tests/manual/ui/battle_preparation}/__init__.py (100%) rename {testing => tests/manual}/ui/battle_preparation/e2e.py (96%) rename {testing/ui/bath_page => tests/manual/ui/build_page}/__init__.py (100%) rename {testing => tests/manual}/ui/build_page/e2e.py (94%) rename {testing/ui/battle_preparation => tests/manual/ui/canteen_page}/__init__.py (100%) rename {testing => tests/manual}/ui/canteen_page/e2e.py (93%) rename {testing/ui/build_page => tests/manual/ui/decisive_battle_page}/__init__.py (100%) rename {testing => tests/manual}/ui/decisive_battle_page/e2e.py (94%) rename {testing/ui/canteen_page => tests/manual/ui/event_page}/__init__.py (100%) rename {testing => tests/manual}/ui/event_page/e2e.py (97%) rename {testing/ui/decisive_battle_page => tests/manual/ui/friend_page}/__init__.py (100%) rename {testing => tests/manual}/ui/friend_page/e2e.py (93%) rename {testing/ui/event_page => tests/manual/ui/intensify_page}/__init__.py (100%) rename {testing => tests/manual}/ui/intensify_page/e2e.py (94%) rename {testing/ui/friend_page => tests/manual/ui/main_page}/__init__.py (100%) rename {testing => tests/manual}/ui/main_page/e2e.py (97%) rename {testing/ui/intensify_page => tests/manual/ui/map_page}/__init__.py (100%) rename {testing => tests/manual}/ui/map_page/e2e.py (95%) rename {testing/ui/main_page => tests/manual/ui/mission_page}/__init__.py (100%) rename {testing => tests/manual}/ui/mission_page/e2e.py (92%) rename {testing => tests/manual}/ui/run_all_e2e.py (97%) rename {testing/ui/map_page => tests/manual/ui/sidebar_page}/__init__.py (100%) rename {testing => tests/manual}/ui/sidebar_page/e2e.py (96%) rename {testing/ui/mission_page => tests/unit}/__init__.py (100%) rename {testing/ui/page => tests/unit/combat}/__init__.py (100%) create mode 100644 tests/unit/combat/test_actions.py rename {testing => tests/unit}/combat/test_combat.py (100%) create mode 100644 tests/unit/combat/test_engine.py create mode 100644 tests/unit/combat/test_handlers.py create mode 100644 tests/unit/combat/test_history.py create mode 100644 tests/unit/combat/test_node_tracker.py create mode 100644 tests/unit/combat/test_plan.py create mode 100644 tests/unit/combat/test_recognition.py create mode 100644 tests/unit/combat/test_recognizer.py create mode 100644 tests/unit/combat/test_rules.py create mode 100644 tests/unit/combat/test_state.py rename {testing/ui/sidebar_page => tests/unit/constants}/__init__.py (100%) create mode 100644 tests/unit/constants/test_shipnames.py rename {testing/vision => tests/unit/context}/__init__.py (100%) create mode 100644 tests/unit/context/test_build.py create mode 100644 tests/unit/context/test_equipment.py create mode 100644 tests/unit/context/test_expedition.py create mode 100644 tests/unit/context/test_fleet.py create mode 100644 tests/unit/context/test_game_context.py create mode 100644 tests/unit/context/test_resources.py create mode 100644 tests/unit/context/test_ship.py create mode 100644 tests/unit/emulator/__init__.py create mode 100644 tests/unit/emulator/controller/__init__.py create mode 100644 tests/unit/emulator/controller/test_protocol.py create mode 100644 tests/unit/emulator/controller/test_scrcpy.py create mode 100644 tests/unit/emulator/os_control/__init__.py create mode 100644 tests/unit/emulator/os_control/test_base.py create mode 100644 tests/unit/emulator/os_control/test_linux.py create mode 100644 tests/unit/emulator/os_control/test_macos.py create mode 100644 tests/unit/emulator/os_control/test_windows.py rename {testing => tests/unit}/emulator/test_controller.py (90%) create mode 100644 tests/unit/emulator/test_detector.py rename {testing => tests/unit}/emulator/test_os_control.py (100%) create mode 100644 tests/unit/image_resources/__init__.py create mode 100644 tests/unit/image_resources/test__lazy.py create mode 100644 tests/unit/image_resources/test_combat.py create mode 100644 tests/unit/image_resources/test_keys.py create mode 100644 tests/unit/image_resources/test_ops.py create mode 100644 tests/unit/infra/__init__.py rename {testing => tests/unit}/infra/test_config.py (100%) rename {testing => tests/unit}/infra/test_exceptions.py (100%) rename {testing => tests/unit}/infra/test_file_utils.py (100%) create mode 100644 tests/unit/infra/test_logger.py create mode 100644 tests/unit/ops/__init__.py create mode 100644 tests/unit/ops/decisive/__init__.py create mode 100644 tests/unit/ops/decisive/test_base.py create mode 100644 tests/unit/ops/decisive/test_chapter.py create mode 100644 tests/unit/ops/decisive/test_config.py create mode 100644 tests/unit/ops/decisive/test_controller.py create mode 100644 tests/unit/ops/decisive/test_handlers.py create mode 100644 tests/unit/ops/decisive/test_logic.py create mode 100644 tests/unit/ops/decisive/test_state.py create mode 100644 tests/unit/ops/test_build.py create mode 100644 tests/unit/ops/test_campaign.py create mode 100644 tests/unit/ops/test_cook.py create mode 100644 tests/unit/ops/test_destroy.py create mode 100644 tests/unit/ops/test_event_fight.py create mode 100644 tests/unit/ops/test_exercise.py create mode 100644 tests/unit/ops/test_expedition.py create mode 100644 tests/unit/ops/test_navigate.py create mode 100644 tests/unit/ops/test_normal_fight.py create mode 100644 tests/unit/ops/test_repair.py create mode 100644 tests/unit/ops/test_reward.py create mode 100644 tests/unit/ops/test_startup.py create mode 100644 tests/unit/scheduler/__init__.py create mode 100644 tests/unit/scheduler/test_launcher.py create mode 100644 tests/unit/scheduler/test_scheduler.py create mode 100644 tests/unit/server/__init__.py create mode 100644 tests/unit/server/routes/__init__.py create mode 100644 tests/unit/server/routes/test_game.py create mode 100644 tests/unit/server/routes/test_health.py create mode 100644 tests/unit/server/routes/test_ops.py create mode 100644 tests/unit/server/routes/test_system.py create mode 100644 tests/unit/server/routes/test_task.py create mode 100644 tests/unit/server/test_main.py create mode 100644 tests/unit/server/test_schemas.py create mode 100644 tests/unit/server/test_serializers.py create mode 100644 tests/unit/server/test_task_manager.py create mode 100644 tests/unit/server/test_ws_manager.py create mode 100644 tests/unit/test_types.py rename {testing => tests/unit}/ui/README.md (92%) create mode 100644 tests/unit/ui/__init__.py create mode 100644 tests/unit/ui/backyard_page/__init__.py create mode 100644 tests/unit/ui/bath_page/__init__.py create mode 100644 tests/unit/ui/bath_page/test_recognition.py create mode 100644 tests/unit/ui/bath_page/test_signatures.py create mode 100644 tests/unit/ui/battle/__init__.py create mode 100644 tests/unit/ui/battle/fleet_change/__init__.py create mode 100644 tests/unit/ui/battle/fleet_change/test__change.py create mode 100644 tests/unit/ui/battle/fleet_change/test__detect.py create mode 100644 tests/unit/ui/battle/test_base.py create mode 100644 tests/unit/ui/battle/test_blood.py create mode 100644 tests/unit/ui/battle/test_constants.py create mode 100644 tests/unit/ui/battle/test_detection.py create mode 100644 tests/unit/ui/battle/test_fleet_change.py create mode 100644 tests/unit/ui/battle/test_preparation.py create mode 100644 tests/unit/ui/battle/test_repair.py create mode 100644 tests/unit/ui/battle/test_supply.py create mode 100644 tests/unit/ui/battle_preparation/__init__.py rename {testing => tests/unit}/ui/battle_preparation/test_unit.py (100%) create mode 100644 tests/unit/ui/build_page/__init__.py create mode 100644 tests/unit/ui/canteen_page/__init__.py create mode 100644 tests/unit/ui/decisive/__init__.py create mode 100644 tests/unit/ui/decisive/test_battle_page.py create mode 100644 tests/unit/ui/decisive/test_fleet_ocr.py create mode 100644 tests/unit/ui/decisive/test_map_controller.py create mode 100644 tests/unit/ui/decisive/test_overlay.py create mode 100644 tests/unit/ui/decisive/test_preparation.py create mode 100644 tests/unit/ui/decisive_battle_page/__init__.py create mode 100644 tests/unit/ui/event/__init__.py create mode 100644 tests/unit/ui/event/test_event_page.py create mode 100644 tests/unit/ui/event_page/__init__.py create mode 100644 tests/unit/ui/friend_page/__init__.py create mode 100644 tests/unit/ui/intensify_page/__init__.py create mode 100644 tests/unit/ui/main_page/__init__.py create mode 100644 tests/unit/ui/main_page/test_constants.py create mode 100644 tests/unit/ui/main_page/test_controller.py create mode 100644 tests/unit/ui/main_page/test_event_nav.py create mode 100644 tests/unit/ui/main_page/test_overlays.py create mode 100644 tests/unit/ui/map/__init__.py create mode 100644 tests/unit/ui/map/panels/__init__.py create mode 100644 tests/unit/ui/map/panels/test_campaign.py create mode 100644 tests/unit/ui/map/panels/test_decisive.py create mode 100644 tests/unit/ui/map/panels/test_exercise.py create mode 100644 tests/unit/ui/map/panels/test_expedition.py create mode 100644 tests/unit/ui/map/panels/test_sortie.py create mode 100644 tests/unit/ui/map/test_base.py create mode 100644 tests/unit/ui/map/test_data.py create mode 100644 tests/unit/ui/map_page/__init__.py rename {testing => tests/unit}/ui/map_page/test_unit.py (100%) create mode 100644 tests/unit/ui/mission_page/__init__.py create mode 100644 tests/unit/ui/mission_page/test_data.py create mode 100644 tests/unit/ui/mission_page/test_recognition.py create mode 100644 tests/unit/ui/page/__init__.py rename {testing => tests/unit}/ui/page/test_unit.py (96%) rename {testing => tests/unit}/ui/run_all_e2e.ps1 (100%) create mode 100644 tests/unit/ui/sidebar_page/__init__.py create mode 100644 tests/unit/ui/test_backyard_page.py create mode 100644 tests/unit/ui/test_build_page.py create mode 100644 tests/unit/ui/test_canteen_page.py create mode 100644 tests/unit/ui/test_choose_ship_page.py create mode 100644 tests/unit/ui/test_friend_page.py create mode 100644 tests/unit/ui/test_intensify_page.py create mode 100644 tests/unit/ui/test_navigation.py create mode 100644 tests/unit/ui/test_sidebar_page.py create mode 100644 tests/unit/ui/test_start_screen_page.py create mode 100644 tests/unit/ui/test_tabbed_page.py create mode 100644 tests/unit/ui/utils/__init__.py create mode 100644 tests/unit/ui/utils/test_navigation.py create mode 100644 tests/unit/ui/utils/test_ship_list.py create mode 100644 tests/unit/vision/__init__.py rename {testing => tests/unit}/vision/_helpers.py (100%) create mode 100644 tests/unit/vision/test_api_dll.py rename {testing => tests/unit}/vision/test_image_checker.py (100%) create mode 100644 tests/unit/vision/test_image_matcher.py rename {testing => tests/unit}/vision/test_image_template.py (100%) rename {testing => tests/unit}/vision/test_matcher.py (99%) rename {testing => tests/unit}/vision/test_ocr.py (99%) create mode 100644 tests/unit/vision/test_pixel.py rename {testing => tests/unit}/vision/test_roi.py (100%) diff --git a/autowsgr/ops/README.md b/autowsgr/ops/README.md index 8be6acc3..2225f1e9 100644 --- a/autowsgr/ops/README.md +++ b/autowsgr/ops/README.md @@ -15,7 +15,7 @@ - cook - reward -每个模块只有少量功能,全部做 e2e 测试即可。测试脚本统一放在 `testing/ops/` 下。 +每个模块只有少量功能,全部做 e2e 测试即可。测试脚本统一放在 `tests/manual/ops/` 下。 | 模块 | 功能 | 状态 | |------|------|------| @@ -36,31 +36,31 @@ ### startup 测试 ✅ ```bash -python testing/ops/startup.py -python testing/ops/startup.py 127.0.0.1:16384 +python tests/ops/startup.py +python tests/ops/startup.py 127.0.0.1:16384 ``` ### normal_fight 测试 ✅ ```bash -python testing/ops/normal_fight.py -python testing/ops/normal_fight.py 127.0.0.1:16384 3 # 指定设备和次数 -python testing/ops/normal_fight.py --plan examples/plans/normal_fight/7-46SS-all.yaml +python tests/ops/normal_fight.py +python tests/ops/normal_fight.py 127.0.0.1:16384 3 # 指定设备和次数 +python tests/ops/normal_fight.py --plan examples/plans/normal_fight/7-46SS-all.yaml ``` ### campaign 测试 ✅ ```bash -python testing/ops/campaign.py # 困难驱逐 x1 -python testing/ops/campaign.py 127.0.0.1:16384 困难航母 3 # 指定战役和次数 -python testing/ops/campaign.py "" 简单驱逐 2 +python tests/ops/campaign.py # 困难驱逐 x1 +python tests/ops/campaign.py 127.0.0.1:16384 困难航母 3 # 指定战役和次数 +python tests/ops/campaign.py "" 简单驱逐 2 ``` ### expedition 测试 ✅ ```bash -python testing/ops/expedition.py -python testing/ops/expedition.py 127.0.0.1:16384 +python tests/ops/expedition.py +python tests/ops/expedition.py 127.0.0.1:16384 ``` ### decisive 测试 ❌ @@ -70,15 +70,15 @@ python testing/ops/expedition.py 127.0.0.1:16384 ### exercise 测试 ✅ ```bash -python testing/ops/exercise.py --fleet 1 -python testing/ops/exercise.py --fleet 1 --rival 3 +python tests/ops/exercise.py --fleet 1 +python tests/ops/exercise.py --fleet 1 --rival 3 ``` ### build 测试 ❌ ```bash -python testing/ops/build.py -python testing/ops/build.py 127.0.0.1:16384 +python tests/ops/build.py +python tests/ops/build.py 127.0.0.1:16384 ``` ### repair 测试 ❌ @@ -86,8 +86,8 @@ python testing/ops/build.py 127.0.0.1:16384 #### 修复第一艘舰船 ✅ ```bash -python testing/ops/repair.py -python testing/ops/repair.py 127.0.0.1:16384 +python tests/ops/repair.py +python tests/ops/repair.py 127.0.0.1:16384 ``` #### 按照名称修复舰船 ❌ @@ -97,21 +97,21 @@ python testing/ops/repair.py 127.0.0.1:16384 ### destroy 测试 ✅ ```bash -python testing/ops/destroy.py -python testing/ops/destroy.py 127.0.0.1:16384 +python tests/ops/destroy.py +python tests/ops/destroy.py 127.0.0.1:16384 ``` ### cook 测试 ✅ ```bash -python testing/ops/cook.py -python testing/ops/cook.py 127.0.0.1:16384 2 +python tests/ops/cook.py +python tests/ops/cook.py 127.0.0.1:16384 2 ``` ### reward 测试 ✅ ```bash -python testing/ops/reward.py -python testing/ops/reward.py 127.0.0.1:16384 -python testing/ops/reward.py 127.0.0.1:16384 --auto +python tests/ops/reward.py +python tests/ops/reward.py 127.0.0.1:16384 +python tests/ops/reward.py 127.0.0.1:16384 --auto ``` diff --git a/docs/developer/test_pkg.md b/docs/developer/test_pkg.md index 6b005c9c..cb930bfe 100644 --- a/docs/developer/test_pkg.md +++ b/docs/developer/test_pkg.md @@ -4,11 +4,11 @@ `test_pkg/` 是本地私有的视觉/OCR 回归测试集合,**不纳入版本控制**(已在 `.gitignore` 中排除),也不随 PyPI 包发布。 -它与 `testing/` 的分工: +它与 `tests/` 的分工: | 目录 | 作用 | 版本控制 | 框架 | |------|------|----------|------| -| `testing/` | 单元测试、集成测试 | 是 | pytest | +| `tests/` | 单元测试、集成测试 | 是 | pytest | | `test_pkg/` | 视觉/OCR 回归测试(含截图) | 否(本地私有) | 独立脚本 | `test_pkg/` 中的测试图片来自游戏实机截图,体积较大且可能涉及版权,因此不适合入库。 diff --git a/pyproject.toml b/pyproject.toml index 4549fbdc..45ce47c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ dev = [ "pytest-cov", "pytest-xdist", "requests>=2.32.5", + "httpx>=0.28.0", "beautifulsoup4", ] @@ -50,7 +51,7 @@ path = "autowsgr/__init__.py" ignore-words = "docs/spelling_wordlist.txt" [tool.pytest.ini_options] -testpaths = ["testing"] +testpaths = ["tests/unit"] addopts = ["--import-mode=importlib"] [tool.ruff] @@ -171,7 +172,7 @@ extend-safe-fixes = [ "__init__.py" = [ "F401", # unused-import ] -"testing/**" = [ +"tests/**" = [ "T201", # print ] "tools/**" = [ diff --git a/testing/__init__.py b/tests/__init__.py similarity index 100% rename from testing/__init__.py rename to tests/__init__.py diff --git a/testing/conftest.py b/tests/conftest.py similarity index 100% rename from testing/conftest.py rename to tests/conftest.py diff --git a/testing/combat/__init__.py b/tests/manual/__init__.py similarity index 100% rename from testing/combat/__init__.py rename to tests/manual/__init__.py diff --git a/testing/ops/__init__.py b/tests/manual/ops/__init__.py similarity index 100% rename from testing/ops/__init__.py rename to tests/manual/ops/__init__.py diff --git a/testing/ops/_framework.py b/tests/manual/ops/_framework.py similarity index 100% rename from testing/ops/_framework.py rename to tests/manual/ops/_framework.py diff --git a/testing/ops/build.py b/tests/manual/ops/build.py similarity index 85% rename from testing/ops/build.py rename to tests/manual/ops/build.py index 35ec542e..312736ba 100644 --- a/testing/ops/build.py +++ b/tests/manual/ops/build.py @@ -2,8 +2,8 @@ 用法:: - python testing/ops/build.py - python testing/ops/build.py 127.0.0.1:16384 + python tests/manual/ops/build.py + python tests/manual/ops/build.py 127.0.0.1:16384 无页面前置要求 — collect_built_ships() 内部通过 goto_page() 自动导航。 """ @@ -14,17 +14,17 @@ from pathlib import Path -sys.path.insert(0, str(Path(__file__).parent.parent.parent)) +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent)) try: if hasattr(sys.stdout, 'reconfigure'): - sys.stdout.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 - sys.stderr.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 + sys.stdout.reconfigure(encoding='utf-8', errors='replace') # ty: ignore[call-non-callable] + sys.stderr.reconfigure(encoding='utf-8', errors='replace') # ty: ignore[call-non-callable] except Exception: # noqa: S110 pass from loguru import logger -from testing.ops._framework import launch_for_test +from tests.manual.ops._framework import launch_for_test _STEPS = [ diff --git a/testing/ops/campaign.py b/tests/manual/ops/campaign.py similarity index 94% rename from testing/ops/campaign.py rename to tests/manual/ops/campaign.py index 4a5d02ee..3b4049e2 100644 --- a/testing/ops/campaign.py +++ b/tests/manual/ops/campaign.py @@ -5,16 +5,16 @@ 用法:: # 自动检测设备,困难驱逐 x1 - python testing/ops/campaign.py + python tests/manual/ops/campaign.py # 指定设备 serial - python testing/ops/campaign.py 127.0.0.1:16384 + python tests/manual/ops/campaign.py 127.0.0.1:16384 # 指定战役名和次数(serial 留空用自动检测) - python testing/ops/campaign.py "" 困难航母 3 + python tests/manual/ops/campaign.py "" 困难航母 3 # 全参数 - python testing/ops/campaign.py 127.0.0.1:16384 简单驱逐 2 + python tests/manual/ops/campaign.py 127.0.0.1:16384 简单驱逐 2 可选参数:: @@ -36,8 +36,8 @@ # ── UTF-8 输出兼容(Windows 终端)── try: if hasattr(sys.stdout, 'reconfigure'): - sys.stdout.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 - sys.stderr.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 + sys.stdout.reconfigure(encoding='utf-8', errors='replace') # ty: ignore[call-non-callable] + sys.stderr.reconfigure(encoding='utf-8', errors='replace') # ty: ignore[call-non-callable] except Exception: try: if isinstance(sys.stdout, io.TextIOWrapper): @@ -54,7 +54,7 @@ from autowsgr.ops.campaign import CAMPAIGN_NAME_MAP, CampaignRunner from autowsgr.types import ConditionFlag, Formation, RepairMode -from testing.ops._framework import launch_for_test +from tests.manual.ops._framework import launch_for_test # ── 默认值 ────────────────────────────────────────────────────────────────── diff --git a/testing/ops/cook.py b/tests/manual/ops/cook.py similarity index 86% rename from testing/ops/cook.py rename to tests/manual/ops/cook.py index b304a760..d418cbe1 100644 --- a/testing/ops/cook.py +++ b/tests/manual/ops/cook.py @@ -2,8 +2,8 @@ 用法:: - python testing/ops/cook.py - python testing/ops/cook.py 127.0.0.1:16384 + python tests/manual/ops/cook.py + python tests/manual/ops/cook.py 127.0.0.1:16384 无页面前置要求 — cook() 内部通过 goto_page() 自动导航到食堂。 """ @@ -14,17 +14,17 @@ from pathlib import Path -sys.path.insert(0, str(Path(__file__).parent.parent.parent)) +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent)) try: if hasattr(sys.stdout, 'reconfigure'): - sys.stdout.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 - sys.stderr.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 + sys.stdout.reconfigure(encoding='utf-8', errors='replace') # ty: ignore[call-non-callable] + sys.stderr.reconfigure(encoding='utf-8', errors='replace') # ty: ignore[call-non-callable] except Exception: # noqa: S110 pass from loguru import logger -from testing.ops._framework import launch_for_test +from tests.manual.ops._framework import launch_for_test _STEPS = [ diff --git a/testing/ops/decisive_battle.py b/tests/manual/ops/decisive_battle.py similarity index 93% rename from testing/ops/decisive_battle.py rename to tests/manual/ops/decisive_battle.py index 3c29ea85..0476288a 100644 --- a/testing/ops/decisive_battle.py +++ b/tests/manual/ops/decisive_battle.py @@ -2,14 +2,14 @@ 用法:: - python testing/ops/decisive_battle.py - python testing/ops/decisive_battle.py 127.0.0.1:16384 + python tests/manual/ops/decisive_battle.py + python tests/manual/ops/decisive_battle.py 127.0.0.1:16384 # 指定章节 (4-6) 和出击次数 - python testing/ops/decisive_battle.py 127.0.0.1:16384 --chapter 6 --times 1 + python tests/manual/ops/decisive_battle.py 127.0.0.1:16384 --chapter 6 --times 1 # 完整示例 (章节 6, 出击 2 次, 自定义舰船编组) - python testing/ops/decisive_battle.py 127.0.0.1:16384 \\ + python tests/manual/ops/decisive_battle.py 127.0.0.1:16384 \\ --chapter 6 --times 2 \\ --level1 U-1206 U-96 射水鱼 大青花鱼 鹦鹉螺 鲃鱼 \\ --level2 甘比尔湾 平海 \\ @@ -25,20 +25,20 @@ from pathlib import Path -sys.path.insert(0, str(Path(__file__).parent.parent.parent)) +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent)) # ── UTF-8 输出兼容 (Windows 终端) ────────────────────────────────────────── try: if hasattr(sys.stdout, 'reconfigure'): - sys.stdout.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 - sys.stderr.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 + sys.stdout.reconfigure(encoding='utf-8', errors='replace') # ty: ignore[call-non-callable] + sys.stderr.reconfigure(encoding='utf-8', errors='replace') # ty: ignore[call-non-callable] except Exception: # noqa: S110 pass from loguru import logger from autowsgr.infra import DecisiveConfig from autowsgr.ops.decisive import DecisiveController, DecisiveResult -from testing.ops._framework import launch_for_test +from tests.manual.ops._framework import launch_for_test # ── 默认舰船配置 (第 6 章示例) ──────────────────────────────────────────── diff --git a/testing/ops/destroy.py b/tests/manual/ops/destroy.py similarity index 89% rename from testing/ops/destroy.py rename to tests/manual/ops/destroy.py index 4158ad69..89615236 100644 --- a/testing/ops/destroy.py +++ b/tests/manual/ops/destroy.py @@ -3,13 +3,13 @@ 用法:: # 1. 全量解装(不过滤舰种) - python testing/ops/destroy.py [serial] + python tests/manual/ops/destroy.py [serial] # 2. 只解装驱逐 + 轻巡 - python testing/ops/destroy.py [serial] --types DD CL + python tests/manual/ops/destroy.py [serial] --types DD CL # 3. 不卸装备直接解装 - python testing/ops/destroy.py [serial] --no-remove-equip + python tests/manual/ops/destroy.py [serial] --no-remove-equip 无页面前置要求 — destroy_ships() 内部通过 goto_page() 自动导航, 执行完毕后自动返回主页面。 @@ -22,18 +22,18 @@ from pathlib import Path -sys.path.insert(0, str(Path(__file__).parent.parent.parent)) +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent)) try: if hasattr(sys.stdout, 'reconfigure'): - sys.stdout.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 - sys.stderr.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 + sys.stdout.reconfigure(encoding='utf-8', errors='replace') # ty: ignore[call-non-callable] + sys.stderr.reconfigure(encoding='utf-8', errors='replace') # ty: ignore[call-non-callable] except Exception: # noqa: S110 pass from loguru import logger from autowsgr.types import ShipType -from testing.ops._framework import launch_for_test +from tests.manual.ops._framework import launch_for_test def _parse_args() -> argparse.Namespace: diff --git a/testing/ops/event_fight.py b/tests/manual/ops/event_fight.py similarity index 95% rename from testing/ops/event_fight.py rename to tests/manual/ops/event_fight.py index 4ba0e84b..df69b13a 100644 --- a/testing/ops/event_fight.py +++ b/tests/manual/ops/event_fight.py @@ -5,16 +5,16 @@ 用法:: # 自动检测设备,默认打 H3 x 1 次 - python testing/ops/event_fight.py + python tests/manual/ops/event_fight.py # 指定设备 serial - python testing/ops/event_fight.py 127.0.0.1:16384 + python tests/manual/ops/event_fight.py 127.0.0.1:16384 # 指定 map_code 和次数 - python testing/ops/event_fight.py "" H5 3 + python tests/manual/ops/event_fight.py "" H5 3 # 全参数 - python testing/ops/event_fight.py 127.0.0.1:16384 E2 2 + python tests/manual/ops/event_fight.py 127.0.0.1:16384 E2 2 可选参数:: @@ -39,8 +39,8 @@ # ── UTF-8 输出兼容(Windows 终端)── try: if hasattr(sys.stdout, 'reconfigure'): - sys.stdout.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 - sys.stderr.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 + sys.stdout.reconfigure(encoding='utf-8', errors='replace') # ty: ignore[call-non-callable] + sys.stderr.reconfigure(encoding='utf-8', errors='replace') # ty: ignore[call-non-callable] except Exception: try: if isinstance(sys.stdout, io.TextIOWrapper): @@ -58,7 +58,7 @@ from autowsgr.combat import CombatMode, CombatPlan, NodeDecision, RuleEngine from autowsgr.ops.event_fight import EventFightRunner from autowsgr.types import ConditionFlag, FightCondition, Formation, RepairMode -from testing.ops._framework import launch_for_test +from tests.manual.ops._framework import launch_for_test # ── 默认值 ────────────────────────────────────────────────────────────────── @@ -67,7 +67,7 @@ _DEFAULT_FLEET_ID = 1 _DEFAULT_FORMATION = 'single_column' _DEFAULT_PLAN_YAML = str( - Path(__file__).parent.parent.parent + Path(__file__).parent.parent.parent.parent / 'examples' / 'plans' / 'event' diff --git a/testing/ops/exercise.py b/tests/manual/ops/exercise.py similarity index 89% rename from testing/ops/exercise.py rename to tests/manual/ops/exercise.py index 595ecb98..c0c0b73d 100644 --- a/testing/ops/exercise.py +++ b/tests/manual/ops/exercise.py @@ -2,11 +2,11 @@ 用法:: - python testing/ops/exercise.py - python testing/ops/exercise.py 127.0.0.1:16384 + python tests/manual/ops/exercise.py + python tests/manual/ops/exercise.py 127.0.0.1:16384 # 指定舰队和对手 (对手编号 1-5,不传则挑战所有可用对手) - python testing/ops/exercise.py 127.0.0.1:16384 --fleet 2 --rival 3 + python tests/manual/ops/exercise.py 127.0.0.1:16384 --fleet 2 --rival 3 无页面前置要求 — ExerciseRunner 内部通过 goto_page() 自动导航到演习面板。 """ @@ -18,17 +18,17 @@ from pathlib import Path -sys.path.insert(0, str(Path(__file__).parent.parent.parent)) +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent)) try: if hasattr(sys.stdout, 'reconfigure'): - sys.stdout.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 - sys.stderr.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 + sys.stdout.reconfigure(encoding='utf-8', errors='replace') # ty: ignore[call-non-callable] + sys.stderr.reconfigure(encoding='utf-8', errors='replace') # ty: ignore[call-non-callable] except Exception: # noqa: S110 pass from loguru import logger -from testing.ops._framework import launch_for_test +from tests.manual.ops._framework import launch_for_test def _parse_args() -> argparse.Namespace: diff --git a/testing/ops/expedition.py b/tests/manual/ops/expedition.py similarity index 84% rename from testing/ops/expedition.py rename to tests/manual/ops/expedition.py index c523d0e1..7754216c 100644 --- a/testing/ops/expedition.py +++ b/tests/manual/ops/expedition.py @@ -2,8 +2,8 @@ 用法:: - python testing/ops/expedition.py - python testing/ops/expedition.py 127.0.0.1:16384 + python tests/manual/ops/expedition.py + python tests/manual/ops/expedition.py 127.0.0.1:16384 无页面前置要求 — collect_expedition() 内部先 goto_page(MAIN) 再检测远征通知。 """ @@ -14,17 +14,17 @@ from pathlib import Path -sys.path.insert(0, str(Path(__file__).parent.parent.parent)) +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent)) try: if hasattr(sys.stdout, 'reconfigure'): - sys.stdout.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 - sys.stderr.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 + sys.stdout.reconfigure(encoding='utf-8', errors='replace') # ty: ignore[call-non-callable] + sys.stderr.reconfigure(encoding='utf-8', errors='replace') # ty: ignore[call-non-callable] except Exception: # noqa: S110 pass from loguru import logger -from testing.ops._framework import launch_for_test +from tests.manual.ops._framework import launch_for_test _STEPS = [ diff --git a/testing/ops/normal_fight.py b/tests/manual/ops/normal_fight.py similarity index 95% rename from testing/ops/normal_fight.py rename to tests/manual/ops/normal_fight.py index 3c796000..d92a26fa 100644 --- a/testing/ops/normal_fight.py +++ b/tests/manual/ops/normal_fight.py @@ -5,13 +5,13 @@ 用法:: # 自动检测设备,默认 1 次 - python testing/ops/normal_fight.py + python tests/manual/ops/normal_fight.py # 指定设备 serial 和次数 - python testing/ops/normal_fight.py 127.0.0.1:16384 3 + python tests/manual/ops/normal_fight.py 127.0.0.1:16384 3 # 使用自定义 YAML 计划 - python testing/ops/normal_fight.py --plan examples/plans/normal_fight/7-46SS-all.yaml + python tests/manual/ops/normal_fight.py --plan examples/plans/normal_fight/7-46SS-all.yaml 可选参数:: @@ -32,8 +32,8 @@ # ── UTF-8 输出兼容 (Windows 终端) ── try: if hasattr(sys.stdout, 'reconfigure'): - sys.stdout.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 - sys.stderr.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 + sys.stdout.reconfigure(encoding='utf-8', errors='replace') # ty: ignore[call-non-callable] + sys.stderr.reconfigure(encoding='utf-8', errors='replace') # ty: ignore[call-non-callable] except Exception: try: if isinstance(sys.stdout, io.TextIOWrapper): @@ -51,7 +51,7 @@ from autowsgr.combat import CombatMode, CombatPlan, NodeDecision, RuleEngine from autowsgr.ops import NormalFightRunner from autowsgr.types import ConditionFlag, FightCondition, Formation, RepairMode -from testing.ops._framework import launch_for_test +from tests.manual.ops._framework import launch_for_test # ── 默认值 ── diff --git a/testing/ops/repair.py b/tests/manual/ops/repair.py similarity index 84% rename from testing/ops/repair.py rename to tests/manual/ops/repair.py index 0c066ae3..329bd52f 100644 --- a/testing/ops/repair.py +++ b/tests/manual/ops/repair.py @@ -2,8 +2,8 @@ 用法:: - python testing/ops/repair.py - python testing/ops/repair.py 127.0.0.1:16384 + python tests/manual/ops/repair.py + python tests/manual/ops/repair.py 127.0.0.1:16384 无页面前置要求 — repair_in_bath() 内部通过 goto_page() 自动导航到浴室。 """ @@ -14,17 +14,17 @@ from pathlib import Path -sys.path.insert(0, str(Path(__file__).parent.parent.parent)) +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent)) try: if hasattr(sys.stdout, 'reconfigure'): - sys.stdout.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 - sys.stderr.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 + sys.stdout.reconfigure(encoding='utf-8', errors='replace') # ty: ignore[call-non-callable] + sys.stderr.reconfigure(encoding='utf-8', errors='replace') # ty: ignore[call-non-callable] except Exception: # noqa: S110 pass from loguru import logger -from testing.ops._framework import launch_for_test +from tests.manual.ops._framework import launch_for_test _STEPS = [ diff --git a/testing/ops/reward.py b/tests/manual/ops/reward.py similarity index 84% rename from testing/ops/reward.py rename to tests/manual/ops/reward.py index c0bd2748..c3c44ca2 100644 --- a/testing/ops/reward.py +++ b/tests/manual/ops/reward.py @@ -2,8 +2,8 @@ 用法:: - python testing/ops/reward.py - python testing/ops/reward.py 127.0.0.1:16384 + python tests/manual/ops/reward.py + python tests/manual/ops/reward.py 127.0.0.1:16384 无页面前置要求 — collect_rewards() 内部先 goto_page(MAIN) 再检测任务通知。 """ @@ -14,17 +14,17 @@ from pathlib import Path -sys.path.insert(0, str(Path(__file__).parent.parent.parent)) +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent)) try: if hasattr(sys.stdout, 'reconfigure'): - sys.stdout.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 - sys.stderr.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 + sys.stdout.reconfigure(encoding='utf-8', errors='replace') # ty: ignore[call-non-callable] + sys.stderr.reconfigure(encoding='utf-8', errors='replace') # ty: ignore[call-non-callable] except Exception: # noqa: S110 pass from loguru import logger -from testing.ops._framework import launch_for_test +from tests.manual.ops._framework import launch_for_test _STEPS = [ diff --git a/testing/ops/scheduler.py b/tests/manual/ops/scheduler.py similarity index 92% rename from testing/ops/scheduler.py rename to tests/manual/ops/scheduler.py index bbce5255..89e2019e 100644 --- a/testing/ops/scheduler.py +++ b/tests/manual/ops/scheduler.py @@ -3,13 +3,13 @@ 用法:: # 默认: Ex5 H5 三连夜战 x30, 远征间隔 15min - python testing/ops/scheduler.py + python tests/manual/ops/scheduler.py # 指定设备和次数 - python testing/ops/scheduler.py 127.0.0.1:16384 --times 10 + python tests/manual/ops/scheduler.py 127.0.0.1:16384 --times 10 # 使用自定义 YAML - python testing/ops/scheduler.py --plan examples/plans/event/20260212/Ex5A夜战.yaml --times 50 + python tests/manual/ops/scheduler.py --plan examples/plans/event/20260212/Ex5A夜战.yaml --times 50 前置要求: 游戏处于任意正常页面。 """ @@ -25,8 +25,8 @@ # ── UTF-8 输出兼容(Windows 终端)── try: if hasattr(sys.stdout, 'reconfigure'): - sys.stdout.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 - sys.stderr.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 + sys.stdout.reconfigure(encoding='utf-8', errors='replace') # ty: ignore[call-non-callable] + sys.stderr.reconfigure(encoding='utf-8', errors='replace') # ty: ignore[call-non-callable] except Exception: try: if isinstance(sys.stdout, io.TextIOWrapper): @@ -45,12 +45,12 @@ from autowsgr.ops.event_fight import EventFightRunner from autowsgr.scheduler import FightTask, TaskScheduler from autowsgr.types import ConditionFlag -from testing.ops._framework import launch_for_test +from tests.manual.ops._framework import launch_for_test # ── 默认值 ── _DEFAULT_PLAN_YAML = str( - Path(__file__).parent.parent.parent + Path(__file__).parent.parent.parent.parent / 'examples' / 'plans' / 'event' diff --git a/testing/ops/startup.py b/tests/manual/ops/startup.py similarity index 96% rename from testing/ops/startup.py rename to tests/manual/ops/startup.py index e30b092f..6f291a01 100644 --- a/testing/ops/startup.py +++ b/tests/manual/ops/startup.py @@ -5,10 +5,10 @@ 用法:: # 自动检测设备 - python testing/ops/startup.py + python tests/manual/ops/startup.py # 指定设备 serial - python testing/ops/startup.py 127.0.0.1:16384 + python tests/manual/ops/startup.py 127.0.0.1:16384 可选参数:: @@ -30,8 +30,8 @@ # ── UTF-8 输出兼容 (Windows 终端) ── try: if hasattr(sys.stdout, 'reconfigure'): - sys.stdout.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 - sys.stderr.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 + sys.stdout.reconfigure(encoding='utf-8', errors='replace') # ty: ignore[call-non-callable] + sys.stderr.reconfigure(encoding='utf-8', errors='replace') # ty: ignore[call-non-callable] except Exception: try: if isinstance(sys.stdout, io.TextIOWrapper): diff --git a/testing/emulator/__init__.py b/tests/manual/ui/__init__.py similarity index 100% rename from testing/emulator/__init__.py rename to tests/manual/ui/__init__.py diff --git a/testing/ui/_framework.py b/tests/manual/ui/_framework.py similarity index 99% rename from testing/ui/_framework.py rename to tests/manual/ui/_framework.py index 456a14ab..f91a54b8 100644 --- a/testing/ui/_framework.py +++ b/tests/manual/ui/_framework.py @@ -8,7 +8,7 @@ 典型使用:: - from testing.ui._framework import UIControllerTestRunner, parse_e2e_args + from tests.manual.ui._framework import UIControllerTestRunner, parse_e2e_args def run_test(runner: UIControllerTestRunner) -> None: from autowsgr.ui.xxx_page import XxxPage @@ -47,8 +47,8 @@ def run_test(runner: UIControllerTestRunner) -> None: try: if hasattr(sys.stdout, 'reconfigure'): - sys.stdout.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 - sys.stderr.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 + sys.stdout.reconfigure(encoding='utf-8', errors='replace') # ty: ignore[call-non-callable] + sys.stderr.reconfigure(encoding='utf-8', errors='replace') # ty: ignore[call-non-callable] except Exception: try: if isinstance(sys.stdout, io.TextIOWrapper): diff --git a/testing/infra/__init__.py b/tests/manual/ui/backyard_page/__init__.py similarity index 100% rename from testing/infra/__init__.py rename to tests/manual/ui/backyard_page/__init__.py diff --git a/testing/ui/backyard_page/e2e.py b/tests/manual/ui/backyard_page/e2e.py similarity index 94% rename from testing/ui/backyard_page/e2e.py rename to tests/manual/ui/backyard_page/e2e.py index 12cbd81c..bb6fe959 100644 --- a/testing/ui/backyard_page/e2e.py +++ b/tests/manual/ui/backyard_page/e2e.py @@ -2,7 +2,7 @@ 运行方式:: - python testing/ui/backyard_page/e2e.py [serial] [--auto] [--debug] + python tests/manual/ui/backyard_page/e2e.py [serial] [--auto] [--debug] 前置条件: 游戏位于 **后院页面** (主页面 → 🏛 主页图标) @@ -20,11 +20,11 @@ from pathlib import Path -sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent)) +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent)) from typing import TYPE_CHECKING -from testing.ui._framework import ( +from tests.manual.ui._framework import ( UIControllerTestRunner, _make_test_ctx, connect_via_launcher, @@ -149,11 +149,11 @@ def main() -> None: try: run_test(runner) except KeyboardInterrupt: - from testing.ui._framework import warn + from tests.manual.ui._framework import warn warn('用户中断') except Exception as exc: - from testing.ui._framework import fail + from tests.manual.ui._framework import fail fail(f'未预期异常: {exc}') logger.opt(exception=True).error('后院页面 e2e 测试异常') diff --git a/testing/ui/__init__.py b/tests/manual/ui/bath_page/__init__.py similarity index 100% rename from testing/ui/__init__.py rename to tests/manual/ui/bath_page/__init__.py diff --git a/testing/ui/bath_page/e2e.py b/tests/manual/ui/bath_page/e2e.py similarity index 93% rename from testing/ui/bath_page/e2e.py rename to tests/manual/ui/bath_page/e2e.py index 604468e0..bcc994a7 100644 --- a/testing/ui/bath_page/e2e.py +++ b/tests/manual/ui/bath_page/e2e.py @@ -2,7 +2,7 @@ 运行方式:: - python testing/ui/bath_page/e2e.py [serial] [--auto] [--debug] + python tests/manual/ui/bath_page/e2e.py [serial] [--auto] [--debug] 前置条件: 游戏位于 **浴室页面** (后院 → 浴室) @@ -19,11 +19,11 @@ from pathlib import Path -sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent)) +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent)) from typing import TYPE_CHECKING -from testing.ui._framework import ( +from tests.manual.ui._framework import ( UIControllerTestRunner, _make_test_ctx, connect_via_launcher, @@ -113,11 +113,11 @@ def main() -> None: try: run_test(runner) except KeyboardInterrupt: - from testing.ui._framework import warn + from tests.manual.ui._framework import warn warn('用户中断') except Exception as exc: - from testing.ui._framework import fail + from tests.manual.ui._framework import fail fail(f'未预期: {exc}') logger.opt(exception=True).error('浴室页面 e2e 测试异常') diff --git a/testing/ui/backyard_page/__init__.py b/tests/manual/ui/battle_preparation/__init__.py similarity index 100% rename from testing/ui/backyard_page/__init__.py rename to tests/manual/ui/battle_preparation/__init__.py diff --git a/testing/ui/battle_preparation/e2e.py b/tests/manual/ui/battle_preparation/e2e.py similarity index 96% rename from testing/ui/battle_preparation/e2e.py rename to tests/manual/ui/battle_preparation/e2e.py index 523fdc03..84632364 100644 --- a/testing/ui/battle_preparation/e2e.py +++ b/tests/manual/ui/battle_preparation/e2e.py @@ -2,7 +2,7 @@ 运行方式:: - python testing/ui/battle_preparation/e2e.py [serial] [--auto] [--debug] + python tests/manual/ui/battle_preparation/e2e.py [serial] [--auto] [--debug] 前置条件: 游戏位于 **出征准备页面** (地图 → 普通关卡 → 选择关卡后进入) @@ -21,11 +21,11 @@ from pathlib import Path -sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent)) +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent)) from typing import TYPE_CHECKING -from testing.ui._framework import ( +from tests.manual.ui._framework import ( UIControllerTestRunner, _make_test_ctx, connect_via_launcher, @@ -207,11 +207,11 @@ def main() -> None: try: run_test(runner) except KeyboardInterrupt: - from testing.ui._framework import warn + from tests.manual.ui._framework import warn warn('用户中断') except Exception as exc: - from testing.ui._framework import fail + from tests.manual.ui._framework import fail fail(f'未预期: {exc}') logger.opt(exception=True).error('出征准备页面 e2e 测试异常') diff --git a/testing/ui/bath_page/__init__.py b/tests/manual/ui/build_page/__init__.py similarity index 100% rename from testing/ui/bath_page/__init__.py rename to tests/manual/ui/build_page/__init__.py diff --git a/testing/ui/build_page/e2e.py b/tests/manual/ui/build_page/e2e.py similarity index 94% rename from testing/ui/build_page/e2e.py rename to tests/manual/ui/build_page/e2e.py index 8ede40b3..9a9e20e7 100644 --- a/testing/ui/build_page/e2e.py +++ b/tests/manual/ui/build_page/e2e.py @@ -2,7 +2,7 @@ 运行方式:: - python testing/ui/build_page/e2e.py [serial] [--auto] [--debug] + python tests/manual/ui/build_page/e2e.py [serial] [--auto] [--debug] 前置条件: 游戏位于 **建造页面** (侧边栏 → 建造) @@ -19,11 +19,11 @@ from pathlib import Path -sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent)) +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent)) from typing import TYPE_CHECKING -from testing.ui._framework import ( +from tests.manual.ui._framework import ( UIControllerTestRunner, _make_test_ctx, connect_via_launcher, @@ -123,11 +123,11 @@ def main() -> None: try: run_test(runner) except KeyboardInterrupt: - from testing.ui._framework import warn + from tests.manual.ui._framework import warn warn('用户中断') except Exception as exc: - from testing.ui._framework import fail + from tests.manual.ui._framework import fail fail(f'未预期: {exc}') logger.opt(exception=True).error('建造页面 e2e 测试异常') diff --git a/testing/ui/battle_preparation/__init__.py b/tests/manual/ui/canteen_page/__init__.py similarity index 100% rename from testing/ui/battle_preparation/__init__.py rename to tests/manual/ui/canteen_page/__init__.py diff --git a/testing/ui/canteen_page/e2e.py b/tests/manual/ui/canteen_page/e2e.py similarity index 93% rename from testing/ui/canteen_page/e2e.py rename to tests/manual/ui/canteen_page/e2e.py index e100dbc7..820d97df 100644 --- a/testing/ui/canteen_page/e2e.py +++ b/tests/manual/ui/canteen_page/e2e.py @@ -2,7 +2,7 @@ 运行方式:: - python testing/ui/canteen_page/e2e.py [serial] [--auto] [--debug] + python tests/manual/ui/canteen_page/e2e.py [serial] [--auto] [--debug] 前置条件: 游戏位于 **食堂页面** (后院 → 食堂) @@ -18,11 +18,11 @@ from pathlib import Path -sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent)) +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent)) from typing import TYPE_CHECKING -from testing.ui._framework import ( +from tests.manual.ui._framework import ( UIControllerTestRunner, _make_test_ctx, connect_via_launcher, @@ -107,11 +107,11 @@ def main() -> None: try: run_test(runner) except KeyboardInterrupt: - from testing.ui._framework import warn + from tests.manual.ui._framework import warn warn('用户中断') except Exception as exc: - from testing.ui._framework import fail + from tests.manual.ui._framework import fail fail(f'未预期: {exc}') logger.opt(exception=True).error('食堂页面 e2e 测试异常') diff --git a/testing/ui/build_page/__init__.py b/tests/manual/ui/decisive_battle_page/__init__.py similarity index 100% rename from testing/ui/build_page/__init__.py rename to tests/manual/ui/decisive_battle_page/__init__.py diff --git a/testing/ui/decisive_battle_page/e2e.py b/tests/manual/ui/decisive_battle_page/e2e.py similarity index 94% rename from testing/ui/decisive_battle_page/e2e.py rename to tests/manual/ui/decisive_battle_page/e2e.py index f4e94635..945fa4f4 100644 --- a/testing/ui/decisive_battle_page/e2e.py +++ b/tests/manual/ui/decisive_battle_page/e2e.py @@ -2,7 +2,7 @@ 运行方式:: - python testing/ui/decisive_battle_page/e2e.py [serial] [--auto] [--debug] + python tests/manual/ui/decisive_battle_page/e2e.py [serial] [--auto] [--debug] 前置条件: 游戏位于 **决战总览页面** (地图 → 决战面板 → 点击进入) @@ -19,11 +19,11 @@ from pathlib import Path -sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent)) +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent)) from typing import TYPE_CHECKING -from testing.ui._framework import ( +from tests.manual.ui._framework import ( UIControllerTestRunner, _make_test_ctx, connect_via_launcher, @@ -131,11 +131,11 @@ def main() -> None: try: run_test(runner) except KeyboardInterrupt: - from testing.ui._framework import warn + from tests.manual.ui._framework import warn warn('用户中断') except Exception as exc: - from testing.ui._framework import fail + from tests.manual.ui._framework import fail fail(f'未预期: {exc}') logger.opt(exception=True).error('决战页面 e2e 测试异常') diff --git a/testing/ui/canteen_page/__init__.py b/tests/manual/ui/event_page/__init__.py similarity index 100% rename from testing/ui/canteen_page/__init__.py rename to tests/manual/ui/event_page/__init__.py diff --git a/testing/ui/event_page/e2e.py b/tests/manual/ui/event_page/e2e.py similarity index 97% rename from testing/ui/event_page/e2e.py rename to tests/manual/ui/event_page/e2e.py index fa26a76e..4bcd5dcc 100644 --- a/testing/ui/event_page/e2e.py +++ b/tests/manual/ui/event_page/e2e.py @@ -3,13 +3,13 @@ 运行方式:: # 交互模式 (默认) - python testing/ui/event_page/e2e.py + python tests/manual/ui/event_page/e2e.py # 自动执行 - python testing/ui/event_page/e2e.py --auto + python tests/manual/ui/event_page/e2e.py --auto # 指定设备 - python testing/ui/event_page/e2e.py emulator-5554 --auto --debug + python tests/manual/ui/event_page/e2e.py emulator-5554 --auto --debug 前置条件: 游戏位于 **活动地图页面** (主页面 → 活动入口) @@ -40,11 +40,11 @@ from pathlib import Path -sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent)) +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent)) from typing import TYPE_CHECKING -from testing.ui._framework import ( +from tests.manual.ui._framework import ( UIControllerTestRunner, _make_test_ctx, connect_via_launcher, @@ -182,7 +182,7 @@ def run_test(runner: UIControllerTestRunner) -> None: def _try_enter_node(event_page: BaseEventPage, node_id: int) -> None: """尝试选择一个节点。异常时静默处理(不中断测试)。""" try: - event_page._enter_node(node_id) # type: ignore[attr-defined] + event_page._enter_node(node_id) except Exception: # noqa: S110 pass # 节点选择可能因活动状态而失败,不影响测试流程 @@ -244,7 +244,7 @@ def main() -> None: except KeyboardInterrupt: warn('用户中断 (Ctrl+C)') except Exception as exc: - from testing.ui._framework import fail + from tests.manual.ui._framework import fail fail(f'未预期异常: {exc}') logger.opt(exception=True).error('活动地图页面 e2e 测试异常') diff --git a/testing/ui/decisive_battle_page/__init__.py b/tests/manual/ui/friend_page/__init__.py similarity index 100% rename from testing/ui/decisive_battle_page/__init__.py rename to tests/manual/ui/friend_page/__init__.py diff --git a/testing/ui/friend_page/e2e.py b/tests/manual/ui/friend_page/e2e.py similarity index 93% rename from testing/ui/friend_page/e2e.py rename to tests/manual/ui/friend_page/e2e.py index fd8c1cfd..0a109f90 100644 --- a/testing/ui/friend_page/e2e.py +++ b/tests/manual/ui/friend_page/e2e.py @@ -2,7 +2,7 @@ 运行方式:: - python testing/ui/friend_page/e2e.py [serial] [--auto] [--debug] + python tests/manual/ui/friend_page/e2e.py [serial] [--auto] [--debug] 前置条件: 游戏位于 **好友页面** (侧边栏 → 好友) @@ -18,11 +18,11 @@ from pathlib import Path -sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent)) +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent)) from typing import TYPE_CHECKING -from testing.ui._framework import ( +from tests.manual.ui._framework import ( UIControllerTestRunner, _make_test_ctx, connect_via_launcher, @@ -107,11 +107,11 @@ def main() -> None: try: run_test(runner) except KeyboardInterrupt: - from testing.ui._framework import warn + from tests.manual.ui._framework import warn warn('用户中断') except Exception as exc: - from testing.ui._framework import fail + from tests.manual.ui._framework import fail fail(f'未预期: {exc}') logger.opt(exception=True).error('好友页面 e2e 测试异常') diff --git a/testing/ui/event_page/__init__.py b/tests/manual/ui/intensify_page/__init__.py similarity index 100% rename from testing/ui/event_page/__init__.py rename to tests/manual/ui/intensify_page/__init__.py diff --git a/testing/ui/intensify_page/e2e.py b/tests/manual/ui/intensify_page/e2e.py similarity index 94% rename from testing/ui/intensify_page/e2e.py rename to tests/manual/ui/intensify_page/e2e.py index 8e3e7108..28bfc97f 100644 --- a/testing/ui/intensify_page/e2e.py +++ b/tests/manual/ui/intensify_page/e2e.py @@ -2,7 +2,7 @@ 运行方式:: - python testing/ui/intensify_page/e2e.py [serial] [--auto] [--debug] + python tests/manual/ui/intensify_page/e2e.py [serial] [--auto] [--debug] 前置条件: 游戏位于 **强化页面** (侧边栏 → 强化) @@ -19,11 +19,11 @@ from pathlib import Path -sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent)) +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent)) from typing import TYPE_CHECKING -from testing.ui._framework import ( +from tests.manual.ui._framework import ( UIControllerTestRunner, _make_test_ctx, connect_via_launcher, @@ -123,11 +123,11 @@ def main() -> None: try: run_test(runner) except KeyboardInterrupt: - from testing.ui._framework import warn + from tests.manual.ui._framework import warn warn('用户中断') except Exception as exc: - from testing.ui._framework import fail + from tests.manual.ui._framework import fail fail(f'未预期: {exc}') logger.opt(exception=True).error('强化页面 e2e 测试异常') diff --git a/testing/ui/friend_page/__init__.py b/tests/manual/ui/main_page/__init__.py similarity index 100% rename from testing/ui/friend_page/__init__.py rename to tests/manual/ui/main_page/__init__.py diff --git a/testing/ui/main_page/e2e.py b/tests/manual/ui/main_page/e2e.py similarity index 97% rename from testing/ui/main_page/e2e.py rename to tests/manual/ui/main_page/e2e.py index 5e5ce5c7..5cb96752 100644 --- a/testing/ui/main_page/e2e.py +++ b/tests/manual/ui/main_page/e2e.py @@ -3,13 +3,13 @@ 运行方式:: # 交互模式 (默认) - python testing/ui/main_page/e2e.py + python tests/manual/ui/main_page/e2e.py # 自动执行 - python testing/ui/main_page/e2e.py --auto + python tests/manual/ui/main_page/e2e.py --auto # 指定设备 - python testing/ui/main_page/e2e.py emulator-5554 --auto --debug + python tests/manual/ui/main_page/e2e.py emulator-5554 --auto --debug 前置条件: 游戏位于 **主页面** (母港/秘书舰界面) @@ -41,11 +41,11 @@ from pathlib import Path -sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent)) +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent)) from typing import TYPE_CHECKING -from testing.ui._framework import ( +from tests.manual.ui._framework import ( UIControllerTestRunner, connect_via_launcher, ensure_page, @@ -315,7 +315,7 @@ def main() -> None: except KeyboardInterrupt: warn('用户中断 (Ctrl+C)') except Exception as exc: - from testing.ui._framework import fail + from tests.manual.ui._framework import fail fail(f'未预期异常: {exc}') logger.opt(exception=True).error('主页面 e2e 测试异常') diff --git a/testing/ui/intensify_page/__init__.py b/tests/manual/ui/map_page/__init__.py similarity index 100% rename from testing/ui/intensify_page/__init__.py rename to tests/manual/ui/map_page/__init__.py diff --git a/testing/ui/map_page/e2e.py b/tests/manual/ui/map_page/e2e.py similarity index 95% rename from testing/ui/map_page/e2e.py rename to tests/manual/ui/map_page/e2e.py index 1d2672fe..2b44efc8 100644 --- a/testing/ui/map_page/e2e.py +++ b/tests/manual/ui/map_page/e2e.py @@ -2,7 +2,7 @@ 运行方式:: - python testing/ui/map_page/e2e.py [serial] [--auto] [--debug] [--pause 1.5] + python tests/manual/ui/map_page/e2e.py [serial] [--auto] [--debug] [--pause 1.5] 前置条件: 游戏位于 **地图选择页面** (出征面板) @@ -22,11 +22,11 @@ from pathlib import Path -sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent)) +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent)) from typing import TYPE_CHECKING -from testing.ui._framework import ( +from tests.manual.ui._framework import ( UIControllerTestRunner, _make_test_ctx, connect_via_launcher, @@ -157,11 +157,11 @@ def main() -> None: try: run_test(runner) except KeyboardInterrupt: - from testing.ui._framework import warn + from tests.manual.ui._framework import warn warn('用户中断 (Ctrl+C)') except Exception as exc: - from testing.ui._framework import fail + from tests.manual.ui._framework import fail fail(f'未预期异常: {exc}') logger.opt(exception=True).error('地图页面 e2e 测试异常') diff --git a/testing/ui/main_page/__init__.py b/tests/manual/ui/mission_page/__init__.py similarity index 100% rename from testing/ui/main_page/__init__.py rename to tests/manual/ui/mission_page/__init__.py diff --git a/testing/ui/mission_page/e2e.py b/tests/manual/ui/mission_page/e2e.py similarity index 92% rename from testing/ui/mission_page/e2e.py rename to tests/manual/ui/mission_page/e2e.py index ff8d7c88..b764b6d7 100644 --- a/testing/ui/mission_page/e2e.py +++ b/tests/manual/ui/mission_page/e2e.py @@ -2,7 +2,7 @@ 运行方式:: - python testing/ui/mission_page/e2e.py [serial] [--auto] [--debug] + python tests/manual/ui/mission_page/e2e.py [serial] [--auto] [--debug] 前置条件: 游戏位于 **任务页面** (主页面 → 任务) @@ -18,11 +18,11 @@ from pathlib import Path -sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent)) +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent)) from typing import TYPE_CHECKING -from testing.ui._framework import ( +from tests.manual.ui._framework import ( UIControllerTestRunner, _make_test_ctx, connect_via_launcher, @@ -101,11 +101,11 @@ def main() -> None: try: run_test(runner) except KeyboardInterrupt: - from testing.ui._framework import warn + from tests.manual.ui._framework import warn warn('用户中断') except Exception as exc: - from testing.ui._framework import fail + from tests.manual.ui._framework import fail fail(f'未预期: {exc}') logger.opt(exception=True).error('任务页面 e2e 测试异常') diff --git a/testing/ui/run_all_e2e.py b/tests/manual/ui/run_all_e2e.py similarity index 97% rename from testing/ui/run_all_e2e.py rename to tests/manual/ui/run_all_e2e.py index d4438cef..9a9d312b 100755 --- a/testing/ui/run_all_e2e.py +++ b/tests/manual/ui/run_all_e2e.py @@ -3,7 +3,7 @@ 用法:: - python testing/ui/run_all_e2e.py [--serial SERIAL] [--debug] [--pause SECONDS] [--parallel N] [--no-cleanup] + python tests/manual/ui/run_all_e2e.py [--serial SERIAL] [--debug] [--pause SECONDS] [--parallel N] [--no-cleanup] 选项: --serial SERIAL ADB 设备序列号(默认自动检测) @@ -13,7 +13,7 @@ --no-cleanup 不清理日志目录中的旧文件 流程: - 1. 自动搜索 `testing/ui/*/e2e.py` 所有测试脚本 + 1. 自动搜索 `tests/manual/ui/*/e2e.py` 所有测试脚本 2. 按顺序或并行执行,均为 --auto 自动模式 3. 收集每个测试的退出码和输出日志 4. 汇总统计,输出综合报告 @@ -32,8 +32,8 @@ # 处理 Windows GBK 编码兼容性 try: - sys.stdout.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 - sys.stderr.reconfigure(encoding='utf-8', errors='replace') # type: ignore # noqa: PGH003 + sys.stdout.reconfigure(encoding='utf-8', errors='replace') # ty: ignore[call-non-callable] + sys.stderr.reconfigure(encoding='utf-8', errors='replace') # ty: ignore[call-non-callable] except Exception: # noqa: S110 pass # 如果 reconfigure 不可用,继续使用默认编码 from datetime import UTC, datetime diff --git a/testing/ui/map_page/__init__.py b/tests/manual/ui/sidebar_page/__init__.py similarity index 100% rename from testing/ui/map_page/__init__.py rename to tests/manual/ui/sidebar_page/__init__.py diff --git a/testing/ui/sidebar_page/e2e.py b/tests/manual/ui/sidebar_page/e2e.py similarity index 96% rename from testing/ui/sidebar_page/e2e.py rename to tests/manual/ui/sidebar_page/e2e.py index 61b0f451..c8cb5351 100644 --- a/testing/ui/sidebar_page/e2e.py +++ b/tests/manual/ui/sidebar_page/e2e.py @@ -2,7 +2,7 @@ 运行方式:: - python testing/ui/sidebar_page/e2e.py [serial] [--auto] [--debug] + python tests/manual/ui/sidebar_page/e2e.py [serial] [--auto] [--debug] 前置条件: 游戏位于 **侧边栏** (主页面 → ≡ 菜单) @@ -21,11 +21,11 @@ from pathlib import Path -sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent)) +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent)) from typing import TYPE_CHECKING -from testing.ui._framework import ( +from tests.manual.ui._framework import ( UIControllerTestRunner, _make_test_ctx, connect_via_launcher, @@ -194,11 +194,11 @@ def main() -> None: try: run_test(runner) except KeyboardInterrupt: - from testing.ui._framework import warn + from tests.manual.ui._framework import warn warn('用户中断') except Exception as exc: - from testing.ui._framework import fail + from tests.manual.ui._framework import fail fail(f'未预期异常: {exc}') logger.opt(exception=True).error('侧边栏 e2e 测试异常') diff --git a/testing/ui/mission_page/__init__.py b/tests/unit/__init__.py similarity index 100% rename from testing/ui/mission_page/__init__.py rename to tests/unit/__init__.py diff --git a/testing/ui/page/__init__.py b/tests/unit/combat/__init__.py similarity index 100% rename from testing/ui/page/__init__.py rename to tests/unit/combat/__init__.py diff --git a/tests/unit/combat/test_actions.py b/tests/unit/combat/test_actions.py new file mode 100644 index 00000000..11ab303c --- /dev/null +++ b/tests/unit/combat/test_actions.py @@ -0,0 +1,277 @@ +"""Tests for autowsgr.combat.actions.""" + +from __future__ import annotations + +from typing import Any, cast +from unittest.mock import MagicMock, call, patch + +import pytest + +from autowsgr.combat.actions import ( + Coords, + check_blood, + click_enter_fight, + click_fight_condition, + click_formation, + click_image, + click_night_battle, + click_proceed, + click_result, + click_retreat, + click_skip_missile_animation, + click_speed_up, + click_start_march, + dismiss_resource_confirm, + get_enemy_formation, + image_exist, +) +from autowsgr.types import RepairMode, ShipDamageState +from autowsgr.vision.image_template import ImageMatchDetail + + +class TestCheckBlood: + """Tests for check_blood pure logic.""" + + def test_scalar_repair_mode_all_ok(self) -> None: + stats = [ShipDamageState.NORMAL] * 6 + assert check_blood(stats, RepairMode.moderate_damage) is True + + def test_scalar_repair_mode_one_exceeds(self) -> None: + stats = [ShipDamageState.NORMAL] * 5 + [ShipDamageState.MODERATE] + assert check_blood(stats, RepairMode.moderate_damage) is False + + def test_per_slot_list(self) -> None: + stats = [ + ShipDamageState.NORMAL, + ShipDamageState.MODERATE, + ShipDamageState.NORMAL, + ShipDamageState.NORMAL, + ShipDamageState.NORMAL, + ShipDamageState.NORMAL, + ] + rules = [ + RepairMode.severe_damage, + RepairMode.moderate_damage, + RepairMode.severe_damage, + RepairMode.severe_damage, + RepairMode.severe_damage, + RepairMode.severe_damage, + ] + assert check_blood(stats, rules) is False + + def test_no_ship_skipped(self) -> None: + stats = [ShipDamageState.NO_SHIP] * 6 + assert check_blood(stats, RepairMode.moderate_damage) is True + + def test_threshold_boundaries(self) -> None: + # moderate_damage (1): NORMAL (0) ok, MODERATE (1) fail, SEVERE (2) fail + assert check_blood([ShipDamageState.NORMAL], RepairMode.moderate_damage) is True + assert check_blood([ShipDamageState.MODERATE], RepairMode.moderate_damage) is False + assert check_blood([ShipDamageState.SEVERE], RepairMode.moderate_damage) is False + + # severe_damage (2): NORMAL (0) ok, MODERATE (1) ok, SEVERE (2) fail + assert check_blood([ShipDamageState.NORMAL], RepairMode.severe_damage) is True + assert check_blood([ShipDamageState.MODERATE], RepairMode.severe_damage) is True + assert check_blood([ShipDamageState.SEVERE], RepairMode.severe_damage) is False + + +def test_coords_attributes_are_tuples_of_floats() -> None: + for name in dir(Coords): + if name.startswith('_'): + continue + value = getattr(Coords, name) + assert isinstance(value, tuple), f'{name} is not a tuple' + assert len(value) == 2, f'{name} does not have length 2' + assert all(isinstance(v, float) for v in value), f'{name} values are not floats' + + +class TestClickFunctions: + """Tests for click_* helpers.""" + + @pytest.fixture + def device(self) -> MagicMock: + return MagicMock() + + def test_click_start_march(self, device: MagicMock) -> None: + with patch('autowsgr.combat.actions.time.sleep'): + click_start_march(device) + device.click.assert_called_once_with(*Coords.START_MARCH) + + def test_click_retreat(self, device: MagicMock) -> None: + with patch('autowsgr.combat.actions.time.sleep'): + click_retreat(device) + device.click.assert_called_once_with(*Coords.RETREAT) + + def test_click_enter_fight(self, device: MagicMock) -> None: + with patch('autowsgr.combat.actions.time.sleep'): + click_enter_fight(device) + device.click.assert_called_once_with(*Coords.ENTER_FIGHT) + + def test_click_result(self, device: MagicMock) -> None: + with patch('autowsgr.combat.actions.time.sleep'): + click_result(device) + device.click.assert_called_once_with(*Coords.CLICK_RESULT) + + def test_click_skip_missile_animation(self, device: MagicMock) -> None: + with patch('autowsgr.combat.actions.time.sleep'): + click_skip_missile_animation(device) + assert device.click.call_count == 2 + device.click.assert_has_calls( + [call(*Coords.SPEED_UP_BATTLE), call(*Coords.SPEED_UP_BATTLE)] + ) + + def test_click_night_battle_pursue(self, device: MagicMock) -> None: + with patch('autowsgr.combat.actions.time.sleep'): + click_night_battle(device, pursue=True) + device.click.assert_called_once_with(*Coords.NIGHT_YES) + + def test_click_night_battle_retreat(self, device: MagicMock) -> None: + with patch('autowsgr.combat.actions.time.sleep'): + click_night_battle(device, pursue=False) + device.click.assert_called_once_with(*Coords.NIGHT_NO) + + def test_click_proceed_forward(self, device: MagicMock) -> None: + with patch('autowsgr.combat.actions.time.sleep'): + click_proceed(device, go_forward=True) + device.click.assert_called_once_with(*Coords.PROCEED_YES) + + def test_click_proceed_retreat(self, device: MagicMock) -> None: + with patch('autowsgr.combat.actions.time.sleep'): + click_proceed(device, go_forward=False) + device.click.assert_called_once_with(*Coords.PROCEED_NO) + + def test_click_formation(self, device: MagicMock) -> None: + with patch('autowsgr.combat.actions.time.sleep'): + formation = MagicMock() + formation.relative_position = (0.123, 0.456) + click_formation(device, cast('Any', formation)) + device.click.assert_called_once_with(0.123, 0.456) + + def test_click_fight_condition(self, device: MagicMock) -> None: + with patch('autowsgr.combat.actions.time.sleep'): + condition = MagicMock() + condition.relative_click_position = (0.789, 0.321) + click_fight_condition(device, cast('Any', condition)) + device.click.assert_called_once_with(0.789, 0.321) + + def test_click_speed_up_normal(self, device: MagicMock) -> None: + with patch('autowsgr.combat.actions.time.sleep'): + click_speed_up(device, battle_mode=False) + device.click.assert_called_once_with(*Coords.SPEED_UP_NORMAL) + + def test_click_speed_up_battle(self, device: MagicMock) -> None: + with patch('autowsgr.combat.actions.time.sleep'): + click_speed_up(device, battle_mode=True) + device.click.assert_called_once_with(*Coords.SPEED_UP_BATTLE) + + +class TestImageExist: + """Tests for image_exist.""" + + @pytest.fixture + def device(self) -> MagicMock: + return MagicMock() + + @patch('autowsgr.combat.actions.ImageChecker') + def test_image_exist_true(self, mock_checker: MagicMock, device: MagicMock) -> None: + mock_checker.find_any.return_value = MagicMock() + template_key = MagicMock() + template_key.templates = [MagicMock()] + + result = image_exist(device, cast('Any', template_key), confidence=0.8) + assert result is True + device.screenshot.assert_called_once() + mock_checker.find_any.assert_called_once() + + @patch('autowsgr.combat.actions.ImageChecker') + def test_image_exist_false(self, mock_checker: MagicMock, device: MagicMock) -> None: + mock_checker.find_any.return_value = None + template_key = MagicMock() + template_key.templates = [MagicMock()] + + result = image_exist(device, cast('Any', template_key), confidence=0.8) + assert result is False + device.screenshot.assert_called_once() + mock_checker.find_any.assert_called_once() + + +class TestClickImage: + """Tests for click_image.""" + + @pytest.fixture + def device(self) -> MagicMock: + return MagicMock() + + @patch('autowsgr.combat.actions.ImageChecker') + def test_click_image_success(self, mock_checker: MagicMock, device: MagicMock) -> None: + with patch('autowsgr.combat.actions.time.sleep'): + detail = ImageMatchDetail( + template_name='test', + confidence=0.9, + center=(0.5, 0.6), + top_left=(0.4, 0.5), + bottom_right=(0.6, 0.7), + ) + mock_checker.find_any.return_value = detail + template_key = MagicMock() + template_key.templates = [MagicMock()] + + result = click_image(device, cast('Any', template_key), timeout=1.0) + assert result is True + device.screenshot.assert_called_once() + device.click.assert_called_once_with(0.5, 0.6) + + @patch('autowsgr.combat.actions.ImageChecker') + def test_click_image_timeout(self, mock_checker: MagicMock, device: MagicMock) -> None: + with patch('autowsgr.combat.actions.time.sleep'): + mock_checker.find_any.return_value = None + template_key = MagicMock() + template_key.templates = [MagicMock()] + + with patch('autowsgr.combat.actions.time.time', side_effect=[0.0, 0.0, 0.0, 10.0]): + result = click_image(device, cast('Any', template_key), timeout=0.1) + assert result is False + assert device.screenshot.call_count == 2 + assert mock_checker.find_any.call_count == 2 + device.click.assert_not_called() + + +def test_get_enemy_formation_returns_empty_when_no_ocr() -> None: + device = MagicMock() + assert get_enemy_formation(device, cast('Any', None)) == '' + device.screenshot.assert_not_called() + + +class TestDismissResourceConfirm: + """Tests for dismiss_resource_confirm.""" + + @patch('autowsgr.combat.actions.ImageChecker') + def test_no_confirm_found(self, mock_checker: MagicMock) -> None: + with ( + patch('autowsgr.combat.actions.time.sleep'), + patch('autowsgr.combat.actions.Templates'), + ): + mock_checker.find_any.return_value = None + device = MagicMock() + screen = MagicMock() + dismiss_resource_confirm(device, screen) + device.click.assert_not_called() + + @patch('autowsgr.combat.actions.ImageChecker') + def test_confirm_found_and_clicked(self, mock_checker: MagicMock) -> None: + with ( + patch('autowsgr.combat.actions.time.sleep'), + patch('autowsgr.combat.actions.Templates'), + ): + detail = ImageMatchDetail( + template_name='confirm', + confidence=0.8, + center=(0.5, 0.5), + top_left=(0.4, 0.4), + bottom_right=(0.6, 0.6), + ) + mock_checker.find_any.return_value = detail + device = MagicMock() + screen = MagicMock() + dismiss_resource_confirm(device, screen) + device.click.assert_called_once_with(0.5, 0.5) diff --git a/testing/combat/test_combat.py b/tests/unit/combat/test_combat.py similarity index 100% rename from testing/combat/test_combat.py rename to tests/unit/combat/test_combat.py diff --git a/tests/unit/combat/test_engine.py b/tests/unit/combat/test_engine.py new file mode 100644 index 00000000..3e100c83 --- /dev/null +++ b/tests/unit/combat/test_engine.py @@ -0,0 +1,272 @@ +"""测试 autowsgr.combat.engine。""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import numpy as np +import pytest + +from autowsgr.combat.engine import CombatEngine, run_combat +from autowsgr.combat.history import CombatHistory +from autowsgr.combat.plan import CombatMode, CombatPlan +from autowsgr.combat.state import CombatPhase +from autowsgr.types import ConditionFlag, ShipDamageState + + +class TestCombatEngineInit: + """CombatEngine 初始化测试。""" + + def test_init_attributes(self) -> None: + ctx = MagicMock() + ctx.ctrl = MagicMock() + ctx.ocr = None + engine = CombatEngine(ctx=ctx) + assert engine.current_node == '0' + assert engine._phase == CombatPhase.PROCEED + assert engine._plan.name == '' + assert engine._tracker is None + assert isinstance(engine.history, CombatHistory) + + def test_set_node(self) -> None: + ctx = MagicMock() + engine = CombatEngine(ctx=ctx) + engine.set_node('B') + assert engine.current_node == 'B' + + +class TestCombatEngineReset: + """CombatEngine._reset 测试。""" + + def test_reset_clears_state(self) -> None: + ctx = MagicMock() + engine = CombatEngine(ctx=ctx) + engine._node = 'C' + engine._node_count = 3 + engine._last_action = 'yes' + engine._phase = CombatPhase.RESULT + engine._reset() + assert engine.current_node == '0' + assert engine._node_count == 0 + assert engine._last_action == '' + assert engine._phase == CombatPhase.START_FIGHT + + def test_reset_calls_tracker_reset(self) -> None: + ctx = MagicMock() + engine = CombatEngine(ctx=ctx) + engine._tracker = MagicMock() + engine._reset() + engine._tracker.reset.assert_called_once() + + +class TestIsMapRoutingPhase: + """CombatEngine._is_map_routing_phase 测试。""" + + @pytest.fixture + def engine(self) -> CombatEngine: + ctx = MagicMock() + return CombatEngine(ctx=ctx) + + @pytest.mark.parametrize( + 'phase', + [ + CombatPhase.PROCEED, + CombatPhase.FIGHT_CONDITION, + CombatPhase.START_FIGHT, + ], + ) + def test_true_phases(self, engine: CombatEngine, phase: CombatPhase) -> None: + assert engine._is_map_routing_phase(phase) is True + + def test_true_detour(self, engine: CombatEngine) -> None: + engine._last_action = 'detour' + assert engine._is_map_routing_phase(CombatPhase.RESULT) is True + + def test_false(self, engine: CombatEngine) -> None: + engine._last_action = 'yes' + assert engine._is_map_routing_phase(CombatPhase.RESULT) is False + + +class TestGetPollAction: + """CombatEngine._get_poll_action 测试。""" + + @pytest.fixture + def engine(self) -> CombatEngine: + ctx = MagicMock() + return CombatEngine(ctx=ctx) + + def test_fight_period_returns_sleep(self, engine: CombatEngine) -> None: + engine._plan = MagicMock() + engine._plan.mode = CombatMode.NORMAL + engine._phase = CombatPhase.FIGHT_PERIOD + result = engine._get_poll_action(CombatPhase.FIGHT_PERIOD) + assert result is not None + # lambda 应执行 time.sleep(0.5) + result(np.zeros((10, 10, 3), dtype=np.uint8)) + + def test_map_routing_phase_returns_poll_map(self, engine: CombatEngine) -> None: + engine._plan = MagicMock() + engine._plan.mode = CombatMode.NORMAL + engine._phase = CombatPhase.START_FIGHT + engine._tracker = MagicMock() + engine._node = 'A' + result = engine._get_poll_action(CombatPhase.START_FIGHT) + assert result is not None + # 调用应触发 tracker 更新 + screen = MagicMock() + with patch('autowsgr.combat.engine.dismiss_resource_confirm'): + result(screen) + engine._tracker.update_ship_position.assert_called_once_with(screen) + engine._tracker.update_node.assert_called_once() + + def test_map_routing_no_tracker(self, engine: CombatEngine) -> None: + engine._plan = MagicMock() + engine._plan.mode = CombatMode.NORMAL + engine._phase = CombatPhase.START_FIGHT + engine._tracker = None + result = engine._get_poll_action(CombatPhase.START_FIGHT) + assert result is not None + screen = MagicMock() + result(screen) + + def test_single_start_fight_returns_poll_single(self, engine: CombatEngine) -> None: + engine._plan = MagicMock() + engine._plan.mode = CombatMode.EXERCISE + engine._phase = CombatPhase.START_FIGHT + result = engine._get_poll_action(CombatPhase.START_FIGHT) + assert result is not None + screen = MagicMock() + result(screen) + + def test_other_returns_none(self, engine: CombatEngine) -> None: + engine._plan = MagicMock() + engine._plan.mode = CombatMode.NORMAL + engine._phase = CombatPhase.FORMATION + result = engine._get_poll_action(CombatPhase.FORMATION) + assert result is None + + +class TestFightInitialization: + """CombatEngine.fight 初始化逻辑测试。""" + + @pytest.fixture + def engine(self) -> CombatEngine: + ctx = MagicMock() + ctx.ctrl = MagicMock() + ctx.ocr = None + return CombatEngine(ctx=ctx) + + def test_normal_mode_loads_map(self, engine: CombatEngine) -> None: + plan = CombatPlan( + name='test', + mode=CombatMode.NORMAL, + chapter=7, + map_id=1, + ) + with ( + patch( + 'autowsgr.combat.engine.MapNodeData.load', + return_value=MagicMock(__len__=lambda _: 5), + ) as mock_load, + patch.object( + engine, + '_step', + side_effect=[ + ConditionFlag.FIGHT_END, + ], + ), + ): + result = engine.fight(plan, initial_ship_stats=[ShipDamageState.NORMAL] * 6) + mock_load.assert_called_once_with(7, 1) + assert engine._tracker is not None + assert result.node_count == 0 + + def test_normal_mode_missing_map(self, engine: CombatEngine) -> None: + plan = CombatPlan( + name='test', + mode=CombatMode.NORMAL, + chapter=99, + map_id=99, + ) + with ( + patch( + 'autowsgr.combat.engine.MapNodeData.load', + return_value=None, + ), + patch.object( + engine, + '_step', + side_effect=[ + ConditionFlag.FIGHT_END, + ], + ), + ): + engine.fight(plan, initial_ship_stats=None) + assert engine._tracker is None + + def test_event_mode_loads_event_map(self, engine: CombatEngine) -> None: + plan = CombatPlan( + name='test', + mode=CombatMode.EVENT, + event_name='20260212', + chapter='H', + map_id=5, + ) + with ( + patch( + 'autowsgr.combat.engine.MapNodeData.load_event', + return_value=MagicMock(__len__=lambda _: 3), + ) as mock_load, + patch.object( + engine, + '_step', + side_effect=[ + ConditionFlag.FIGHT_END, + ], + ), + ): + engine.fight(plan, initial_ship_stats=None) + mock_load.assert_called_once_with('20260212', 'H', 5) + + def test_battle_mode_no_tracker(self, engine: CombatEngine) -> None: + plan = CombatPlan(name='test', mode=CombatMode.BATTLE) + with patch.object( + engine, + '_step', + side_effect=[ + ConditionFlag.FIGHT_END, + ], + ): + engine.fight(plan, initial_ship_stats=None) + assert engine._tracker is None + + def test_fight_sets_ship_stats(self, engine: CombatEngine) -> None: + plan = CombatPlan(name='test', mode=CombatMode.BATTLE) + stats = [ShipDamageState.SEVERE, ShipDamageState.NORMAL] * 3 + with patch.object( + engine, + '_step', + side_effect=[ + ConditionFlag.FIGHT_END, + ], + ): + result = engine.fight(plan, initial_ship_stats=stats) + assert result.ship_stats == stats + + +class TestRunCombat: + """run_combat 便捷函数测试。""" + + def test_run_combat(self) -> None: + ctx = MagicMock() + ctx.ctrl = MagicMock() + ctx.ocr = None + plan = CombatPlan(name='test', mode=CombatMode.BATTLE) + with patch.object( + CombatEngine, + 'fight', + return_value=MagicMock(flag=ConditionFlag.OPERATION_SUCCESS), + ) as mock_fight: + result = run_combat(ctx, plan, ship_stats=[ShipDamageState.NORMAL] * 6) + mock_fight.assert_called_once() + assert result.flag == ConditionFlag.OPERATION_SUCCESS diff --git a/tests/unit/combat/test_handlers.py b/tests/unit/combat/test_handlers.py new file mode 100644 index 00000000..1d418879 --- /dev/null +++ b/tests/unit/combat/test_handlers.py @@ -0,0 +1,260 @@ +"""测试 autowsgr.combat.handlers 中的决策逻辑。""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any +from unittest.mock import MagicMock, patch + +import pytest + +from autowsgr.combat.handlers import PhaseHandlersMixin +from autowsgr.combat.history import CombatHistory +from autowsgr.combat.state import CombatPhase +from autowsgr.types import ConditionFlag, Formation, ShipDamageState + + +if TYPE_CHECKING: + from autowsgr.combat.plan import NodeDecision + + +class _TestableHandlers(PhaseHandlersMixin): + """可测试的 PhaseHandlersMixin 实现。""" + + _plan: Any # 覆盖 Mixin 的 CombatPlan 类型,以支持 MagicMock 属性 + + def __init__(self) -> None: + self._device = MagicMock() + self._plan = MagicMock() + self._ocr = None + self._node = 'A' + self._last_action = '' + self._ship_stats = [ShipDamageState.NORMAL] * 6 + self._history = CombatHistory() + self._node_count = 0 + self._formation_by_rule = None + + def _get_current_decision(self) -> NodeDecision: + return self._plan.get_node_decision.return_value + + +@pytest.fixture +def handlers() -> _TestableHandlers: + return _TestableHandlers() + + +class TestMakeDecision: + """_make_decision 派发与终止态测试。""" + + def test_known_phase_dispatched(self, handlers: _TestableHandlers) -> None: + handlers._plan.end_phase = CombatPhase.RESULT + with patch.object(handlers, '_handle_result', return_value=ConditionFlag.FIGHT_CONTINUE): + result = handlers._make_decision(CombatPhase.RESULT) + assert result == ConditionFlag.FIGHT_END # 终止态 + + def test_unknown_phase_returns_continue(self, handlers: _TestableHandlers) -> None: + handlers._plan.end_phase = CombatPhase.PROCEED + result = handlers._make_decision(CombatPhase.START_FIGHT) + assert result == ConditionFlag.FIGHT_CONTINUE + + def test_end_phase_adds_history(self, handlers: _TestableHandlers) -> None: + handlers._plan.end_phase = CombatPhase.RESULT + with patch.object(handlers, '_handle_result', return_value=ConditionFlag.FIGHT_CONTINUE): + handlers._make_decision(CombatPhase.RESULT) + assert len(handlers._history.events) == 1 + assert handlers._history.events[0].event_type.name == 'AUTO_RETURN' + + +class TestHandleFightCondition: + """_handle_fight_condition 测试。""" + + def test_sets_last_action(self, handlers: _TestableHandlers) -> None: + handlers._plan.fight_condition = MagicMock(value='优势') + with patch('autowsgr.combat.handlers.click_fight_condition'): + handlers._handle_fight_condition() + assert handlers._last_action == '优势' + + +class TestHandleFormation: + """_handle_formation 纯逻辑分支测试。""" + + def test_not_selected_node_sl(self, handlers: _TestableHandlers) -> None: + handlers._plan.is_selected_node.return_value = False + result = handlers._handle_formation() + assert result == ConditionFlag.SL + assert handlers._history.events[-1].action == 'SL' + + def test_detour_fails_sl(self, handlers: _TestableHandlers) -> None: + handlers._plan.is_selected_node.return_value = True + handlers._last_action = 'detour' + decision = MagicMock() + decision.SL_when_detour_fails = True + decision.formation = Formation.single_column + handlers._plan.get_node_decision.return_value = decision + result = handlers._handle_formation() + assert result == ConditionFlag.SL + + def test_spot_enemy_fails_sl(self, handlers: _TestableHandlers) -> None: + handlers._plan.is_selected_node.return_value = True + handlers._last_action = '' # 不是 fight/detour,表示索敌失败直接到阵型 + decision = MagicMock() + decision.SL_when_detour_fails = False + decision.SL_when_spot_enemy_fails = True + decision.formation = Formation.single_column + decision.formation_when_spot_enemy_fails = None + handlers._plan.get_node_decision.return_value = decision + result = handlers._handle_formation() + assert result == ConditionFlag.SL + + def test_spot_enemy_fails_fallback_formation(self, handlers: _TestableHandlers) -> None: + handlers._plan.is_selected_node.return_value = True + handlers._last_action = 'fight' + decision = MagicMock() + decision.SL_when_detour_fails = False + decision.SL_when_spot_enemy_fails = False + decision.formation = Formation.single_column + decision.formation_when_spot_enemy_fails = Formation.double_column + handlers._plan.get_node_decision.return_value = decision + with patch('autowsgr.combat.handlers.click_formation'): + result = handlers._handle_formation() + assert result == ConditionFlag.FIGHT_CONTINUE + + def test_rule_formation_used(self, handlers: _TestableHandlers) -> None: + handlers._plan.is_selected_node.return_value = True + handlers._last_action = 'fight' + handlers._formation_by_rule = Formation.double_column + decision = MagicMock() + decision.SL_when_detour_fails = False + decision.SL_when_spot_enemy_fails = False + decision.formation = Formation.single_column + decision.formation_when_spot_enemy_fails = None + handlers._plan.get_node_decision.return_value = decision + with patch('autowsgr.combat.handlers.click_formation'): + handlers._handle_formation() + assert handlers._formation_by_rule is None # 已清除 + + +class TestHandleFightPeriod: + """_handle_fight_period 测试。""" + + def test_sl_when_enter_fight(self, handlers: _TestableHandlers) -> None: + decision = MagicMock() + decision.SL_when_enter_fight = True + handlers._plan.get_node_decision.return_value = decision + result = handlers._handle_fight_period() + assert result == ConditionFlag.SL + + def test_continue(self, handlers: _TestableHandlers) -> None: + decision = MagicMock() + decision.SL_when_enter_fight = False + handlers._plan.get_node_decision.return_value = decision + result = handlers._handle_fight_period() + assert result == ConditionFlag.FIGHT_CONTINUE + + +class TestHandleNightPrompt: + """_handle_night_prompt 测试。""" + + def test_pursue(self, handlers: _TestableHandlers) -> None: + decision = MagicMock() + decision.night = True + handlers._plan.get_node_decision.return_value = decision + with patch('autowsgr.combat.handlers.click_night_battle'): + result = handlers._handle_night_prompt() + assert handlers._last_action == 'yes' + assert result == ConditionFlag.FIGHT_CONTINUE + + def test_retreat(self, handlers: _TestableHandlers) -> None: + decision = MagicMock() + decision.night = False + handlers._plan.get_node_decision.return_value = decision + with patch('autowsgr.combat.handlers.click_night_battle'): + result = handlers._handle_night_prompt() + assert handlers._last_action == 'no' + assert result == ConditionFlag.FIGHT_CONTINUE + + +class TestHandleProceed: + """_handle_proceed 测试。""" + + def test_proceed_true(self, handlers: _TestableHandlers) -> None: + decision = MagicMock() + decision.proceed = True + decision.proceed_stop = [ShipDamageState.SEVERE] + handlers._plan.get_node_decision.return_value = decision + with ( + patch('autowsgr.combat.handlers.check_blood', return_value=True), + patch('autowsgr.combat.handlers.click_proceed'), + ): + result = handlers._handle_proceed() + assert handlers._last_action == 'yes' + assert result == ConditionFlag.FIGHT_CONTINUE + + def test_proceed_false(self, handlers: _TestableHandlers) -> None: + decision = MagicMock() + decision.proceed = False + decision.proceed_stop = [] + handlers._plan.get_node_decision.return_value = decision + with ( + patch('autowsgr.combat.handlers.check_blood', return_value=False), + patch('autowsgr.combat.handlers.click_proceed'), + ): + result = handlers._handle_proceed() + assert handlers._last_action == 'no' + assert result == ConditionFlag.FIGHT_END + + def test_blood_check_fails(self, handlers: _TestableHandlers) -> None: + decision = MagicMock() + decision.proceed = True + decision.proceed_stop = [ShipDamageState.SEVERE] + handlers._ship_stats = [ShipDamageState.SEVERE] + [ShipDamageState.NORMAL] * 5 + handlers._plan.get_node_decision.return_value = decision + with ( + patch('autowsgr.combat.handlers.check_blood', return_value=False), + patch('autowsgr.combat.handlers.click_proceed'), + ): + result = handlers._handle_proceed() + assert result == ConditionFlag.FIGHT_END + + +class TestHandleDockFull: + """_handle_dock_full 测试。""" + + def test_returns_dock_full(self, handlers: _TestableHandlers) -> None: + result = handlers._handle_dock_full() + assert result == ConditionFlag.DOCK_FULL + assert handlers._history.events[-1].action == '船坞已满' + + +class TestHandleFlagshipSevereDamage: + """_handle_flagship_severe_damage 测试。""" + + def test_returns_fight_end(self, handlers: _TestableHandlers) -> None: + with patch('autowsgr.combat.handlers.click_image'): + result = handlers._handle_flagship_severe_damage() + assert result == ConditionFlag.FIGHT_END + assert handlers._history.events[-1].event_type.name == 'FLAGSHIP_DAMAGE' + + +class TestHandleMissileAnimation: + """_handle_missile_animation 测试。""" + + def test_returns_continue(self, handlers: _TestableHandlers) -> None: + with patch('autowsgr.combat.handlers.click_skip_missile_animation'): + result = handlers._handle_missile_animation() + assert result == ConditionFlag.FIGHT_CONTINUE + assert handlers._last_action == 'skip_animation' + + +class TestHandleGetShip: + """_handle_get_ship 测试。""" + + def test_with_ship_name(self, handlers: _TestableHandlers) -> None: + with patch('autowsgr.combat.handlers.get_ship_drop', return_value='岛风'): + result = handlers._handle_get_ship() + assert result == ConditionFlag.FIGHT_CONTINUE + assert handlers._history.events[-1].result == '岛风' + + def test_without_ship_name(self, handlers: _TestableHandlers) -> None: + with patch('autowsgr.combat.handlers.get_ship_drop', return_value=''): + handlers._handle_get_ship() + assert handlers._history.events[-1].result == '' diff --git a/tests/unit/combat/test_history.py b/tests/unit/combat/test_history.py new file mode 100644 index 00000000..48f90671 --- /dev/null +++ b/tests/unit/combat/test_history.py @@ -0,0 +1,138 @@ +"""测试 autowsgr.combat.history。""" + +from __future__ import annotations + +import pytest + +from autowsgr.combat.history import ( + CombatEvent, + CombatHistory, + CombatResult, + EventType, + FightResult, +) +from autowsgr.types import ConditionFlag + + +class TestCombatEvent: + """CombatEvent 测试。""" + + def test_str_basic(self) -> None: + event = CombatEvent(event_type=EventType.RESULT, node='A', result='S') + assert '[RESULT]' in str(event) + assert '节点=A' in str(event) + assert '结果=S' in str(event) + + def test_str_minimal(self) -> None: + event = CombatEvent(event_type=EventType.SL) + assert str(event) == '[SL]' + + def test_defaults(self) -> None: + event = CombatEvent(event_type=EventType.FORMATION) + assert event.node == '' + assert event.action == '' + assert event.enemies is None + assert event.extra == {} + + +class TestFightResult: + """FightResult 测试。""" + + def test_str_with_data(self) -> None: + fr = FightResult(mvp=2, grade='S', dropped_ship='岛风') + s = str(fr) + assert 'MVP=2' in s + assert '掉落=岛风' in s + assert '评价=S' in s + + def test_grade_comparison(self) -> None: + s = FightResult(grade='S') + a = FightResult(grade='A') + assert a < s + assert s > a + assert a <= s + assert s >= a + + def test_grade_comparison_with_str(self) -> None: + s = FightResult(grade='S') + assert s > 'A' + assert s >= 'S' + assert s < 'SS' + + def test_grade_comparison_not_implemented(self) -> None: + s = FightResult(grade='S') + with pytest.raises(TypeError): + _ = s < 123 + + def test_defaults(self) -> None: + fr = FightResult() + assert fr.mvp is None + assert fr.grade == '' + assert len(fr.ship_stats) == 6 + + +class TestCombatHistory: + """CombatHistory 测试。""" + + def test_add_and_len(self) -> None: + h = CombatHistory() + h.add(CombatEvent(EventType.RESULT)) + assert len(h) == 1 + + def test_reset(self) -> None: + h = CombatHistory() + h.add(CombatEvent(EventType.RESULT)) + h.reset() + assert len(h) == 0 + + def test_last_node(self) -> None: + h = CombatHistory() + assert h.last_node == '' + h.add(CombatEvent(EventType.RESULT, node='B')) + assert h.last_node == 'B' + + def test_get_fight_results_empty(self) -> None: + h = CombatHistory() + # 空结果时返回空 dict(因为 results_list or results_dict) + assert h.get_fight_results() == {} + + def test_get_fight_results_with_ship_drop(self) -> None: + h = CombatHistory() + h.add(CombatEvent(EventType.RESULT, node='A', result='S')) + h.add(CombatEvent(EventType.GET_SHIP, result='岛风')) + results = h.get_fight_results_list() + assert len(results) == 1 + assert results[0].grade == 'S' + assert results[0].dropped_ship == '岛风' + + def test_get_fight_results_dict_format(self) -> None: + h = CombatHistory() + h.add(CombatEvent(EventType.RESULT, node='A', result='S')) + h.add(CombatEvent(EventType.RESULT, node='B', result='A')) + results = h.get_fight_results() + assert isinstance(results, dict) + assert results['A'].grade == 'S' + assert results['B'].grade == 'A' + + def test_str_repr(self) -> None: + h = CombatHistory() + h.add(CombatEvent(EventType.SL)) + assert repr(h) == 'CombatHistory(1 events)' + assert 'SL' in str(h) + + +class TestCombatResult: + """CombatResult 测试。""" + + def test_defaults(self) -> None: + result = CombatResult() + assert result.flag == ConditionFlag.FIGHT_END + assert result.node_count == 0 + assert result.ship_full is False + + def test_fight_results_property(self) -> None: + result = CombatResult() + result.history.add(CombatEvent(EventType.RESULT, result='S')) + frs = result.fight_results + assert len(frs) == 1 + assert frs[0].grade == 'S' diff --git a/tests/unit/combat/test_node_tracker.py b/tests/unit/combat/test_node_tracker.py new file mode 100644 index 00000000..f837196b --- /dev/null +++ b/tests/unit/combat/test_node_tracker.py @@ -0,0 +1,207 @@ +"""测试 autowsgr.combat.node_tracker。""" + +from __future__ import annotations + +import numpy as np +import pytest + +from autowsgr.combat.node_tracker import ( + MapNodeData, + NodePosition, + NodeTracker, + _euclidean_distance, + _point_to_ray_distance, +) + + +class TestNodePosition: + """NodePosition 数据类测试。""" + + def test_defaults(self) -> None: + pos = NodePosition(name='A', x=0.5, y=0.5) + assert pos.name == 'A' + assert pos.x == 0.5 + assert pos.y == 0.5 + assert pos.next_nodes == [] + + def test_with_next_nodes(self) -> None: + pos = NodePosition(name='A', x=0.1, y=0.2, next_nodes=['B', 'C']) + assert pos.next_nodes == ['B', 'C'] + + +class TestMapNodeData: + """MapNodeData 解析与查询测试。""" + + def test_parse_standard_format(self) -> None: + raw = { + '0': {'position': [0.1, 0.2], 'next': ['A']}, + 'A': {'position': [0.3, 0.4], 'next': ['B']}, + 'B': {'position': [0.5, 0.6], 'next': []}, + } + data = MapNodeData._parse(raw) + assert len(data) == 3 + assert 'A' in data + assert data.get('A') == NodePosition(name='A', x=0.3, y=0.4, next_nodes=['B']) + + def test_node_names_excludes_zero(self) -> None: + raw = { + '0': {'position': [0.0, 0.0], 'next': ['A']}, + 'A': {'position': [0.1, 0.1], 'next': []}, + } + data = MapNodeData._parse(raw) + assert data.node_names == ['A'] + + def test_contains(self) -> None: + data = MapNodeData._parse({'A': {'position': [0.1, 0.2], 'next': []}}) + assert 'A' in data + assert 'Z' not in data + + def test_get_missing_returns_none(self) -> None: + data = MapNodeData._parse({}) + assert data.get('X') is None + + def test_parse_ignores_invalid_value(self) -> None: + raw = {'A': 'invalid'} + data = MapNodeData._parse(raw) + assert 'A' not in data + + +class TestEuclideanDistance: + """欧氏距离计算测试。""" + + def test_zero_distance(self) -> None: + assert _euclidean_distance(1.0, 2.0, 1.0, 2.0) == 0.0 + + def test_3_4_5_triangle(self) -> None: + assert _euclidean_distance(0.0, 0.0, 3.0, 4.0) == 5.0 + + def test_negative_coords(self) -> None: + assert _euclidean_distance(-1.0, -1.0, 2.0, 3.0) == 5.0 + + +class TestPointToRayDistance: + """点到射线距离计算测试。""" + + def test_point_behind_ray(self) -> None: + """点在射线后方,距离退化为到起点距离。""" + dist = _point_to_ray_distance(0.0, 0.0, 1.0, 0.0, 1.0, 0.0) + assert dist == pytest.approx(1.0) + + def test_point_on_ray(self) -> None: + """点在射线上,距离为 0。""" + dist = _point_to_ray_distance(2.0, 0.0, 1.0, 0.0, 1.0, 0.0) + assert dist == pytest.approx(0.0) + + def test_perpendicular_distance(self) -> None: + """点在射线正侧方。""" + dist = _point_to_ray_distance(1.0, 1.0, 0.0, 0.0, 1.0, 0.0) + assert dist == pytest.approx(1.0) + + def test_zero_direction(self) -> None: + """方向向量为零时退化为到起点距离。""" + dist = _point_to_ray_distance(3.0, 4.0, 0.0, 0.0, 0.0, 0.0) + assert dist == pytest.approx(5.0) + + +class TestNodeTracker: + """NodeTracker 节点判定逻辑测试。""" + + @pytest.fixture + def map_data(self) -> MapNodeData: + raw = { + '0': {'position': [0.1, 0.1], 'next': ['A']}, + 'A': {'position': [0.3, 0.3], 'next': ['B', 'C']}, + 'B': {'position': [0.5, 0.3], 'next': []}, + 'C': {'position': [0.3, 0.5], 'next': []}, + } + return MapNodeData._parse(raw) + + def test_init_state(self, map_data: MapNodeData) -> None: + tracker = NodeTracker(map_data) + assert tracker.current_node == '0' + assert tracker.ship_position is None + + def test_reset(self, map_data: MapNodeData) -> None: + tracker = NodeTracker(map_data) + tracker._ship_position = (0.5, 0.5) + tracker._current_node = 'A' + tracker.reset() + assert tracker.current_node == '0' + assert tracker.ship_position is None + + def test_update_node_no_position(self, map_data: MapNodeData) -> None: + tracker = NodeTracker(map_data) + assert tracker.update_node() == '0' + + def test_update_node_no_movement(self, map_data: MapNodeData) -> None: + tracker = NodeTracker(map_data) + tracker._ship_position = (0.3, 0.3) + tracker._last_ship_position = (0.3, 0.3) + assert tracker.update_node() == '0' + + def test_update_node_ray_hit_to_B(self, map_data: MapNodeData) -> None: + tracker = NodeTracker(map_data) + tracker._current_node = 'A' + tracker._ship_position = (0.5, 0.3) + tracker._last_ship_position = (0.3, 0.3) + assert tracker.update_node() == 'B' + + def test_update_node_ray_hit_to_C(self, map_data: MapNodeData) -> None: + tracker = NodeTracker(map_data) + tracker._current_node = 'A' + tracker._ship_position = (0.3, 0.5) + tracker._last_ship_position = (0.3, 0.3) + assert tracker.update_node() == 'C' + + def test_update_node_no_ray_fallback(self, map_data: MapNodeData) -> None: + tracker = NodeTracker(map_data) + tracker._current_node = 'A' + tracker._ship_position = (0.5, 0.3) + tracker._last_ship_position = (0.5, 0.3) + assert tracker.update_node() == 'A' + + def test_update_node_missing_next_nodes(self, map_data: MapNodeData) -> None: + tracker = NodeTracker(map_data) + tracker._current_node = 'B' + tracker._ship_position = (0.9, 0.9) + tracker._last_ship_position = (0.8, 0.8) + assert tracker.update_node() == 'B' + + def test_update_node_no_prev_position(self, map_data: MapNodeData) -> None: + tracker = NodeTracker(map_data) + tracker._current_node = 'A' + tracker._ship_position = (0.5, 0.3) + assert tracker.update_node() == 'A' + + +class TestNodeTrackerYellowCluster: + """NodeTracker 黄色簇检测测试(使用小 ndarray)。""" + + def test_find_yellow_cluster_no_match(self) -> None: + """全黑图像不应匹配任何黄色簇。""" + screen = np.zeros((100, 100, 3), dtype=np.uint8) + result = NodeTracker._find_yellow_cluster(screen) + assert result is None + + def test_find_yellow_cluster_single_blob(self) -> None: + """在图像中心绘制一个足够大的黄色块,应返回中心坐标。""" + screen = np.zeros((100, 100, 3), dtype=np.uint8) + screen[40:60, 40:60] = [230, 210, 100] # RGB, 20x20=400 > _min_area 200 + result = NodeTracker._find_yellow_cluster(screen) + assert result is not None + rx, ry = result + assert 0.45 <= rx <= 0.55 + assert 0.45 <= ry <= 0.55 + + def test_recheck_pixel_true(self) -> None: + screen = np.zeros((100, 100, 3), dtype=np.uint8) + screen[50, 50] = [239, 219, 106] + screen[50, 47] = [231, 222, 101] + screen[50, 53] = [231, 222, 101] + tracker = NodeTracker(MapNodeData._parse({})) + assert tracker._recheck_pixel((0.5, 0.5), screen) is True + + def test_recheck_pixel_false(self) -> None: + screen = np.zeros((100, 100, 3), dtype=np.uint8) + tracker = NodeTracker(MapNodeData._parse({})) + assert tracker._recheck_pixel((0.5, 0.5), screen) is False diff --git a/tests/unit/combat/test_plan.py b/tests/unit/combat/test_plan.py new file mode 100644 index 00000000..70c486cc --- /dev/null +++ b/tests/unit/combat/test_plan.py @@ -0,0 +1,275 @@ +"""测试 autowsgr.combat.plan.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from autowsgr.combat.plan import ( + MODE_CATEGORIES, + MODE_END_PHASES, + MODE_TRANSITIONS, + CombatMode, + CombatPlan, + NodeDecision, + _parse_rule_item, +) +from autowsgr.combat.state import ModeCategory +from autowsgr.types import FightCondition, Formation, RepairMode + + +# ═══════════════════════════════════════════════════════════════════════════════ +# _parse_rule_item +# ═══════════════════════════════════════════════════════════════════════════════ + + +def test_parse_rule_item_with_arrow() -> None: + rule = '(BB >= 2) and (CV > 0) => retreat' + assert _parse_rule_item(rule) == ['(BB >= 2) and (CV > 0)', 'retreat'] + + +def test_parse_rule_item_bare_string() -> None: + rule = '(BB >= 2) and (CV > 0)' + assert _parse_rule_item(rule) == ['(BB >= 2) and (CV > 0)', 'retreat'] + + +def test_parse_rule_item_list_format() -> None: + rule = ['(BB >= 2) and (CV > 0)', 'retreat', 'extra'] + assert _parse_rule_item(rule) == ['(BB >= 2) and (CV > 0)', 'retreat'] + + +def test_parse_rule_item_invalid() -> None: + with pytest.raises(ValueError, match='无法解析规则'): + _parse_rule_item(['only_one']) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# NodeDecision +# ═══════════════════════════════════════════════════════════════════════════════ + + +def test_node_decision_from_dict_minimal() -> None: + decision = NodeDecision.from_dict({}) + assert decision.formation == Formation.double_column + assert decision.night is False + assert decision.proceed is True + assert decision.proceed_stop == RepairMode.severe_damage + assert decision.enemy_rules is None + assert decision.formation_rules is None + assert decision.detour is False + assert decision.long_missile_support is False + assert decision.SL_when_spot_enemy_fails is False + assert decision.SL_when_detour_fails is True + assert decision.SL_when_enter_fight is False + assert decision.formation_when_spot_enemy_fails is None + + +def test_node_decision_from_dict_full() -> None: + data: dict[str, Any] = { + 'formation': 1, + 'night': True, + 'proceed': False, + 'proceed_stop': 1, + 'enemy_rules': ['BB >= 2 => retreat'], + 'enemy_formation_rules': ['单纵阵 => retreat'], + 'detour': True, + 'long_missile_support': True, + 'SL_when_spot_enemy_fails': True, + 'SL_when_detour_fails': False, + 'SL_when_enter_fight': True, + 'formation_when_spot_enemy_fails': 3, + } + decision = NodeDecision.from_dict(data) + assert decision.formation == Formation.single_column + assert decision.night is True + assert decision.proceed is False + assert decision.proceed_stop == RepairMode.moderate_damage + assert decision.enemy_rules is not None + assert decision.formation_rules is not None + assert decision.detour is True + assert decision.long_missile_support is True + assert decision.SL_when_spot_enemy_fails is True + assert decision.SL_when_detour_fails is False + assert decision.SL_when_enter_fight is True + assert decision.formation_when_spot_enemy_fails == Formation.circular + + +# ═══════════════════════════════════════════════════════════════════════════════ +# CombatMode & 派生映射表 +# ═══════════════════════════════════════════════════════════════════════════════ + + +def test_combat_mode_constants() -> None: + assert CombatMode.NORMAL == 'normal' + assert CombatMode.BATTLE == 'battle' + assert CombatMode.EXERCISE == 'exercise' + assert CombatMode.DECISIVE == 'decisive' + assert CombatMode.EVENT == 'event' + + +def test_mode_transitions_cover_all_modes() -> None: + for mode in ( + CombatMode.NORMAL, + CombatMode.BATTLE, + CombatMode.EXERCISE, + CombatMode.DECISIVE, + CombatMode.EVENT, + ): + assert mode in MODE_TRANSITIONS + + +def test_mode_end_phases_cover_all_modes() -> None: + for mode in ( + CombatMode.NORMAL, + CombatMode.BATTLE, + CombatMode.EXERCISE, + CombatMode.DECISIVE, + CombatMode.EVENT, + ): + assert mode in MODE_END_PHASES + + +def test_mode_categories_cover_all_modes() -> None: + for mode in ( + CombatMode.NORMAL, + CombatMode.BATTLE, + CombatMode.EXERCISE, + CombatMode.DECISIVE, + CombatMode.EVENT, + ): + assert mode in MODE_CATEGORIES + if mode in (CombatMode.NORMAL, CombatMode.EVENT): + assert MODE_CATEGORIES[mode] == ModeCategory.MAP + else: + assert MODE_CATEGORIES[mode] == ModeCategory.SINGLE + + +# ═══════════════════════════════════════════════════════════════════════════════ +# CombatPlan +# ═══════════════════════════════════════════════════════════════════════════════ + + +def test_combat_plan_post_init_expands_repair_mode() -> None: + plan = CombatPlan(repair_mode=RepairMode.moderate_damage) + assert isinstance(plan.repair_mode, list) + assert len(plan.repair_mode) == 6 + assert all(r == RepairMode.moderate_damage for r in plan.repair_mode) + + +def test_combat_plan_post_init_keeps_list() -> None: + modes = [RepairMode.severe_damage] * 6 + plan = CombatPlan(repair_mode=modes) + assert plan.repair_mode is modes + + +@pytest.mark.parametrize( + 'mode', + [ + CombatMode.NORMAL, + CombatMode.BATTLE, + CombatMode.EXERCISE, + CombatMode.DECISIVE, + CombatMode.EVENT, + ], +) +def test_combat_plan_transitions_and_end_phase(mode: str) -> None: + plan = CombatPlan(mode=mode) + assert plan.transitions == MODE_TRANSITIONS[mode] + assert plan.end_phase == MODE_END_PHASES[mode] + + +def test_combat_plan_get_node_decision_existing() -> None: + node_a = NodeDecision.from_dict({'night': True}) + default = NodeDecision.from_dict({'night': False}) + plan = CombatPlan(nodes={'A': node_a}, default_node=default) + assert plan.get_node_decision('A').night is True + + +def test_combat_plan_get_node_decision_missing_returns_default() -> None: + default = NodeDecision.from_dict({'night': False}) + plan = CombatPlan(default_node=default) + assert plan.get_node_decision('B').night is False + + +def test_combat_plan_is_selected_node_empty_whitelist() -> None: + plan = CombatPlan(selected_nodes=[]) + assert plan.is_selected_node('A') is True + assert plan.is_selected_node('B') is True + + +def test_combat_plan_is_selected_node_non_empty() -> None: + plan = CombatPlan(selected_nodes=['A', 'C']) + assert plan.is_selected_node('A') is True + assert plan.is_selected_node('B') is False + assert plan.is_selected_node('C') is True + + +def test_combat_plan_from_dict_minimal() -> None: + plan = CombatPlan.from_dict({}) + assert plan.name == '' + assert plan.mode == CombatMode.NORMAL + assert plan.chapter == 1 + assert plan.map_id == 1 + assert plan.fleet_id == 1 + assert plan.fleet is None + assert plan.fight_condition == FightCondition.aim + assert plan.selected_nodes == [] + assert plan.nodes == {} + assert plan.event_name is None + assert isinstance(plan.repair_mode, list) + assert len(plan.repair_mode) == 6 + assert all(r == RepairMode.severe_damage for r in plan.repair_mode) + + +def test_combat_plan_from_dict_with_nodes() -> None: + data: dict[str, Any] = { + 'node_args': { + 'A': {'night': True}, + 'B': {'formation': 1}, + }, + } + plan = CombatPlan.from_dict(data) + assert set(plan.nodes.keys()) == {'A', 'B'} + assert plan.get_node_decision('A').night is True + assert plan.get_node_decision('B').formation == Formation.single_column + + +def test_combat_plan_from_dict_node_defaults_merging() -> None: + data: dict[str, Any] = { + 'node_defaults': {'night': True, 'formation': 1}, + 'node_args': { + 'A': {'formation': 3}, + }, + } + plan = CombatPlan.from_dict(data) + decision = plan.get_node_decision('A') + assert decision.night is True + assert decision.formation == Formation.circular + + +def test_combat_plan_from_dict_selected_nodes_backfill() -> None: + data: dict[str, Any] = { + 'node_defaults': {'night': True}, + 'selected_nodes': ['A', 'B'], + } + plan = CombatPlan.from_dict(data) + assert 'A' in plan.nodes + assert 'B' in plan.nodes + assert plan.get_node_decision('A').night is True + assert plan.get_node_decision('B').night is True + + +def test_combat_plan_from_dict_event_name() -> None: + data: dict[str, Any] = {'event': '20260212'} + plan = CombatPlan.from_dict(data) + assert plan.event_name == '20260212' + + +def test_combat_plan_from_yaml(monkeypatch: pytest.MonkeyPatch) -> None: + data: dict[str, Any] = {'mode': 'battle', 'chapter': 5} + monkeypatch.setattr('autowsgr.combat.plan.load_yaml', lambda _path: data) + plan = CombatPlan.from_yaml('/fake/path/plan.yaml') + assert plan.name == 'plan' + assert plan.mode == CombatMode.BATTLE + assert plan.chapter == 5 diff --git a/tests/unit/combat/test_recognition.py b/tests/unit/combat/test_recognition.py new file mode 100644 index 00000000..4fa6d35d --- /dev/null +++ b/tests/unit/combat/test_recognition.py @@ -0,0 +1,166 @@ +"""测试 autowsgr.combat.recognition。""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import numpy as np +import pytest + +from autowsgr.combat import recognition +from autowsgr.combat.recognition import ( + ShipDropResult, + detect_mvp, + recognize_enemy_formation, + recognize_enemy_ships, + recognize_ship_drop, +) + + +class TestRecognizeEnemyShips: + """recognize_enemy_ships 测试。""" + + def test_fight_mode(self) -> None: + screen = np.zeros((1080, 1920, 3), dtype=np.uint8) + dll = MagicMock() + dll.recognize_enemy.return_value = 'BB CV DD DD DD NO' + with patch('autowsgr.combat.recognition.get_api_dll', return_value=dll): + result = recognize_enemy_ships(screen, mode='fight') + assert result == {'BB': 1, 'CV': 1, 'DD': 3, 'ALL': 5} + + def test_exercise_mode(self) -> None: + screen = np.zeros((1080, 1920, 3), dtype=np.uint8) + dll = MagicMock() + dll.recognize_enemy.return_value = 'NO NO NO BC BC BC' + with patch('autowsgr.combat.recognition.get_api_dll', return_value=dll): + result = recognize_enemy_ships(screen, mode='exercise') + assert result == {'BC': 3, 'ALL': 3} + + def test_invalid_mode(self) -> None: + with pytest.raises(ValueError, match='不支持的模式'): + recognize_enemy_ships(np.zeros((10, 10, 3)), mode='invalid') + + def test_all_no_ship(self) -> None: + screen = np.zeros((1080, 1920, 3), dtype=np.uint8) + dll = MagicMock() + dll.recognize_enemy.return_value = 'NO NO NO NO NO NO' + with patch('autowsgr.combat.recognition.get_api_dll', return_value=dll): + result = recognize_enemy_ships(screen) + assert result == {'ALL': 0} + + +class TestRecognizeEnemyFormation: + """recognize_enemy_formation 测试。""" + + def test_exact_match(self) -> None: + screen = np.zeros((1080, 1920, 3), dtype=np.uint8) + ocr = MagicMock() + ocr.recognize_single.return_value = MagicMock(text='单纵阵') + mock_roi = MagicMock() + mock_roi.crop.return_value = np.zeros((50, 50, 3), dtype=np.uint8) + with patch.object(recognition, '_FORMATION_ROI', mock_roi): + result = recognize_enemy_formation(screen, ocr) + assert result == '单纵阵' + + def test_fuzzy_match(self) -> None: + screen = np.zeros((1080, 1920, 3), dtype=np.uint8) + ocr = MagicMock() + ocr.recognize_single.return_value = MagicMock(text='梯形') + mock_roi = MagicMock() + mock_roi.crop.return_value = np.zeros((50, 50, 3), dtype=np.uint8) + with patch.object(recognition, '_FORMATION_ROI', mock_roi): + result = recognize_enemy_formation(screen, ocr) + assert result == '梯形阵' + + def test_empty_returns_empty(self) -> None: + screen = np.zeros((1080, 1920, 3), dtype=np.uint8) + ocr = MagicMock() + ocr.recognize_single.return_value = MagicMock(text='') + mock_roi = MagicMock() + mock_roi.crop.return_value = np.zeros((50, 50, 3), dtype=np.uint8) + with patch.object(recognition, '_FORMATION_ROI', mock_roi): + result = recognize_enemy_formation(screen, ocr) + assert result == '' + + def test_unknown_text_passes_through(self) -> None: + screen = np.zeros((1080, 1920, 3), dtype=np.uint8) + ocr = MagicMock() + ocr.recognize_single.return_value = MagicMock(text='未知阵型') + mock_roi = MagicMock() + mock_roi.crop.return_value = np.zeros((50, 50, 3), dtype=np.uint8) + with patch.object(recognition, '_FORMATION_ROI', mock_roi): + result = recognize_enemy_formation(screen, ocr) + assert result == '未知阵型' + + +class TestRecognizeShipDrop: + """recognize_ship_drop 测试。""" + + def test_recognize_success(self) -> None: + screen = np.zeros((1080, 1920, 3), dtype=np.uint8) + ocr = MagicMock() + ocr.recognize_ship_name.return_value = '岛风' + ocr.recognize_single.return_value = MagicMock(text='驱逐舰') + with patch( + 'autowsgr.combat.recognition.PixelChecker.crop_rotated', + return_value=np.zeros((30, 100, 3), dtype=np.uint8), + ): + result = recognize_ship_drop(screen, ocr) + assert result == ShipDropResult(ship_name='岛风', ship_type='驱逐舰') + + def test_empty_results(self) -> None: + screen = np.zeros((1080, 1920, 3), dtype=np.uint8) + ocr = MagicMock() + ocr.recognize_ship_name.return_value = None + ocr.recognize_single.return_value = MagicMock(text='') + with patch( + 'autowsgr.combat.recognition.PixelChecker.crop_rotated', + return_value=np.zeros((30, 100, 3), dtype=np.uint8), + ): + result = recognize_ship_drop(screen, ocr) + assert result == ShipDropResult(ship_name=None, ship_type=None) + + +class TestDetectMvp: + """detect_mvp 测试。""" + + def test_no_badge(self) -> None: + screen = np.zeros((1080, 1920, 3), dtype=np.uint8) + with patch( + 'autowsgr.vision.ImageChecker.find_template', + return_value=None, + ): + assert detect_mvp(screen) is None + + def test_slot_1(self) -> None: + screen = np.zeros((1080, 1920, 3), dtype=np.uint8) + detail = MagicMock() + detail.center = (0.5, 142 / 540) + detail.confidence = 0.95 + with patch( + 'autowsgr.vision.ImageChecker.find_template', + return_value=detail, + ): + assert detect_mvp(screen) == 1 + + def test_slot_6(self) -> None: + screen = np.zeros((1080, 1920, 3), dtype=np.uint8) + detail = MagicMock() + detail.center = (0.5, 517 / 540) + detail.confidence = 0.92 + with patch( + 'autowsgr.vision.ImageChecker.find_template', + return_value=detail, + ): + assert detect_mvp(screen) == 6 + + def test_midpoint_slot_3(self) -> None: + screen = np.zeros((1080, 1920, 3), dtype=np.uint8) + detail = MagicMock() + detail.center = (0.5, 292 / 540) + detail.confidence = 0.90 + with patch( + 'autowsgr.vision.ImageChecker.find_template', + return_value=detail, + ): + assert detect_mvp(screen) == 3 diff --git a/tests/unit/combat/test_recognizer.py b/tests/unit/combat/test_recognizer.py new file mode 100644 index 00000000..607d7041 --- /dev/null +++ b/tests/unit/combat/test_recognizer.py @@ -0,0 +1,291 @@ +"""测试 autowsgr.combat.recognizer。""" + +from __future__ import annotations + +from dataclasses import FrozenInstanceError +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import numpy as np +import pytest + +from autowsgr.combat.recognizer import ( + RESULT_GRADE_KEYS, + RESULT_GRADE_TEMPLATES, + CombatRecognitionTimeoutError, + CombatRecognizer, + CombatStopRequestedError, + PhaseSignature, +) +from autowsgr.combat.state import CombatPhase +from autowsgr.image_resources import TemplateKey + + +class TestPhaseSignature: + """PhaseSignature 构造与冻结测试。""" + + def test_construction_with_defaults(self) -> None: + sig = PhaseSignature(template_key=TemplateKey.PROCEED) + assert sig.template_key is TemplateKey.PROCEED + assert sig.default_timeout == 15.0 + assert sig.confidence == 0.8 + assert sig.after_match_delay == 0.0 + assert sig.pixel_signature is None + + def test_frozen_instance(self) -> None: + sig = PhaseSignature(template_key=TemplateKey.PROCEED) + with pytest.raises(FrozenInstanceError): + sig.confidence = 0.5 # ty: ignore[invalid-assignment] + + +class TestGetSignature: + """CombatRecognizer.get_signature 测试。""" + + def test_known_phase(self) -> None: + sig = CombatRecognizer.get_signature(CombatPhase.PROCEED) + assert sig.template_key is TemplateKey.PROCEED + + def test_unknown_phase_returns_default(self) -> None: + with patch( + 'autowsgr.combat.recognizer.PHASE_SIGNATURES', + {}, + ): + sig = CombatRecognizer.get_signature(CombatPhase.PROCEED) + assert sig.template_key is None + assert sig.default_timeout == 10.0 + assert sig.confidence == 0.8 + assert sig.after_match_delay == 0.0 + assert sig.pixel_signature is None + + +class TestMatchTemplate: + """CombatRecognizer._match_template 测试。""" + + def test_find_any_returns_detail(self) -> None: + screen = np.zeros((10, 10, 3), dtype=np.uint8) + detail = MagicMock() + with patch( + 'autowsgr.combat.recognizer.ImageChecker.find_any', + return_value=detail, + ) as mock_find: + result = CombatRecognizer._match_template( + screen, + TemplateKey.PROCEED, + 0.8, + ) + assert result is True + mock_find.assert_called_once_with( + screen, + TemplateKey.PROCEED.templates, + confidence=0.8, + ) + + def test_find_any_returns_none(self) -> None: + screen = np.zeros((10, 10, 3), dtype=np.uint8) + with patch( + 'autowsgr.combat.recognizer.ImageChecker.find_any', + return_value=None, + ) as mock_find: + result = CombatRecognizer._match_template( + screen, + TemplateKey.PROCEED, + 0.8, + ) + assert result is False + mock_find.assert_called_once_with( + screen, + TemplateKey.PROCEED.templates, + confidence=0.8, + ) + + +class TestMatchPixel: + """CombatRecognizer._match_pixel 测试。""" + + def test_check_signature_matched_true(self) -> None: + screen = np.zeros((10, 10, 3), dtype=np.uint8) + pixel_sig = MagicMock() + with patch( + 'autowsgr.combat.recognizer.PixelChecker.check_signature', + return_value=SimpleNamespace(matched=True), + ) as mock_check: + result = CombatRecognizer._match_pixel(screen, pixel_sig) + assert result is True + mock_check.assert_called_once_with(screen, pixel_sig) + + def test_check_signature_matched_false(self) -> None: + screen = np.zeros((10, 10, 3), dtype=np.uint8) + pixel_sig = MagicMock() + with patch( + 'autowsgr.combat.recognizer.PixelChecker.check_signature', + return_value=SimpleNamespace(matched=False), + ) as mock_check: + result = CombatRecognizer._match_pixel(screen, pixel_sig) + assert result is False + mock_check.assert_called_once_with(screen, pixel_sig) + + +class TestMatchPhase: + """CombatRecognizer._match_phase 分支测试。""" + + def test_template_branch(self) -> None: + screen = np.zeros((10, 10, 3), dtype=np.uint8) + sig = PhaseSignature(template_key=TemplateKey.PROCEED) + with ( + patch.object( + CombatRecognizer, + '_match_template', + return_value=True, + ) as mock_template, + patch.object( + CombatRecognizer, + '_match_pixel', + return_value=False, + ) as mock_pixel, + ): + result = CombatRecognizer._match_phase(screen, sig) + assert result is True + mock_template.assert_called_once_with( + screen, + TemplateKey.PROCEED, + 0.8, + ) + mock_pixel.assert_not_called() + + def test_pixel_branch(self) -> None: + screen = np.zeros((10, 10, 3), dtype=np.uint8) + pixel_sig = MagicMock() + sig = PhaseSignature(template_key=None, pixel_signature=pixel_sig) + with ( + patch.object( + CombatRecognizer, + '_match_template', + return_value=True, + ) as mock_template, + patch.object( + CombatRecognizer, + '_match_pixel', + return_value=True, + ) as mock_pixel, + ): + result = CombatRecognizer._match_phase(screen, sig) + assert result is True + mock_template.assert_not_called() + mock_pixel.assert_called_once_with(screen, pixel_sig) + + def test_both_none_returns_false(self) -> None: + screen = np.zeros((10, 10, 3), dtype=np.uint8) + sig = PhaseSignature(template_key=None, pixel_signature=None) + with ( + patch.object( + CombatRecognizer, + '_match_template', + return_value=True, + ) as mock_template, + patch.object( + CombatRecognizer, + '_match_pixel', + return_value=True, + ) as mock_pixel, + ): + result = CombatRecognizer._match_phase(screen, sig) + assert result is False + mock_template.assert_not_called() + mock_pixel.assert_not_called() + + +class TestIdentifyCurrent: + """CombatRecognizer.identify_current 测试。""" + + def test_returns_first_match(self) -> None: + screen = np.zeros((10, 10, 3), dtype=np.uint8) + sig1 = PhaseSignature(template_key=TemplateKey.PROCEED) + sig2 = PhaseSignature(template_key=TemplateKey.RESULT) + with ( + patch.object( + CombatRecognizer, + 'get_signature', + side_effect=[sig1, sig2], + ), + patch.object( + CombatRecognizer, + '_match_phase', + side_effect=[False, True], + ) as mock_match, + ): + result = CombatRecognizer.identify_current( + screen, + [CombatPhase.PROCEED, CombatPhase.RESULT], + ) + assert result is CombatPhase.RESULT + assert mock_match.call_count == 2 + + def test_returns_none_when_no_match(self) -> None: + screen = np.zeros((10, 10, 3), dtype=np.uint8) + sig = PhaseSignature(template_key=TemplateKey.PROCEED) + with ( + patch.object( + CombatRecognizer, + 'get_signature', + return_value=sig, + ), + patch.object( + CombatRecognizer, + '_match_phase', + return_value=False, + ) as mock_match, + ): + result = CombatRecognizer.identify_current( + screen, + [CombatPhase.PROCEED], + ) + assert result is None + assert mock_match.call_count == 1 + + def test_skips_empty_signature(self) -> None: + screen = np.zeros((10, 10, 3), dtype=np.uint8) + empty_sig = PhaseSignature(template_key=None, pixel_signature=None) + normal_sig = PhaseSignature(template_key=TemplateKey.PROCEED) + with ( + patch.object( + CombatRecognizer, + 'get_signature', + side_effect=[empty_sig, normal_sig], + ), + patch.object( + CombatRecognizer, + '_match_phase', + return_value=False, + ) as mock_match, + ): + result = CombatRecognizer.identify_current( + screen, + [CombatPhase.START_FIGHT, CombatPhase.PROCEED], + ) + assert result is None + assert mock_match.call_count == 1 + mock_match.assert_called_once_with(screen, normal_sig) + + +class TestExceptions: + """异常类基本测试。""" + + def test_timeout_error_subclass(self) -> None: + assert issubclass(CombatRecognitionTimeoutError, Exception) + + def test_stop_requested_error_subclass(self) -> None: + assert issubclass(CombatStopRequestedError, Exception) + + +class TestResultGradeKeys: + """RESULT_GRADE_KEYS 测试。""" + + def test_keys(self) -> None: + assert set(RESULT_GRADE_KEYS.keys()) == {'SS', 'S', 'A', 'B', 'C', 'D'} + + +class TestResultGradeTemplates: + """RESULT_GRADE_TEMPLATES 测试。""" + + def test_values_are_strings(self) -> None: + assert all(isinstance(v, str) for v in RESULT_GRADE_TEMPLATES.values()) diff --git a/tests/unit/combat/test_rules.py b/tests/unit/combat/test_rules.py new file mode 100644 index 00000000..93b29e44 --- /dev/null +++ b/tests/unit/combat/test_rules.py @@ -0,0 +1,271 @@ +"""测试 autowsgr.combat.rules.""" + +from __future__ import annotations + +import pytest + +from autowsgr.combat.rules import ( + Condition, + Rule, + RuleAction, + RuleEngine, + RuleResult, + _parse_action_value, + _parse_legacy_condition, +) +from autowsgr.types import Formation + + +# 1. RuleResult values exist + + +def test_rule_result_members() -> None: + assert RuleResult.NO_ACTION is not None + assert RuleResult.RETREAT is not None + assert RuleResult.DETOUR is not None + assert RuleResult.FORMATION is not None + assert len(RuleResult) == 4 + + +# 2. RuleAction factories return correct objects + + +def test_rule_action_no_action() -> None: + action = RuleAction.no_action() + assert action.result == RuleResult.NO_ACTION + assert action.formation is None + + +def test_rule_action_retreat() -> None: + action = RuleAction.retreat() + assert action.result == RuleResult.RETREAT + assert action.formation is None + + +def test_rule_action_detour() -> None: + action = RuleAction.detour() + assert action.result == RuleResult.DETOUR + assert action.formation is None + + +def test_rule_action_set_formation() -> None: + action = RuleAction.set_formation(Formation.wedge) + assert action.result == RuleResult.FORMATION + assert action.formation == Formation.wedge + + +# 3. Condition validates operator, evaluates simple/combined/missing fields, all 6 operators + + +def test_condition_invalid_operator_raises() -> None: + with pytest.raises(ValueError, match='不支持的操作符'): + Condition(field='BB', op='~~', value=2) + + +@pytest.mark.parametrize( + ('op', 'value', 'context', 'expected'), + [ + ('>', 2, {'BB': 3}, True), + ('>', 2, {'BB': 1}, False), + ('>=', 2, {'BB': 2}, True), + ('>=', 2, {'BB': 1}, False), + ('<', 2, {'BB': 1}, True), + ('<', 2, {'BB': 3}, False), + ('<=', 2, {'BB': 2}, True), + ('<=', 2, {'BB': 3}, False), + ('==', 2, {'BB': 2}, True), + ('==', 2, {'BB': 3}, False), + ('!=', 2, {'BB': 3}, True), + ('!=', 2, {'BB': 2}, False), + ], +) +def test_condition_operators(op: str, value: int, context: dict[str, int], expected: bool) -> None: + cond = Condition(field='BB', op=op, value=value) + assert cond.evaluate(context) == expected + + +def test_condition_combined_fields() -> None: + cond = Condition(field='CL+DD', op='>=', value=5) + assert cond.evaluate({'CL': 2, 'DD': 3}) is True + assert cond.evaluate({'CL': 1, 'DD': 3}) is False + + +def test_condition_missing_field_defaults_to_zero() -> None: + cond = Condition(field='CV', op='==', value=0) + assert cond.evaluate({'BB': 2}) is True + + +def test_condition_combined_with_missing_field() -> None: + cond = Condition(field='BB+CV', op='==', value=2) + assert cond.evaluate({'BB': 2}) is True + + +# 4. Rule AND semantics + + +def test_rule_and_semantics_all_match() -> None: + rule = Rule( + conditions=[ + Condition(field='BB', op='>=', value=2), + Condition(field='CV', op='>', value=0), + ], + action=RuleAction.retreat(), + ) + assert rule.evaluate({'BB': 2, 'CV': 1}) is True + + +def test_rule_and_semantics_partial_match() -> None: + rule = Rule( + conditions=[ + Condition(field='BB', op='>=', value=2), + Condition(field='CV', op='>', value=0), + ], + action=RuleAction.retreat(), + ) + assert rule.evaluate({'BB': 2, 'CV': 0}) is False + assert rule.evaluate({'BB': 1, 'CV': 1}) is False + + +# 5. RuleEngine first-match, default action + + +def test_rule_engine_first_match_priority() -> None: + engine = RuleEngine( + rules=[ + Rule( + conditions=[Condition(field='BB', op='>=', value=3)], + action=RuleAction.retreat(), + ), + Rule( + conditions=[Condition(field='BB', op='>=', value=2)], + action=RuleAction.detour(), + ), + ], + default=RuleAction.no_action(), + ) + assert engine.evaluate({'BB': 3}) == RuleAction.retreat() + assert engine.evaluate({'BB': 2}) == RuleAction.detour() + + +def test_rule_engine_default_action() -> None: + engine = RuleEngine( + rules=[ + Rule( + conditions=[Condition(field='SS', op='>=', value=3)], + action=RuleAction.retreat(), + ), + ], + default=RuleAction.no_action(), + ) + assert engine.evaluate({'BB': 1}) == RuleAction.no_action() + + +# 6. _parse_legacy_condition + + +def test_parse_legacy_condition_basic() -> None: + conds = _parse_legacy_condition('(BB >= 2) and (CV > 0)') + assert len(conds) == 2 + assert conds[0] == Condition(field='BB', op='>=', value=2) + assert conds[1] == Condition(field='CV', op='>', value=0) + + +def test_parse_legacy_condition_combined_fields() -> None: + conds = _parse_legacy_condition('CL + DD >= 1') + assert len(conds) == 1 + assert conds[0] == Condition(field='CL+DD', op='>=', value=1) + + +def test_parse_legacy_condition_decimal() -> None: + conds = _parse_legacy_condition('BB >= 2.5') + assert len(conds) == 1 + assert conds[0] == Condition(field='BB', op='>=', value=2.5) + assert isinstance(conds[0].value, float) + + +def test_parse_legacy_condition_malformed_raises() -> None: + with pytest.raises(ValueError, match='无法解析规则条件'): + _parse_legacy_condition('not a condition') + + +# 7. _parse_action_value + + +def test_parse_action_value_retreat() -> None: + assert _parse_action_value('retreat') == RuleAction.retreat() + + +def test_parse_action_value_detour() -> None: + assert _parse_action_value('detour') == RuleAction.detour() + + +def test_parse_action_value_int_formation() -> None: + action = _parse_action_value(4) + assert action == RuleAction.set_formation(Formation.wedge) + + +def test_parse_action_value_string_number_fallback() -> None: + action = _parse_action_value('4') + assert action == RuleAction.set_formation(Formation.wedge) + + +def test_parse_action_value_invalid_raises() -> None: + with pytest.raises(ValueError, match='无法识别的动作值'): + _parse_action_value('flyaway') + + +# 8. from_legacy_rules end-to-end + + +def test_from_legacy_rules_end_to_end() -> None: + engine = RuleEngine.from_legacy_rules( + [ + ['(BB >= 2) and (CV > 0)', 'retreat'], + ['(SS >= 3)', 4], + ] + ) + assert len(engine.rules) == 2 + + assert engine.evaluate({'BB': 3, 'CV': 1}) == RuleAction.retreat() + assert engine.evaluate({'BB': 2, 'CV': 0}) == RuleAction.no_action() + + action = engine.evaluate({'SS': 3}) + assert action == RuleAction.set_formation(Formation.wedge) + + +# 9. from_formation_rules end-to-end + + +def test_from_formation_rules_end_to_end() -> None: + engine = RuleEngine.from_formation_rules( + [ + ['单纵阵', 'retreat'], + ['复纵阵', 4], + ] + ) + assert len(engine.rules) == 2 + + assert engine.evaluate_formation('单纵阵') == RuleAction.retreat() + assert engine.evaluate_formation('复纵阵') == RuleAction.set_formation(Formation.wedge) + assert engine.evaluate_formation('轮型阵') == RuleAction.no_action() + + +# 10. evaluate_formation with matching/non-matching formation + + +def test_evaluate_formation_matching() -> None: + engine = RuleEngine.from_formation_rules( + [ + ['梯形阵', 'detour'], + ] + ) + assert engine.evaluate_formation('梯形阵') == RuleAction.detour() + + +def test_evaluate_formation_non_matching() -> None: + engine = RuleEngine.from_formation_rules( + [ + ['梯形阵', 'detour'], + ] + ) + assert engine.evaluate_formation('单横阵') == RuleAction.no_action() diff --git a/tests/unit/combat/test_state.py b/tests/unit/combat/test_state.py new file mode 100644 index 00000000..2b6c7b16 --- /dev/null +++ b/tests/unit/combat/test_state.py @@ -0,0 +1,88 @@ +"""测试 autowsgr.combat.state。""" + +from __future__ import annotations + +import pytest + +from autowsgr.combat.state import ( + CombatPhase, + ModeCategory, + build_transitions, + resolve_successors, +) + + +class TestCombatPhase: + """CombatPhase 枚举测试。""" + + def test_phase_values_distinct(self) -> None: + values = list(CombatPhase) + assert len(values) == len(set(v.value for v in values)) + + def test_key_phases_exist(self) -> None: + assert CombatPhase.START_FIGHT.name == 'START_FIGHT' + assert CombatPhase.RESULT.name == 'RESULT' + assert CombatPhase.PROCEED.name == 'PROCEED' + + +class TestBuildTransitions: + """build_transitions 测试。""" + + def test_map_category_returns_dict(self) -> None: + result = build_transitions(ModeCategory.MAP, CombatPhase.MAP_PAGE) + assert isinstance(result, dict) + assert CombatPhase.START_FIGHT in result + + def test_single_category_returns_dict(self) -> None: + result = build_transitions(ModeCategory.SINGLE, None) + assert isinstance(result, dict) + assert CombatPhase.START_FIGHT in result + + def test_map_proceed_branch(self) -> None: + t = build_transitions(ModeCategory.MAP, CombatPhase.MAP_PAGE) + proceed = t[CombatPhase.PROCEED] + assert isinstance(proceed, dict) + assert 'yes' in proceed + assert 'no' in proceed + + def test_map_spot_enemy_branch(self) -> None: + t = build_transitions(ModeCategory.MAP, CombatPhase.MAP_PAGE) + spot = t[CombatPhase.SPOT_ENEMY_SUCCESS] + assert isinstance(spot, dict) + assert 'fight' in spot + assert 'detour' in spot + assert 'retreat' in spot + + def test_single_no_end_page_result_termination(self) -> None: + t = build_transitions(ModeCategory.SINGLE, None) + assert CombatPhase.RESULT not in t or t[CombatPhase.RESULT] == [] + + def test_single_with_end_page(self) -> None: + t = build_transitions(ModeCategory.SINGLE, CombatPhase.EXERCISE_PAGE) + assert CombatPhase.RESULT in t + assert CombatPhase.EXERCISE_PAGE in t[CombatPhase.RESULT] + + +class TestResolveSuccessors: + """resolve_successors 测试。""" + + def test_list_branch(self) -> None: + t = build_transitions(ModeCategory.SINGLE, None) + result = resolve_successors(t, CombatPhase.FORMATION, 'any') + assert CombatPhase.FIGHT_PERIOD in result + + def test_dict_branch_known_action(self) -> None: + t = build_transitions(ModeCategory.MAP, CombatPhase.MAP_PAGE) + result = resolve_successors(t, CombatPhase.PROCEED, 'yes') + assert CombatPhase.FIGHT_CONDITION in result + + def test_dict_branch_unknown_action_fallback(self) -> None: + t = build_transitions(ModeCategory.MAP, CombatPhase.MAP_PAGE) + result = resolve_successors(t, CombatPhase.PROCEED, 'unknown') + # fallback 到第一个分支值 + assert len(result) > 0 + + def test_key_error_missing_phase(self) -> None: + t = build_transitions(ModeCategory.SINGLE, None) + with pytest.raises(KeyError): + resolve_successors(t, CombatPhase.MAP_PAGE, '') diff --git a/testing/ui/sidebar_page/__init__.py b/tests/unit/constants/__init__.py similarity index 100% rename from testing/ui/sidebar_page/__init__.py rename to tests/unit/constants/__init__.py diff --git a/tests/unit/constants/test_shipnames.py b/tests/unit/constants/test_shipnames.py new file mode 100644 index 00000000..14521a17 --- /dev/null +++ b/tests/unit/constants/test_shipnames.py @@ -0,0 +1,81 @@ +"""测试 autowsgr.constants.shipnames。""" + +from __future__ import annotations + +import copy + +from autowsgr.constants.shipnames import ( + DECISIVE_SKILL_NAMES, + SHIPNAMES, + process_dict, + update_shipnames, +) + + +def test_process_dict_nested_lists() -> None: + """process_dict 应正确展平嵌套列表值。""" + d = {'a': ['x', 'y'], 'b': ['z']} + assert process_dict(d) == ['x', 'y', 'z'] + + +def test_process_dict_empty_dict() -> None: + """process_dict 对空字典应返回空列表。""" + assert process_dict({}) == [] + + +def test_process_dict_single_key() -> None: + """process_dict 对单键字典应返回其值列表。""" + d = {'ships': ['ship_a', 'ship_b', 'ship_c']} + assert process_dict(d) == ['ship_a', 'ship_b', 'ship_c'] + + +def test_decisive_skill_names_values() -> None: + """DECISIVE_SKILL_NAMES 应包含预期值。""" + assert DECISIVE_SKILL_NAMES == ['长跑训练', '肌肉记忆', '黑科技'] + + +def test_shipnames_is_non_empty_list_of_strings() -> None: + """SHIPNAMES 应为非空字符串列表。""" + assert isinstance(SHIPNAMES, list) + assert len(SHIPNAMES) > 0 + assert all(isinstance(name, str) for name in SHIPNAMES) + + +class TestUpdateShipnames: + """update_shipnames 测试(涉及全局状态,需隔离)。""" + + def setup_method(self) -> None: + self._backup = copy.deepcopy(SHIPNAMES) + + def teardown_method(self) -> None: + SHIPNAMES[:] = self._backup + + def test_prepend_order(self) -> None: + """新名称应按顺序前置到 SHIPNAMES 前端。""" + original_len = len(SHIPNAMES) + update_shipnames(['__test_new_a__', '__test_new_b__']) + assert SHIPNAMES[:2] == ['__test_new_a__', '__test_new_b__'] + assert len(SHIPNAMES) == original_len + 2 + + def test_deduplication(self) -> None: + """重复名称不应被再次添加。""" + original_first = SHIPNAMES[0] + original_len = len(SHIPNAMES) + update_shipnames([original_first, '__test_unique__']) + assert SHIPNAMES[0] == '__test_unique__' + assert SHIPNAMES[1] == original_first + assert len(SHIPNAMES) == original_len + 1 + + def test_empty_list(self) -> None: + """传入空列表不应改变 SHIPNAMES。""" + original = copy.deepcopy(SHIPNAMES) + update_shipnames([]) + assert original == SHIPNAMES + + def test_idempotency(self) -> None: + """相同额外列表多次调用结果应一致(不重复添加)。""" + extra = ['__test_idem_a__', '__test_idem_b__'] + update_shipnames(extra) + first_state = copy.deepcopy(SHIPNAMES) + update_shipnames(extra) + assert first_state == SHIPNAMES diff --git a/testing/vision/__init__.py b/tests/unit/context/__init__.py similarity index 100% rename from testing/vision/__init__.py rename to tests/unit/context/__init__.py diff --git a/tests/unit/context/test_build.py b/tests/unit/context/test_build.py new file mode 100644 index 00000000..f5b34871 --- /dev/null +++ b/tests/unit/context/test_build.py @@ -0,0 +1,50 @@ +"""测试 autowsgr.context.build。""" + +from __future__ import annotations + +from autowsgr.context.build import BuildQueue, BuildSlot + + +def test_build_slot_defaults() -> None: + """BuildSlot 默认值应为空闲。""" + slot = BuildSlot() + assert slot.occupied is False + assert slot.remaining_seconds == 0 + assert slot.is_idle is True + assert slot.is_complete is False + + +def test_build_slot_complete() -> None: + """occupied 且 remaining_seconds <= 0 时应为完成。""" + slot = BuildSlot(occupied=True, remaining_seconds=0) + assert slot.is_complete is True + assert slot.is_idle is False + + +def test_build_slot_in_progress() -> None: + """occupied 且 remaining_seconds > 0 时不应完成。""" + slot = BuildSlot(occupied=True, remaining_seconds=120) + assert slot.is_complete is False + assert slot.is_idle is False + + +def test_build_queue_defaults() -> None: + """BuildQueue 默认应有 4 个空闲槽位。""" + queue = BuildQueue() + assert len(queue.slots) == 4 + assert queue.idle_count == 4 + assert queue.complete_count == 0 + + +def test_build_queue_mixed_state() -> None: + """混合状态槽位统计应正确。""" + queue = BuildQueue( + slots=[ + BuildSlot(occupied=True, remaining_seconds=0), + BuildSlot(occupied=True, remaining_seconds=60), + BuildSlot(), + BuildSlot(), + ], + ) + assert queue.idle_count == 2 + assert queue.complete_count == 1 diff --git a/tests/unit/context/test_equipment.py b/tests/unit/context/test_equipment.py new file mode 100644 index 00000000..8b8d80f0 --- /dev/null +++ b/tests/unit/context/test_equipment.py @@ -0,0 +1,24 @@ +"""测试 autowsgr.context.equipment。""" + +from __future__ import annotations + +from autowsgr.context.equipment import Equipment + + +def test_equipment_defaults() -> None: + """Equipment 默认值应为空名称且未锁定。""" + eq = Equipment() + assert eq.name == '' + assert eq.locked is False + + +def test_equipment_with_name() -> None: + """应能正确存储装备名称。""" + eq = Equipment(name='12.7cm连装炮') + assert eq.name == '12.7cm连装炮' + + +def test_equipment_locked() -> None: + """锁定状态应被正确记录。""" + eq = Equipment(name='61cm四连装鱼雷', locked=True) + assert eq.locked is True diff --git a/tests/unit/context/test_expedition.py b/tests/unit/context/test_expedition.py new file mode 100644 index 00000000..2ed4797c --- /dev/null +++ b/tests/unit/context/test_expedition.py @@ -0,0 +1,46 @@ +"""测试 autowsgr.context.expedition。""" + +from __future__ import annotations + +from autowsgr.context.expedition import Expedition, ExpeditionQueue +from autowsgr.context.fleet import Fleet + + +def test_expedition_defaults() -> None: + """Expedition 默认值应为非激活状态。""" + exp = Expedition() + assert exp.chapter == 0 + assert exp.node == 0 + assert exp.fleet is None + assert exp.is_active is False + + +def test_expedition_active_with_fleet() -> None: + """绑定舰队后应为激活状态。""" + fleet = Fleet(fleet_id=2) + exp = Expedition(chapter=3, node=2, fleet=fleet) + assert exp.is_active is True + assert exp.fleet is not None + assert exp.fleet.fleet_id == 2 + + +def test_expedition_queue_defaults() -> None: + """ExpeditionQueue 默认应有 4 个空闲槽位。""" + queue = ExpeditionQueue() + assert len(queue.expeditions) == 4 + assert queue.active_count == 0 + assert queue.idle_count == 4 + + +def test_expedition_queue_mixed_state() -> None: + """混合状态远征统计应正确。""" + queue = ExpeditionQueue( + expeditions=[ + Expedition(fleet=Fleet(fleet_id=1)), + Expedition(), + Expedition(fleet=Fleet(fleet_id=3)), + Expedition(), + ], + ) + assert queue.active_count == 2 + assert queue.idle_count == 2 diff --git a/tests/unit/context/test_fleet.py b/tests/unit/context/test_fleet.py new file mode 100644 index 00000000..2945cb20 --- /dev/null +++ b/tests/unit/context/test_fleet.py @@ -0,0 +1,54 @@ +"""测试 autowsgr.context.fleet。""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from autowsgr.context.fleet import Fleet +from autowsgr.types import RepairMode, ShipDamageState + + +def test_fleet_defaults() -> None: + """Fleet 默认应为空且编号为 1。""" + fleet = Fleet() + assert fleet.fleet_id == 1 + assert fleet.size == 0 + assert fleet.has_severely_damaged is False + assert fleet.damage_states == [] + + +def test_fleet_size_and_damage_states() -> None: + """size 和 damage_states 应反映成员状态。""" + s1 = MagicMock(damage_state=ShipDamageState.NORMAL) + s2 = MagicMock(damage_state=ShipDamageState.MODERATE) + fleet = Fleet(fleet_id=2, ships=[s1, s2]) + assert fleet.size == 2 + assert fleet.damage_states == [ShipDamageState.NORMAL, ShipDamageState.MODERATE] + assert fleet.has_severely_damaged is False + + +def test_fleet_has_severely_damaged() -> None: + """有大破成员时应返回 True。""" + s1 = MagicMock(damage_state=ShipDamageState.NORMAL) + s2 = MagicMock(damage_state=ShipDamageState.SEVERE) + fleet = Fleet(ships=[s1, s2]) + assert fleet.has_severely_damaged is True + + +def test_fleet_needs_repair() -> None: + """needs_repair 应按策略正确判断。""" + s1 = MagicMock() + s1.needs_repair.return_value = False + s2 = MagicMock() + s2.needs_repair.return_value = True + fleet = Fleet(ships=[s1, s2]) + assert fleet.needs_repair(RepairMode.moderate_damage) is True + s2.needs_repair.assert_called_once_with(RepairMode.moderate_damage) + + +def test_fleet_no_repair_needed() -> None: + """所有成员均不需要修理时应返回 False。""" + s1 = MagicMock(needs_repair=lambda _m: False) + s2 = MagicMock(needs_repair=lambda _m: False) + fleet = Fleet(ships=[s1, s2]) + assert fleet.needs_repair(RepairMode.severe_damage) is False diff --git a/tests/unit/context/test_game_context.py b/tests/unit/context/test_game_context.py new file mode 100644 index 00000000..00c84401 --- /dev/null +++ b/tests/unit/context/test_game_context.py @@ -0,0 +1,88 @@ +"""测试 autowsgr.context.game_context。""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from autowsgr.context.game_context import GameContext +from autowsgr.context.ship import Ship +from autowsgr.types import ConditionFlag, ShipDamageState + + +def _make_ctx() -> GameContext: + """构造一个使用 Mock 基础设施的 GameContext。""" + return GameContext(ctrl=MagicMock(), config=MagicMock()) + + +def test_fleet_accessor_valid() -> None: + """fleet() 应返回对应编号的舰队。""" + ctx = _make_ctx() + assert ctx.fleet(1).fleet_id == 1 + assert ctx.fleet(4).fleet_id == 4 + + +def test_fleet_accessor_invalid() -> None: + """fleet() 传入非法编号应抛出 ValueError。""" + ctx = _make_ctx() + with pytest.raises(ValueError, match='fleet_id 应在 1-4 范围内'): + ctx.fleet(0) + with pytest.raises(ValueError, match='fleet_id 应在 1-4 范围内'): + ctx.fleet(5) + + +def test_get_ship_auto_register() -> None: + """get_ship 应自动注册不存在的舰船。""" + ctx = _make_ctx() + ship = ctx.get_ship('俾斯麦') + assert ship.name == '俾斯麦' + assert '俾斯麦' in ctx.ship_registry + + +def test_is_ship_available() -> None: + """is_ship_available 应反映舰船可用状态。""" + ctx = _make_ctx() + ship = ctx.get_ship('空想') + ship.damage_state = ShipDamageState.NORMAL + ship.repairing = False + assert ctx.is_ship_available('空想') is True + + ship.damage_state = ShipDamageState.SEVERE + assert ctx.is_ship_available('空想') is False + + +def test_update_ship_damage() -> None: + """update_ship_damage 应同步更新注册表中的状态。""" + ctx = _make_ctx() + ctx.update_ship_damage('黎塞留', ShipDamageState.MODERATE) + assert ctx.get_ship('黎塞留').damage_state == ShipDamageState.MODERATE + + +def test_sync_before_combat() -> None: + """sync_before_combat 应同步舰队成员与每日计数器。""" + ctx = _make_ctx() + ships = [Ship(name='Z1', level=80), Ship(name='Z16', level=75)] + ctx.sync_before_combat( + fleet_id=1, + ships=ships, + loot_count=10, + ship_acquired_count=3, + ) + assert ctx.fleet(1).ships == ships + assert ctx.dropped_loot_count == 10 + assert ctx.dropped_ship_count == 3 + assert ctx.get_ship('Z1').level == 80 + + +def test_sync_after_combat_updates_damage() -> None: + """sync_after_combat 应更新舰队战后血量。""" + ctx = _make_ctx() + ctx.fleet(1).ships = [Ship(name='A'), Ship(name='B')] + result = MagicMock() + result.flag = ConditionFlag.OPERATION_SUCCESS + result.ship_stats = [ShipDamageState.MODERATE, ShipDamageState.NORMAL] + result.fight_results = [] + ctx.sync_after_combat(1, result) + assert ctx.fleet(1).ships[0].damage_state == ShipDamageState.MODERATE + assert ctx.get_ship('A').damage_state == ShipDamageState.MODERATE diff --git a/tests/unit/context/test_resources.py b/tests/unit/context/test_resources.py new file mode 100644 index 00000000..e4d4fee0 --- /dev/null +++ b/tests/unit/context/test_resources.py @@ -0,0 +1,33 @@ +"""测试 autowsgr.context.resources。""" + +from __future__ import annotations + +from autowsgr.context.resources import Resources + + +def test_resources_defaults() -> None: + """Resources 默认值应全为 0。""" + res = Resources() + assert res.fuel == 0 + assert res.ammo == 0 + assert res.steel == 0 + assert res.aluminum == 0 + assert res.diamond == 0 + assert res.fast_repair == 0 + assert res.fast_build == 0 + assert res.ship_blueprint == 0 + assert res.equipment_blueprint == 0 + + +def test_resources_basic_property() -> None: + """basic 应返回四项基础资源的元组。""" + res = Resources(fuel=1000, ammo=2000, steel=3000, aluminum=4000) + assert res.basic == (1000, 2000, 3000, 4000) + + +def test_resources_custom_values() -> None: + """应能正确存储非默认资源值。""" + res = Resources(diamond=500, fast_repair=20, fast_build=10) + assert res.diamond == 500 + assert res.fast_repair == 20 + assert res.fast_build == 10 diff --git a/tests/unit/context/test_ship.py b/tests/unit/context/test_ship.py new file mode 100644 index 00000000..2829da16 --- /dev/null +++ b/tests/unit/context/test_ship.py @@ -0,0 +1,91 @@ +"""测试 autowsgr.context.ship。""" + +from __future__ import annotations + +import time + +from autowsgr.context.ship import Ship +from autowsgr.types import RepairMode, ShipDamageState + + +def test_ship_defaults() -> None: + """Ship 默认值应符合预期。""" + ship = Ship() + assert ship.name == '' + assert ship.level == 0 + assert ship.damage_state == ShipDamageState.NORMAL + assert ship.health_ratio == 1.0 + + +def test_health_ratio_known() -> None: + """已知 max_health 时 health_ratio 应正确计算。""" + ship = Ship(health=30, max_health=100) + assert ship.health_ratio == 0.3 + + +def test_health_ratio_zero_max() -> None: + """max_health 为 0 时应返回 1.0。""" + ship = Ship(health=0, max_health=0) + assert ship.health_ratio == 1.0 + + +def test_is_repairing_by_flag() -> None: + """repairing=True 时应判定为修理中。""" + ship = Ship(repairing=True) + assert ship.is_repairing is True + + +def test_is_repairing_by_timestamp() -> None: + """repair_end_time 在未来时应判定为修理中。""" + ship = Ship(repair_end_time=time.time() + 3600) + assert ship.is_repairing is True + + +def test_is_repairing_expired() -> None: + """repair_end_time 已过期时不应判定为修理中。""" + ship = Ship(repair_end_time=time.time() - 1) + assert ship.is_repairing is False + + +def test_available_severe_damage() -> None: + """大破时不可用。""" + ship = Ship(damage_state=ShipDamageState.SEVERE) + assert ship.available is False + + +def test_available_repairing() -> None: + """修理中时不可用。""" + ship = Ship(repairing=True, damage_state=ShipDamageState.NORMAL) + assert ship.available is False + + +def test_set_repair() -> None: + """set_repair 应设置结束时间并恢复状态。""" + ship = Ship(damage_state=ShipDamageState.MODERATE) + ship.set_repair(600) + assert ship.damage_state == ShipDamageState.NORMAL + assert ship.repair_end_time > time.time() + + +def test_needs_repair_moderate() -> None: + """moderate_damage 策略应对中破和大破返回 True。""" + ship_mod = Ship(damage_state=ShipDamageState.MODERATE) + ship_sev = Ship(damage_state=ShipDamageState.SEVERE) + ship_norm = Ship(damage_state=ShipDamageState.NORMAL) + assert ship_mod.needs_repair(RepairMode.moderate_damage) is True + assert ship_sev.needs_repair(RepairMode.moderate_damage) is True + assert ship_norm.needs_repair(RepairMode.moderate_damage) is False + + +def test_needs_repair_severe_only() -> None: + """severe_damage 策略仅对大破返回 True。""" + ship_mod = Ship(damage_state=ShipDamageState.MODERATE) + ship_sev = Ship(damage_state=ShipDamageState.SEVERE) + assert ship_mod.needs_repair(RepairMode.severe_damage) is False + assert ship_sev.needs_repair(RepairMode.severe_damage) is True + + +def test_needs_repair_while_repairing() -> None: + """正在修理中时 needs_repair 应返回 False。""" + ship = Ship(damage_state=ShipDamageState.SEVERE, repairing=True) + assert ship.needs_repair(RepairMode.severe_damage) is False diff --git a/tests/unit/emulator/__init__.py b/tests/unit/emulator/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/emulator/controller/__init__.py b/tests/unit/emulator/controller/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/emulator/controller/test_protocol.py b/tests/unit/emulator/controller/test_protocol.py new file mode 100644 index 00000000..299311e9 --- /dev/null +++ b/tests/unit/emulator/controller/test_protocol.py @@ -0,0 +1,171 @@ +"""Tests for autowsgr.emulator.controller.protocol.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from autowsgr.emulator.controller.protocol import AndroidController, DeviceInfo + + +if TYPE_CHECKING: + import numpy as np + + +class TestDeviceInfo: + """Tests for DeviceInfo dataclass.""" + + def test_construction(self) -> None: + """DeviceInfo can be constructed with serial and resolution.""" + info = DeviceInfo(serial='abc123', resolution=(1920, 1080)) + assert info.serial == 'abc123' + assert info.resolution == (1920, 1080) + + def test_frozen_immutability(self) -> None: + """Frozen dataclass fields cannot be modified.""" + info = DeviceInfo(serial='abc123', resolution=(1920, 1080)) + with pytest.raises(AttributeError): + setattr(info, 'serial', 'new_serial') # noqa: B010 + + def test_slots(self) -> None: + """DeviceInfo uses __slots__ and cannot have arbitrary attributes.""" + info = DeviceInfo(serial='abc123', resolution=(1920, 1080)) + with pytest.raises(AttributeError): + object.__setattr__(info, 'extra_field', 'value') + + +class TestAndroidController: + """Tests for AndroidController abstract base class.""" + + def test_cannot_instantiate_directly(self) -> None: + """Instantiating AndroidController directly raises TypeError.""" + with pytest.raises(TypeError): + AndroidController() + + def test_subclass_with_all_methods(self) -> None: + """A subclass implementing all abstract methods can be instantiated.""" + + class DummyController(AndroidController): + def connect(self) -> DeviceInfo: + return DeviceInfo(serial='dummy', resolution=(1080, 1920)) + + def disconnect(self) -> None: + pass + + @property + def resolution(self) -> tuple[int, int]: + return (1080, 1920) + + def screenshot(self) -> np.ndarray: + raise NotImplementedError + + def click(self, x: float, y: float) -> None: + pass + + def swipe( + self, + x1: float, + y1: float, + x2: float, + y2: float, + duration: float = 0.5, + ) -> None: + pass + + def long_tap(self, x: float, y: float, duration: float = 1.0) -> None: + pass + + def key_event(self, key_code: int) -> None: + pass + + def text(self, content: str) -> None: + pass + + def start_app(self, package: str) -> None: + pass + + def stop_app(self, package: str) -> None: + pass + + def is_app_running(self, package: str) -> bool: # noqa: ARG002 + return False + + def shell(self, cmd: str) -> str: # noqa: ARG002 + return '' + + controller = DummyController() + assert isinstance(controller, AndroidController) + + def test_subclass_missing_one_method(self) -> None: + """A subclass missing an abstract method cannot be instantiated.""" + + class IncompleteController(AndroidController): + def connect(self) -> DeviceInfo: + return DeviceInfo(serial='dummy', resolution=(1080, 1920)) + + def disconnect(self) -> None: + pass + + @property + def resolution(self) -> tuple[int, int]: + return (1080, 1920) + + def screenshot(self) -> np.ndarray: + raise NotImplementedError + + def click(self, x: float, y: float) -> None: + pass + + def swipe( + self, + x1: float, + y1: float, + x2: float, + y2: float, + duration: float = 0.5, + ) -> None: + pass + + def long_tap(self, x: float, y: float, duration: float = 1.0) -> None: + pass + + def key_event(self, key_code: int) -> None: + pass + + def text(self, content: str) -> None: + pass + + def start_app(self, package: str) -> None: + pass + + def stop_app(self, package: str) -> None: + pass + + def is_app_running(self, package: str) -> bool: # noqa: ARG002 + return False + + # Missing shell method + + with pytest.raises(TypeError): + IncompleteController() + + def test_abstract_method_names_exist(self) -> None: + """All expected abstract method names exist on AndroidController.""" + expected = [ + 'connect', + 'disconnect', + 'resolution', + 'screenshot', + 'click', + 'swipe', + 'long_tap', + 'key_event', + 'text', + 'start_app', + 'stop_app', + 'is_app_running', + 'shell', + ] + for name in expected: + assert hasattr(AndroidController, name) diff --git a/tests/unit/emulator/controller/test_scrcpy.py b/tests/unit/emulator/controller/test_scrcpy.py new file mode 100644 index 00000000..df927993 --- /dev/null +++ b/tests/unit/emulator/controller/test_scrcpy.py @@ -0,0 +1,10 @@ +"""测试 autowsgr.emulator.controller.scrcpy.""" + +from __future__ import annotations + + +def test_module_importable() -> None: + """验证模块可被导入。""" + import autowsgr.emulator.controller.scrcpy as _mod + + assert _mod is not None diff --git a/tests/unit/emulator/os_control/__init__.py b/tests/unit/emulator/os_control/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/emulator/os_control/test_base.py b/tests/unit/emulator/os_control/test_base.py new file mode 100644 index 00000000..1852d0d8 --- /dev/null +++ b/tests/unit/emulator/os_control/test_base.py @@ -0,0 +1,157 @@ +"""Tests for autowsgr.emulator.os_control.base.""" + +from __future__ import annotations + +from typing import Any, cast +from unittest.mock import MagicMock, patch + +import pytest + +from autowsgr.emulator.os_control import create_emulator_manager +from autowsgr.emulator.os_control.base import EmulatorProcessManager +from autowsgr.emulator.os_control.linux import LinuxEmulatorManager +from autowsgr.emulator.os_control.macos import MacEmulatorManager +from autowsgr.emulator.os_control.windows import WindowsEmulatorManager +from autowsgr.infra import EmulatorConfig, EmulatorError +from autowsgr.types import EmulatorType, OSType + + +class _ConcreteManager(EmulatorProcessManager): + """Concrete subclass for testing.""" + + def is_running(self) -> bool: + return True + + def start(self) -> None: + pass + + def stop(self) -> None: + pass + + +def test_cannot_instantiate_abc_directly() -> None: + """Instantiating the ABC directly must raise TypeError.""" + config = EmulatorConfig() + with pytest.raises(TypeError): + EmulatorProcessManager(config) + + +def test_subclass_instantiation_sets_attributes() -> None: + """A concrete subclass can be instantiated and attributes are set from config.""" + config = EmulatorConfig( + type=EmulatorType.leidian, + path='/emu/path', + serial='emulator-5554', + process_name='emulator.exe', + ) + manager = _ConcreteManager(config) + + assert manager._config is config + assert manager._emulator_type is EmulatorType.leidian + assert manager._path == '/emu/path' + assert manager._process_name == 'emulator.exe' + assert manager._serial == 'emulator-5554' + + +def test_restart_calls_stop_then_start() -> None: + """restart() must call stop() then start() in order.""" + config = EmulatorConfig() + manager = _ConcreteManager(config) + call_order: list[str] = [] + object.__setattr__(manager, 'stop', MagicMock(side_effect=lambda: call_order.append('stop'))) + object.__setattr__(manager, 'start', MagicMock(side_effect=lambda: call_order.append('start'))) + + manager.restart() + + manager.stop.assert_called_once() + manager.start.assert_called_once() + assert call_order == ['stop', 'start'] + + +def test_wait_until_online_succeeds_immediately() -> None: + """wait_until_online returns at once when is_running is True.""" + config = EmulatorConfig() + manager = _ConcreteManager(config) + object.__setattr__(manager, 'is_running', MagicMock(return_value=True)) + + manager.wait_until_online(timeout=10) + + manager.is_running.assert_called_once() + + +def test_wait_until_online_raises_on_timeout() -> None: + """wait_until_online raises EmulatorError when is_running never becomes True.""" + config = EmulatorConfig() + manager = _ConcreteManager(config) + object.__setattr__(manager, 'is_running', MagicMock(return_value=False)) + + with ( + patch( + 'autowsgr.emulator.os_control.base.time.monotonic', + side_effect=[0.0, 0.5, 1.0, 2.0], + ), + patch('autowsgr.emulator.os_control.base.time.sleep'), + pytest.raises(EmulatorError, match='模拟器启动超时'), + ): + manager.wait_until_online(timeout=1) + + +def test_wait_until_online_eventually_succeeds() -> None: + """wait_until_online succeeds after a few False returns from is_running.""" + config = EmulatorConfig() + manager = _ConcreteManager(config) + object.__setattr__( + manager, + 'is_running', + MagicMock( + side_effect=[False, False, True], + ), + ) + + with ( + patch( + 'autowsgr.emulator.os_control.base.time.monotonic', + side_effect=[0.0, 0.5, 1.0, 1.5, 2.0], + ), + patch('autowsgr.emulator.os_control.base.time.sleep') as mock_sleep, + ): + manager.wait_until_online(timeout=10) + + assert manager.is_running.call_count == 3 + assert mock_sleep.call_count == 2 + + +def test_create_emulator_manager_windows() -> None: + """create_emulator_manager with os_type=OSType.windows returns WindowsEmulatorManager.""" + config = MagicMock() + manager = create_emulator_manager(config, os_type=OSType.windows) + assert isinstance(manager, WindowsEmulatorManager) + + +def test_create_emulator_manager_macos() -> None: + """create_emulator_manager with os_type=OSType.macos returns MacEmulatorManager.""" + config = MagicMock() + manager = create_emulator_manager(config, os_type=OSType.macos) + assert isinstance(manager, MacEmulatorManager) + + +def test_create_emulator_manager_linux() -> None: + """create_emulator_manager with os_type=OSType.linux returns LinuxEmulatorManager.""" + config = MagicMock() + manager = create_emulator_manager(config, os_type=OSType.linux) + assert isinstance(manager, LinuxEmulatorManager) + + +def test_create_emulator_manager_unknown_os_raises() -> None: + """create_emulator_manager with unknown os_type raises EmulatorError.""" + config = MagicMock() + with pytest.raises(EmulatorError, match='不支持的操作系统'): + create_emulator_manager(config, os_type=cast('Any', MagicMock())) + + +def test_create_emulator_manager_auto_detect_windows() -> None: + """create_emulator_manager with os_type=None mocks OSType.auto to return windows.""" + config = MagicMock() + with patch('autowsgr.emulator.os_control.OSType.auto', return_value=OSType.windows): + manager = create_emulator_manager(config, os_type=None) + assert isinstance(manager, WindowsEmulatorManager) diff --git a/tests/unit/emulator/os_control/test_linux.py b/tests/unit/emulator/os_control/test_linux.py new file mode 100644 index 00000000..e3581c93 --- /dev/null +++ b/tests/unit/emulator/os_control/test_linux.py @@ -0,0 +1,179 @@ +"""测试 autowsgr.emulator.os_control.linux.""" + +from __future__ import annotations + +import subprocess +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +from autowsgr.emulator.os_control.linux import LinuxEmulatorManager +from autowsgr.infra import EmulatorConfig, EmulatorError, EmulatorNotFoundError + + +@pytest.fixture +def _patch_is_wsl() -> Any: + with patch('autowsgr.emulator.os_control.linux.OSType._is_wsl', return_value=False): + yield + + +@pytest.mark.usefixtures('_patch_is_wsl') +class TestIsRunning: + def test_serial_in_adb_devices(self) -> None: + config = EmulatorConfig(serial='emulator-5554') + manager = LinuxEmulatorManager(config) + with patch.object(manager, '_adb_devices', return_value=['emulator-5554']): + assert manager.is_running() is True + + def test_wsl_process_found(self) -> None: + with patch('autowsgr.emulator.os_control.linux.OSType._is_wsl', return_value=True): + config = EmulatorConfig(process_name='emulator.exe') + manager = LinuxEmulatorManager(config) + with patch.object(manager, '_is_windows_process_running', return_value=True): + assert manager.is_running() is True + + def test_wsl_no_tasks(self) -> None: + with patch('autowsgr.emulator.os_control.linux.OSType._is_wsl', return_value=True): + config = EmulatorConfig(process_name='emulator.exe') + manager = LinuxEmulatorManager(config) + with patch.object(manager, '_is_windows_process_running', return_value=False): + assert manager.is_running() is False + + def test_linux_pgrep_success(self) -> None: + config = EmulatorConfig(process_name='emulator') + manager = LinuxEmulatorManager(config) + with patch('autowsgr.emulator.os_control.linux.subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0) + assert manager.is_running() is True + mock_run.assert_called_once_with( + ['pgrep', '-f', 'emulator'], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + def test_linux_pgrep_fails(self) -> None: + config = EmulatorConfig(process_name='emulator') + manager = LinuxEmulatorManager(config) + with patch('autowsgr.emulator.os_control.linux.subprocess.run') as mock_run: + mock_run.side_effect = subprocess.CalledProcessError(1, ['pgrep']) + assert manager.is_running() is False + + def test_no_serial_no_process_name(self) -> None: + config = EmulatorConfig() + manager = LinuxEmulatorManager(config) + assert manager.is_running() is False + + +@pytest.mark.usefixtures('_patch_is_wsl') +class TestStart: + def test_start_no_path_raises(self) -> None: + config = EmulatorConfig() + manager = LinuxEmulatorManager(config) + with pytest.raises(EmulatorNotFoundError, match='未设置模拟器路径'): + manager.start() + + def test_start_success(self) -> None: + config = EmulatorConfig(path='/usr/bin/emulator') + manager = LinuxEmulatorManager(config) + with ( + patch('autowsgr.emulator.os_control.linux.subprocess.Popen') as mock_popen, + patch.object(manager, 'wait_until_online') as mock_wait, + ): + manager.start() + mock_popen.assert_called_once_with(['/usr/bin/emulator']) + mock_wait.assert_called_once() + + +@pytest.mark.usefixtures('_patch_is_wsl') +class TestStop: + def test_stop_wsl_taskkill_success(self) -> None: + with patch('autowsgr.emulator.os_control.linux.OSType._is_wsl', return_value=True): + config = EmulatorConfig(process_name='emulator.exe') + manager = LinuxEmulatorManager(config) + with patch('autowsgr.emulator.os_control.linux.subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0, stderr='', stdout='') + manager.stop() + mock_run.assert_called_once_with( + ['taskkill.exe', '/f', '/im', 'emulator.exe'], + capture_output=True, + text=True, + check=False, + ) + + def test_stop_linux_pkill_success(self) -> None: + config = EmulatorConfig(process_name='emulator') + manager = LinuxEmulatorManager(config) + with patch('autowsgr.emulator.os_control.linux.subprocess.run') as mock_run: + manager.stop() + mock_run.assert_called_once_with( + ['pkill', '-9', '-f', 'emulator'], + check=True, + ) + + def test_stop_no_process_name_raises(self) -> None: + config = EmulatorConfig() + manager = LinuxEmulatorManager(config) + with pytest.raises(EmulatorError, match='未设置进程名'): + manager.stop() + + +@pytest.mark.usefixtures('_patch_is_wsl') +class TestAdbDevices: + def test_parses_normal_output(self) -> None: + with ( + patch('autowsgr.emulator.os_control.linux._find_adb', return_value='/usr/bin/adb'), + patch('autowsgr.emulator.os_control.linux.subprocess.run') as mock_run, + ): + mock_run.return_value = MagicMock( + stdout='List of devices attached\nemulator-5554\tdevice\n127.0.0.1:16384\tdevice\n', + returncode=0, + ) + devices = LinuxEmulatorManager._adb_devices() + assert devices == ['emulator-5554', '127.0.0.1:16384'] + + def test_parses_offline_devices(self) -> None: + with ( + patch('autowsgr.emulator.os_control.linux._find_adb', return_value='/usr/bin/adb'), + patch('autowsgr.emulator.os_control.linux.subprocess.run') as mock_run, + ): + mock_run.return_value = MagicMock( + stdout='List of devices attached\nemulator-5554\tdevice\nemulator-5556\toffline\n', + returncode=0, + ) + devices = LinuxEmulatorManager._adb_devices() + assert devices == ['emulator-5554'] + + def test_adb_fails_returns_empty(self) -> None: + with ( + patch('autowsgr.emulator.os_control.linux._find_adb', return_value='/usr/bin/adb'), + patch('autowsgr.emulator.os_control.linux.subprocess.run') as mock_run, + ): + mock_run.side_effect = subprocess.CalledProcessError(1, ['adb']) + devices = LinuxEmulatorManager._adb_devices() + assert devices == [] + + +class TestIsWindowsProcessRunning: + def test_process_found(self) -> None: + with patch('autowsgr.emulator.os_control.linux.OSType._is_wsl', return_value=True): + config = EmulatorConfig(process_name='emulator.exe') + manager = LinuxEmulatorManager(config) + with patch('autowsgr.emulator.os_control.linux.subprocess.run') as mock_run: + mock_run.return_value = MagicMock( + stdout='Image Name PID Session Name Session# Mem Usage\nemulator.exe 1234 Console 1 123456 K\n', + returncode=0, + ) + assert manager._is_windows_process_running() is True + + def test_no_tasks(self) -> None: + with patch('autowsgr.emulator.os_control.linux.OSType._is_wsl', return_value=True): + config = EmulatorConfig(process_name='emulator.exe') + manager = LinuxEmulatorManager(config) + with patch('autowsgr.emulator.os_control.linux.subprocess.run') as mock_run: + mock_run.return_value = MagicMock( + stdout='INFO: No tasks are running which match the specified criteria.\n', + returncode=0, + ) + assert manager._is_windows_process_running() is False diff --git a/tests/unit/emulator/os_control/test_macos.py b/tests/unit/emulator/os_control/test_macos.py new file mode 100644 index 00000000..46a00110 --- /dev/null +++ b/tests/unit/emulator/os_control/test_macos.py @@ -0,0 +1,327 @@ +"""Tests for autowsgr.emulator.os_control.macos.""" + +from __future__ import annotations + +import subprocess +from unittest.mock import MagicMock, patch + +import pytest + +from autowsgr.emulator.os_control.macos import MacEmulatorManager +from autowsgr.infra import EmulatorConfig, EmulatorError, EmulatorNotFoundError +from autowsgr.types import EmulatorType + + +# ── is_running ── + + +def test_is_running_no_process_name() -> None: + """is_running returns False when process_name is not set.""" + config = EmulatorConfig(type=EmulatorType.leidian, process_name=None) + manager = MacEmulatorManager(config) + assert manager.is_running() is False + + +def test_is_running_pgrep_fails() -> None: + """is_running returns False when pgrep finds no process.""" + config = EmulatorConfig(type=EmulatorType.leidian, process_name='test') + manager = MacEmulatorManager(config) + with patch( + 'autowsgr.emulator.os_control.macos.subprocess.check_output', + side_effect=subprocess.CalledProcessError(1, 'pgrep'), + ): + assert manager.is_running() is False + + +def test_is_running_non_mumu_pgrep_succeeds() -> None: + """is_running returns True for non-mumu when pgrep succeeds.""" + config = EmulatorConfig(type=EmulatorType.leidian, process_name='test') + manager = MacEmulatorManager(config) + with patch( + 'autowsgr.emulator.os_control.macos.subprocess.check_output', + return_value=b'1234', + ): + assert manager.is_running() is True + + +def test_is_running_mumu_matching_port() -> None: + """is_running returns True for mumu when adb_port matches.""" + config = EmulatorConfig( + type=EmulatorType.mumu, + process_name='test', + serial='127.0.0.1:5555', + ) + manager = MacEmulatorManager(config) + with ( + patch( + 'autowsgr.emulator.os_control.macos.subprocess.check_output', + return_value=b'1234', + ), + patch.object( + manager, + '_get_mumu_info', + return_value={'return': {'results': [{'adb_port': '5555'}]}}, + ), + ): + assert manager.is_running() is True + + +def test_is_running_mumu_no_matching_port() -> None: + """is_running returns False for mumu when adb_port does not match.""" + config = EmulatorConfig( + type=EmulatorType.mumu, + process_name='test', + serial='127.0.0.1:5555', + ) + manager = MacEmulatorManager(config) + with ( + patch( + 'autowsgr.emulator.os_control.macos.subprocess.check_output', + return_value=b'1234', + ), + patch.object( + manager, + '_get_mumu_info', + return_value={'return': {'results': [{'adb_port': '5556'}]}}, + ), + ): + assert manager.is_running() is False + + +# ── start ── + + +def test_start_no_path_raises() -> None: + """start raises EmulatorNotFoundError when path is not set.""" + config = EmulatorConfig(type=EmulatorType.leidian, path=None) + manager = MacEmulatorManager(config) + with pytest.raises(EmulatorNotFoundError, match='未设置模拟器路径'): + manager.start() + + +def test_start_non_mumu_success() -> None: + """start opens non-mumu emulator with 'open -a' and waits until online.""" + config = EmulatorConfig(type=EmulatorType.leidian, path='/App') + manager = MacEmulatorManager(config) + with ( + patch( + 'autowsgr.emulator.os_control.macos.subprocess.Popen', + ) as mock_popen, + patch.object(manager, 'wait_until_online'), + ): + manager.start() + mock_popen.assert_called_once_with(['open', '-a', '/App']) + + +def test_start_mumu_success() -> None: + """start opens mumu emulator and calls _mumu_restart_instance.""" + config = EmulatorConfig(type=EmulatorType.mumu, path='/App') + manager = MacEmulatorManager(config) + with ( + patch( + 'autowsgr.emulator.os_control.macos.subprocess.Popen', + ) as mock_popen, + patch.object(manager, 'wait_until_online'), + patch.object(manager, '_mumu_restart_instance') as mock_restart, + ): + manager.start() + mock_popen.assert_called_once_with(['open', '-a', '/App']) + mock_restart.assert_called_once() + + +# ── stop ── + + +def test_stop_mumu_logs_and_returns() -> None: + """stop logs a warning for mumu and does not kill the process.""" + config = EmulatorConfig(type=EmulatorType.mumu) + manager = MacEmulatorManager(config) + with ( + patch('autowsgr.emulator.os_control.macos._log.info') as mock_log, + patch( + 'autowsgr.emulator.os_control.macos.subprocess.Popen', + ) as mock_popen, + ): + manager.stop() + mock_popen.assert_not_called() + mock_log.assert_called_once_with( + 'MuMu macOS 版暂不支持 CLI 关闭', + ) + + +def test_stop_non_mumu_no_process_name_raises() -> None: + """stop raises EmulatorError for non-mumu when process_name is not set.""" + config = EmulatorConfig(type=EmulatorType.leidian, process_name=None) + manager = MacEmulatorManager(config) + with pytest.raises(EmulatorError, match='未设置进程名'): + manager.stop() + + +def test_stop_non_mumu_success() -> None: + """stop kills non-mumu process with pkill.""" + config = EmulatorConfig(type=EmulatorType.leidian, process_name='test') + manager = MacEmulatorManager(config) + with patch( + 'autowsgr.emulator.os_control.macos.subprocess.Popen', + ) as mock_popen: + manager.stop() + mock_popen.assert_called_once_with( + ['pkill', '-9', '-f', 'test'], + ) + + +# ── _get_mumu_info ── + + +def test_get_mumu_info_valid_json() -> None: + """_get_mumu_info returns parsed dict for valid JSON output.""" + config = EmulatorConfig(type=EmulatorType.mumu, path='/App') + manager = MacEmulatorManager(config) + mock_proc = MagicMock() + mock_proc.communicate.return_value = (b'{"return":{"results":[]}}', b'') + with ( + patch( + 'autowsgr.emulator.os_control.macos.os.path.join', + return_value='/App/Contents/MacOS/mumutool', + ), + patch( + 'autowsgr.emulator.os_control.macos.os.path.isfile', + return_value=True, + ), + patch( + 'autowsgr.emulator.os_control.macos.subprocess.Popen', + return_value=mock_proc, + ) as mock_popen, + ): + result = manager._get_mumu_info() + assert result == {'return': {'results': []}} + mock_popen.assert_called_once_with( + ['/App/Contents/MacOS/mumutool', 'info', 'all'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + +def test_get_mumu_info_invalid_json() -> None: + """_get_mumu_info returns {} when mumutool output is not valid JSON.""" + config = EmulatorConfig(type=EmulatorType.mumu, path='/App') + manager = MacEmulatorManager(config) + mock_proc = MagicMock() + mock_proc.communicate.return_value = (b'not json', b'') + with ( + patch( + 'autowsgr.emulator.os_control.macos.os.path.join', + return_value='/App/Contents/MacOS/mumutool', + ), + patch( + 'autowsgr.emulator.os_control.macos.os.path.isfile', + return_value=True, + ), + patch( + 'autowsgr.emulator.os_control.macos.subprocess.Popen', + return_value=mock_proc, + ) as mock_popen, + ): + result = manager._get_mumu_info() + assert result == {} + mock_popen.assert_called_once_with( + ['/App/Contents/MacOS/mumutool', 'info', 'all'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + +def test_get_mumu_info_missing_tool() -> None: + """_get_mumu_info returns {} when mumutool executable does not exist.""" + config = EmulatorConfig(type=EmulatorType.mumu, path='/App') + manager = MacEmulatorManager(config) + with ( + patch( + 'autowsgr.emulator.os_control.macos.os.path.join', + return_value='/App/Contents/MacOS/mumutool', + ), + patch( + 'autowsgr.emulator.os_control.macos.os.path.isfile', + return_value=False, + ), + patch( + 'autowsgr.emulator.os_control.macos.subprocess.Popen', + ) as mock_popen, + ): + result = manager._get_mumu_info() + assert result == {} + mock_popen.assert_not_called() + + +# ── _mumu_restart_instance ── + + +def test_mumu_restart_instance_matching_port() -> None: + """_mumu_restart_instance calls mumutool restart with correct idx.""" + config = EmulatorConfig( + type=EmulatorType.mumu, + path='/App', + serial='127.0.0.1:5555', + ) + manager = MacEmulatorManager(config) + with ( + patch( + 'autowsgr.emulator.os_control.macos.os.path.join', + return_value='/App/Contents/MacOS/mumutool', + ), + patch( + 'autowsgr.emulator.os_control.macos.os.path.isfile', + return_value=True, + ), + patch.object( + manager, + '_get_mumu_info', + return_value={ + 'return': { + 'results': [ + {'adb_port': '5555'}, + {'adb_port': '5556'}, + ], + }, + }, + ), + patch( + 'autowsgr.emulator.os_control.macos.subprocess.Popen', + ) as mock_popen, + ): + manager._mumu_restart_instance() + mock_popen.assert_called_once_with( + ['/App/Contents/MacOS/mumutool', 'restart', '0'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + +def test_mumu_restart_instance_no_match() -> None: + """_mumu_restart_instance does nothing when no adb_port matches.""" + config = EmulatorConfig( + type=EmulatorType.mumu, + path='/App', + serial='127.0.0.1:5555', + ) + manager = MacEmulatorManager(config) + with ( + patch( + 'autowsgr.emulator.os_control.macos.os.path.join', + return_value='/App/Contents/MacOS/mumutool', + ), + patch( + 'autowsgr.emulator.os_control.macos.os.path.isfile', + return_value=True, + ), + patch.object( + manager, + '_get_mumu_info', + return_value={'return': {'results': [{'adb_port': '5556'}]}}, + ), + patch( + 'autowsgr.emulator.os_control.macos.subprocess.Popen', + ) as mock_popen, + ): + manager._mumu_restart_instance() + mock_popen.assert_not_called() diff --git a/tests/unit/emulator/os_control/test_windows.py b/tests/unit/emulator/os_control/test_windows.py new file mode 100644 index 00000000..d9deaade --- /dev/null +++ b/tests/unit/emulator/os_control/test_windows.py @@ -0,0 +1,419 @@ +"""Tests for autowsgr.emulator.os_control.windows.""" + +from __future__ import annotations + +import re +import subprocess +from unittest.mock import MagicMock, patch + +import pytest + +from autowsgr.emulator.os_control.windows import WindowsEmulatorManager +from autowsgr.infra import EmulatorConfig, EmulatorError, EmulatorNotFoundError +from autowsgr.types import EmulatorType + + +class TestIsRunning: + """is_running tests.""" + + def test_leidian_running_and_not_running(self) -> None: + """Leidian is_running returns True when ldconsole says running, else False.""" + config = EmulatorConfig( + type=EmulatorType.leidian, + path='/emu/leidian.exe', + serial='emulator-5554', + ) + manager = WindowsEmulatorManager(config) + with ( + patch( + 'autowsgr.emulator.os_control.windows.os.path.join', + return_value='/emu/ldconsole.exe', + ), + patch( + 'autowsgr.emulator.os_control.windows.os.path.isfile', + return_value=True, + ), + patch( + 'autowsgr.emulator.os_control.windows.os.path.dirname', + return_value='/emu', + ), + patch( + 'autowsgr.emulator.os_control.windows.subprocess.Popen', + ) as mock_popen, + ): + mock_popen.return_value.communicate.return_value = (b'running\n', b'') + assert manager.is_running() is True + + mock_popen.return_value.communicate.return_value = (b'stopped\n', b'') + assert manager.is_running() is False + + def test_mumu_started_and_parse_fail(self) -> None: + """MuMu is_running returns True when JSON says started; False on parse failure.""" + config = EmulatorConfig( + type=EmulatorType.mumu, + path='/emu/mumu.exe', + serial='127.0.0.1:16384', + ) + manager = WindowsEmulatorManager(config) + with ( + patch( + 'autowsgr.emulator.os_control.windows.os.path.join', + return_value='/emu/MuMuManager.exe', + ), + patch( + 'autowsgr.emulator.os_control.windows.os.path.isfile', + return_value=True, + ), + patch( + 'autowsgr.emulator.os_control.windows.os.path.dirname', + return_value='/emu', + ), + patch( + 'autowsgr.emulator.os_control.windows.subprocess.Popen', + ) as mock_popen, + ): + mock_popen.return_value.communicate.return_value = ( + b'{"is_android_started": true}', + b'', + ) + assert manager.is_running() is True + + mock_popen.return_value.communicate.return_value = (b'bad json', b'') + assert manager.is_running() is False + + def test_yunshouji(self) -> None: + """Yunshouji is_running always returns True.""" + config = EmulatorConfig(type=EmulatorType.yunshouji) + manager = WindowsEmulatorManager(config) + assert manager.is_running() is True + + def test_default_tasklist_finds_process(self) -> None: + """Default is_running returns True when tasklist finds the process.""" + config = EmulatorConfig( + type=EmulatorType.bluestacks, + process_name='bs.exe', + ) + manager = WindowsEmulatorManager(config) + with patch( + 'autowsgr.emulator.os_control.windows.subprocess.check_output', + return_value=b'ImageName PID SessionName\nbs.exe 1234 Console', + ) as mock_co: + assert manager.is_running() is True + mock_co.assert_called_once_with( + ['tasklist', '/fi', 'ImageName eq bs.exe'], + ) + + def test_default_tasklist_no_process(self) -> None: + """Default is_running returns False when tasklist reports no process.""" + config = EmulatorConfig( + type=EmulatorType.bluestacks, + process_name='bs.exe', + ) + manager = WindowsEmulatorManager(config) + with patch( + 'autowsgr.emulator.os_control.windows.subprocess.check_output', + return_value=b'INFO: No tasks are running', + ) as mock_co: + assert manager.is_running() is False + mock_co.assert_called_once_with( + ['tasklist', '/fi', 'ImageName eq bs.exe'], + ) + + +class TestStart: + """start tests.""" + + def test_yunshouji_no_op(self) -> None: + """Yunshouji start is a no-op.""" + config = EmulatorConfig(type=EmulatorType.yunshouji) + manager = WindowsEmulatorManager(config) + manager.start() + + def test_leidian_calls_ldconsole_launch(self) -> None: + """Leidian start calls _ldconsole('launch').""" + config = EmulatorConfig( + type=EmulatorType.leidian, + path='/emu/leidian.exe', + ) + manager = WindowsEmulatorManager(config) + object.__setattr__(manager, 'wait_until_online', MagicMock()) + object.__setattr__(manager, '_ldconsole', MagicMock()) + manager.start() + manager._ldconsole.assert_called_once_with('launch') + + def test_mumu_calls_mumuconsole_launch(self) -> None: + """MuMu start calls _mumuconsole('launch').""" + config = EmulatorConfig( + type=EmulatorType.mumu, + path='/emu/mumu.exe', + ) + manager = WindowsEmulatorManager(config) + object.__setattr__(manager, 'wait_until_online', MagicMock()) + object.__setattr__(manager, '_mumuconsole', MagicMock()) + manager.start() + manager._mumuconsole.assert_called_once_with('launch') + + def test_default_calls_popen_with_path(self) -> None: + """Default start calls subprocess.Popen with the executable path.""" + config = EmulatorConfig( + type=EmulatorType.bluestacks, + path='/emu/bs.exe', + ) + manager = WindowsEmulatorManager(config) + object.__setattr__(manager, 'wait_until_online', MagicMock()) + with patch( + 'autowsgr.emulator.os_control.windows.subprocess.Popen', + ) as mock_popen: + manager.start() + mock_popen.assert_called_once_with(['/emu/bs.exe']) + + def test_no_path_raises(self) -> None: + """start raises EmulatorNotFoundError when path is not set.""" + config = EmulatorConfig(type=EmulatorType.leidian, path=None) + manager = WindowsEmulatorManager(config) + with pytest.raises(EmulatorNotFoundError, match='未设置模拟器路径'): + manager.start() + + +class TestStop: + """stop tests.""" + + def test_leidian_calls_ldconsole_quit(self) -> None: + """Leidian stop calls _ldconsole('quit').""" + config = EmulatorConfig( + type=EmulatorType.leidian, + path='/emu/leidian.exe', + ) + manager = WindowsEmulatorManager(config) + object.__setattr__(manager, '_ldconsole', MagicMock()) + manager.stop() + manager._ldconsole.assert_called_once_with('quit') + + def test_mumu_calls_mumuconsole_shutdown(self) -> None: + """MuMu stop calls _mumuconsole('shutdown').""" + config = EmulatorConfig( + type=EmulatorType.mumu, + path='/emu/mumu.exe', + ) + manager = WindowsEmulatorManager(config) + object.__setattr__(manager, '_mumuconsole', MagicMock()) + manager.stop() + manager._mumuconsole.assert_called_once_with('shutdown') + + def test_yunshouji_no_op(self) -> None: + """Yunshouji stop is a no-op.""" + config = EmulatorConfig(type=EmulatorType.yunshouji) + manager = WindowsEmulatorManager(config) + manager.stop() + + def test_default_calls_taskkill(self) -> None: + """Default stop calls taskkill with the process name.""" + config = EmulatorConfig( + type=EmulatorType.bluestacks, + process_name='bs.exe', + ) + manager = WindowsEmulatorManager(config) + with patch( + 'autowsgr.emulator.os_control.windows.subprocess.run', + ) as mock_run: + manager.stop() + mock_run.assert_called_once_with( + ['taskkill', '-f', '-im', 'bs.exe'], + check=True, + capture_output=True, + ) + + def test_no_process_name_raises(self) -> None: + """stop raises EmulatorError for unsupported types without a process name.""" + config = EmulatorConfig( + type=EmulatorType.others, + process_name=None, + ) + manager = WindowsEmulatorManager(config) + with pytest.raises(EmulatorError, match='未设置进程名'): + manager.stop() + + +class TestLdConsole: + """_ldconsole tests.""" + + def test_command_built_with_index_from_serial(self) -> None: + """_ldconsole builds the correct command including index from serial.""" + config = EmulatorConfig( + type=EmulatorType.leidian, + path='/emu/leidian.exe', + serial='emulator-5556', + ) + manager = WindowsEmulatorManager(config) + with ( + patch( + 'autowsgr.emulator.os_control.windows.os.path.join', + return_value='/emu/ldconsole.exe', + ), + patch( + 'autowsgr.emulator.os_control.windows.os.path.isfile', + return_value=True, + ), + patch( + 'autowsgr.emulator.os_control.windows.os.path.dirname', + return_value='/emu', + ), + patch( + 'autowsgr.emulator.os_control.windows.subprocess.Popen', + ) as mock_popen, + ): + mock_popen.return_value.communicate.return_value = (b'ok', b'') + manager._ldconsole('launch') + mock_popen.assert_called_once() + cmd = mock_popen.call_args[0][0] + assert cmd == [ + '/emu/ldconsole.exe', + 'launch', + '--index', + '1', + ] + + def test_missing_console_raises(self) -> None: + """_ldconsole raises EmulatorNotFoundError when ldconsole.exe is missing.""" + config = EmulatorConfig( + type=EmulatorType.leidian, + path='/emu/leidian.exe', + ) + manager = WindowsEmulatorManager(config) + with ( + patch( + 'autowsgr.emulator.os_control.windows.os.path.join', + return_value='/emu/ldconsole.exe', + ), + patch( + 'autowsgr.emulator.os_control.windows.os.path.isfile', + return_value=False, + ), + patch( + 'autowsgr.emulator.os_control.windows.os.path.dirname', + return_value='/emu', + ), + pytest.raises(EmulatorNotFoundError, match=re.escape('找不到 ldconsole.exe')), + ): + manager._ldconsole('launch') + + +class TestMumuConsole: + """_mumuconsole tests.""" + + def test_command_built_with_index_from_serial(self) -> None: + """_mumuconsole builds the correct command including index from serial.""" + config = EmulatorConfig( + type=EmulatorType.mumu, + path='/emu/mumu.exe', + serial='127.0.0.1:16416', + ) + manager = WindowsEmulatorManager(config) + with ( + patch( + 'autowsgr.emulator.os_control.windows.os.path.join', + return_value='/emu/MuMuManager.exe', + ), + patch( + 'autowsgr.emulator.os_control.windows.os.path.isfile', + return_value=True, + ), + patch( + 'autowsgr.emulator.os_control.windows.os.path.dirname', + return_value='/emu', + ), + patch( + 'autowsgr.emulator.os_control.windows.subprocess.Popen', + ) as mock_popen, + ): + mock_popen.return_value.communicate.return_value = (b'ok', b'') + manager._mumuconsole('launch') + mock_popen.assert_called_once() + cmd = mock_popen.call_args[0][0] + assert cmd == [ + '/emu/MuMuManager.exe', + 'control', + '-v', + '1', + 'launch', + ] + + def test_missing_console_raises(self) -> None: + """_mumuconsole raises EmulatorNotFoundError when MuMuManager.exe is missing.""" + config = EmulatorConfig( + type=EmulatorType.mumu, + path='/emu/mumu.exe', + ) + manager = WindowsEmulatorManager(config) + with ( + patch( + 'autowsgr.emulator.os_control.windows.os.path.join', + return_value='/emu/MuMuManager.exe', + ), + patch( + 'autowsgr.emulator.os_control.windows.os.path.isfile', + return_value=False, + ), + patch( + 'autowsgr.emulator.os_control.windows.os.path.dirname', + return_value='/emu', + ), + pytest.raises(EmulatorNotFoundError, match=re.escape('找不到 MuMuManager.exe')), + ): + manager._mumuconsole('launch') + + +class TestTasklistCheck: + """_tasklist_check tests.""" + + def test_process_found(self) -> None: + """_tasklist_check returns True when tasklist output contains PID.""" + config = EmulatorConfig(process_name='emu.exe') + manager = WindowsEmulatorManager(config) + with patch( + 'autowsgr.emulator.os_control.windows.subprocess.check_output', + return_value=b'ImageName PID SessionName\nemu.exe 1234 Console', + ) as mock_co: + assert manager._tasklist_check() is True + mock_co.assert_called_once_with( + ['tasklist', '/fi', 'ImageName eq emu.exe'], + ) + + def test_no_process(self) -> None: + """_tasklist_check returns False when tasklist output has no PID.""" + config = EmulatorConfig(process_name='emu.exe') + manager = WindowsEmulatorManager(config) + with patch( + 'autowsgr.emulator.os_control.windows.subprocess.check_output', + return_value=b'INFO: No tasks are running', + ) as mock_co: + assert manager._tasklist_check() is False + mock_co.assert_called_once_with( + ['tasklist', '/fi', 'ImageName eq emu.exe'], + ) + + +class TestRunCmd: + """_run_cmd tests.""" + + def test_returns_stdout_else_stderr(self) -> None: + """_run_cmd returns stdout; falls back to stderr when stdout is empty.""" + with patch( + 'autowsgr.emulator.os_control.windows.subprocess.Popen', + ) as mock_popen: + mock_proc = MagicMock() + mock_proc.communicate.return_value = (b'stdout content', b'stderr content') + mock_popen.return_value = mock_proc + + result = WindowsEmulatorManager._run_cmd(['echo', 'hello']) + assert result == 'stdout content' + mock_popen.assert_called_once_with( + ['echo', 'hello'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + mock_popen.reset_mock() + mock_proc.communicate.return_value = (b'', b'stderr only') + result = WindowsEmulatorManager._run_cmd(['echo', 'hello']) + assert result == 'stderr only' diff --git a/testing/emulator/test_controller.py b/tests/unit/emulator/test_controller.py similarity index 90% rename from testing/emulator/test_controller.py rename to tests/unit/emulator/test_controller.py index c6b907c2..2d0d0a47 100644 --- a/testing/emulator/test_controller.py +++ b/tests/unit/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') # type: ignore # noqa: PGH003 + ctrl._device.shell.assert_called_once_with('input tap 480 270') # ty: ignore[unresolved-attribute] 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') # type: ignore # noqa: PGH003 + ctrl._device.shell.assert_called_once_with('input tap 0 0') # ty: ignore[unresolved-attribute] 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') # type: ignore # noqa: PGH003 + ctrl._device.shell.assert_called_once_with('input tap 960 540') # ty: ignore[unresolved-attribute] def test_click_quarter(self, ctrl: ScrcpyController): ctrl.click(0.25, 0.75) - ctrl._device.shell.assert_called_once_with('input tap 240 405') # type: ignore # noqa: PGH003 + ctrl._device.shell.assert_called_once_with('input tap 240 405') # ty: ignore[unresolved-attribute] 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') # type: ignore # noqa: PGH003 + ctrl._device.shell.assert_called_once_with('input swipe 96 108 864 432 500') # ty: ignore[unresolved-attribute] 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') # type: ignore # noqa: PGH003 + ctrl._device.shell.assert_called_once_with('input swipe 0 0 960 540 1000') # ty: ignore[unresolved-attribute] 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') # type: ignore # noqa: PGH003 + ctrl._device.shell.assert_called_once_with('input swipe 480 270 576 324 200') # ty: ignore[unresolved-attribute] 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') # type: ignore # noqa: PGH003 + ctrl._device.shell.assert_called_once_with('input swipe 480 270 480 270 2000') # ty: ignore[unresolved-attribute] 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() # type: ignore # noqa: PGH003 + object.__setattr__(ctrl, '_ensure_stream_alive', MagicMock()) 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() # type: ignore # noqa: PGH003 + object.__setattr__(ctrl, '_ensure_stream_alive', MagicMock()) 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() # type: ignore # noqa: PGH003 + object.__setattr__(ctrl, '_ensure_stream_alive', MagicMock()) ctrl._alive = True img = np.zeros((2, 2, 3), dtype=np.uint8) diff --git a/tests/unit/emulator/test_detector.py b/tests/unit/emulator/test_detector.py new file mode 100644 index 00000000..0e6827de --- /dev/null +++ b/tests/unit/emulator/test_detector.py @@ -0,0 +1,313 @@ +"""测试 autowsgr.emulator.detector。""" + +from __future__ import annotations + +import sys +from unittest.mock import MagicMock, patch + +import pytest + +from autowsgr.emulator.detector import ( + EmulatorCandidate, + _find_adb, + connect_and_list_devices, + detect_emulators, + identify_emulator_type, + list_adb_devices, + prompt_user_select, + resolve_serial, +) +from autowsgr.infra import EmulatorConfig, EmulatorConnectionError +from autowsgr.types import EmulatorType + + +class TestIdentifyEmulatorType: + """模拟器类型识别测试。""" + + @pytest.mark.parametrize( + ('serial', 'expected'), + [ + ('emulator-5554', EmulatorType.leidian), + ('emulator-5556', EmulatorType.leidian), + ('127.0.0.1:16384', EmulatorType.mumu), + ('127.0.0.1:16416', EmulatorType.mumu), + ('127.0.0.1:62001', EmulatorType.mumu), + ('127.0.0.1:5555', EmulatorType.bluestacks), + ('127.0.0.1:5565', EmulatorType.bluestacks), + ('unknown-device', None), + ('', None), + ], + ) + def test_identify(self, serial: str, expected: EmulatorType | None) -> None: + assert identify_emulator_type(serial) == expected + + +class TestEmulatorCandidate: + """EmulatorCandidate 数据类测试。""" + + def test_description_known(self) -> None: + cand = EmulatorCandidate( + serial='emulator-5554', + emulator_type=EmulatorType.leidian, + status='device', + ) + assert '雷电' in cand.description + assert 'emulator-5554' in cand.description + assert 'device' in cand.description + + def test_description_unknown(self) -> None: + cand = EmulatorCandidate( + serial='foo', + emulator_type=None, + status='offline', + ) + assert '未知' in cand.description + + +class TestListAdbDevices: + """adb devices 输出解析测试。""" + + def test_parse_two_devices(self) -> None: + output = 'List of devices attached\nemulator-5554\tdevice\n127.0.0.1:16384\toffline\n' + with patch( + 'autowsgr.emulator.detector.subprocess.run', + return_value=MagicMock(stdout=output), + ): + devices = list_adb_devices(adb_path='/fake/adb') + assert devices == [('emulator-5554', 'device'), ('127.0.0.1:16384', 'offline')] + + def test_parse_empty(self) -> None: + output = 'List of devices attached\n' + with patch( + 'autowsgr.emulator.detector.subprocess.run', + return_value=MagicMock(stdout=output), + ): + devices = list_adb_devices(adb_path='/fake/adb') + assert devices == [] + + def test_timeout(self) -> None: + from subprocess import TimeoutExpired + + with ( + patch( + 'autowsgr.emulator.detector.subprocess.run', + side_effect=TimeoutExpired('adb devices', 10), + ), + pytest.raises(EmulatorConnectionError, match='超时'), + ): + list_adb_devices(adb_path='/fake/adb') + + +class TestDetectEmulators: + """设备探测过滤测试。""" + + def test_filter_offline(self) -> None: + raw = [ + ('emulator-5554', 'device'), + ('127.0.0.1:16384', 'offline'), + ] + with patch( + 'autowsgr.emulator.detector.list_adb_devices', + return_value=raw, + ): + candidates = detect_emulators() + assert len(candidates) == 1 + assert candidates[0].serial == 'emulator-5554' + + def test_sorted(self) -> None: + raw = [ + ('emulator-5556', 'device'), + ('emulator-5554', 'device'), + ] + with patch( + 'autowsgr.emulator.detector.list_adb_devices', + return_value=raw, + ): + candidates = detect_emulators() + assert [c.serial for c in candidates] == ['emulator-5554', 'emulator-5556'] + + +class TestFindAdb: + """adb 路径查找测试。""" + + def test_shutil_which_found(self) -> None: + with patch('autowsgr.emulator.detector.shutil.which', return_value='/usr/bin/adb'): + assert _find_adb() == '/usr/bin/adb' + + def test_shutil_which_not_found_raises(self) -> None: + with ( + patch('autowsgr.emulator.detector.shutil.which', return_value=None), + patch('autowsgr.emulator.detector.sys.platform', 'linux'), + pytest.raises(FileNotFoundError), + ): + _find_adb() + + +class TestResolveSerial: + """resolve_serial 决策逻辑测试。""" + + def test_priority_1_config_serial(self) -> None: + config = EmulatorConfig(serial='my-serial') + assert resolve_serial(config) == 'my-serial' + + def test_priority_2_single_device(self) -> None: + config = EmulatorConfig(serial='') + with patch( + 'autowsgr.emulator.detector.detect_emulators', + return_value=[ + EmulatorCandidate( + serial='emulator-5554', + emulator_type=EmulatorType.leidian, + status='device', + ), + ], + ): + assert resolve_serial(config) == 'emulator-5554' + + def test_priority_5_no_devices(self) -> None: + config = EmulatorConfig(serial='') + with ( + patch( + 'autowsgr.emulator.detector.detect_emulators', + return_value=[], + ), + pytest.raises(EmulatorConnectionError, match='未检测到'), + ): + resolve_serial(config) + + def test_priority_3_type_match_single(self) -> None: + config = EmulatorConfig(serial='', type=EmulatorType.leidian) + with patch( + 'autowsgr.emulator.detector.detect_emulators', + return_value=[ + EmulatorCandidate( + serial='emulator-5554', + emulator_type=EmulatorType.leidian, + status='device', + ), + EmulatorCandidate( + serial='127.0.0.1:16384', + emulator_type=EmulatorType.mumu, + status='device', + ), + ], + ): + assert resolve_serial(config) == 'emulator-5554' + + def test_priority_3_type_match_multiple_prompt(self) -> None: + config = EmulatorConfig(serial='', type=EmulatorType.leidian) + with ( + patch( + 'autowsgr.emulator.detector.detect_emulators', + return_value=[ + EmulatorCandidate( + serial='emulator-5554', + emulator_type=EmulatorType.leidian, + status='device', + ), + EmulatorCandidate( + serial='emulator-5556', + emulator_type=EmulatorType.leidian, + status='device', + ), + ], + ), + patch('autowsgr.emulator.detector.sys.stdin.isatty', return_value=False), + pytest.raises(EmulatorConnectionError, match='无法自动选择'), + ): + resolve_serial(config) + + def test_priority_4_multiple_prompt_tty(self) -> None: + config = EmulatorConfig(serial='') + with ( + patch( + 'autowsgr.emulator.detector.detect_emulators', + return_value=[ + EmulatorCandidate( + serial='emulator-5554', + emulator_type=EmulatorType.leidian, + status='device', + ), + EmulatorCandidate( + serial='127.0.0.1:16384', + emulator_type=EmulatorType.mumu, + status='device', + ), + ], + ), + patch('autowsgr.emulator.detector.sys.stdin.isatty', return_value=True), + patch( + 'autowsgr.emulator.detector.input', + return_value='0', + ), + ): + assert resolve_serial(config) == 'emulator-5554' + + +class TestPromptUserSelect: + """用户交互选择测试。""" + + def test_non_tty_raises(self) -> None: + candidates = [ + EmulatorCandidate('a', None, 'device'), + EmulatorCandidate('b', None, 'device'), + ] + with ( + patch.object(sys.stdin, 'isatty', return_value=False), + pytest.raises( + EmulatorConnectionError, + match='无法自动选择', + ), + ): + prompt_user_select(candidates) + + def test_tty_valid_input(self) -> None: + candidates = [ + EmulatorCandidate('a', EmulatorType.leidian, 'device'), + EmulatorCandidate('b', EmulatorType.mumu, 'device'), + ] + with ( + patch.object(sys.stdin, 'isatty', return_value=True), + patch( + 'autowsgr.emulator.detector.input', + return_value='1', + ), + ): + assert prompt_user_select(candidates) == 'b' + + def test_tty_invalid_then_valid(self) -> None: + candidates = [ + EmulatorCandidate('a', None, 'device'), + ] + with ( + patch.object(sys.stdin, 'isatty', return_value=True), + patch( + 'autowsgr.emulator.detector.input', + side_effect=['abc', '0'], + ), + ): + assert prompt_user_select(candidates) == 'a' + + +class TestConnectAndListDevices: + """adb connect + list 测试。""" + + def test_connect_then_list(self) -> None: + with ( + patch( + 'autowsgr.emulator.detector._find_adb', + return_value='/fake/adb', + ), + patch( + 'autowsgr.emulator.detector.subprocess.run', + return_value=MagicMock(stdout='', stderr=''), + ) as mock_run, + patch( + 'autowsgr.emulator.detector.list_adb_devices', + return_value=[('127.0.0.1:16384', 'device')], + ) as mock_list, + ): + result = connect_and_list_devices() + assert result == [('127.0.0.1:16384', 'device')] + assert mock_run.call_count == 2 # connect x2 + list x0 (mocked) + mock_list.assert_called_once_with('/fake/adb') diff --git a/testing/emulator/test_os_control.py b/tests/unit/emulator/test_os_control.py similarity index 100% rename from testing/emulator/test_os_control.py rename to tests/unit/emulator/test_os_control.py diff --git a/tests/unit/image_resources/__init__.py b/tests/unit/image_resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/image_resources/test__lazy.py b/tests/unit/image_resources/test__lazy.py new file mode 100644 index 00000000..8521f3a5 --- /dev/null +++ b/tests/unit/image_resources/test__lazy.py @@ -0,0 +1,133 @@ +"""测试 autowsgr.image_resources._lazy。""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from autowsgr.image_resources._lazy import IMG_ROOT, LazyTemplate, load_template +from autowsgr.vision import ImageTemplate + + +# ── IMG_ROOT ── + + +def test_img_root_is_path() -> None: + """IMG_ROOT 应为 Path 实例。""" + assert isinstance(IMG_ROOT, Path) + + +def test_img_root_exists_and_is_dir() -> None: + """IMG_ROOT 应存在且为目录。""" + assert IMG_ROOT.exists() + assert IMG_ROOT.is_dir() + + +def test_img_root_ends_with_data_images() -> None: + """IMG_ROOT 路径应以 data/images 结尾。""" + parts = IMG_ROOT.parts + assert parts[-2:] == ('data', 'images') + + +# ── load_template ── + + +def test_load_template_with_real_png() -> None: + """使用真实 PNG 文件调用 load_template 应返回 ImageTemplate。""" + template = load_template('common/confirm_1_540p.png') + assert isinstance(template, ImageTemplate) + assert template.name == 'confirm_1_540p' + + +def test_load_template_with_custom_name() -> None: + """load_template 应支持显式传入 name。""" + template = load_template('common/confirm_1_540p.png', name='custom_name') + assert isinstance(template, ImageTemplate) + assert template.name == 'custom_name' + + +def test_load_template_nonexistent_raises() -> None: + """load_template 对不存在的路径应抛出 FileNotFoundError。""" + with pytest.raises(FileNotFoundError): + load_template('common/nonexistent_file_12345.png') + + +# ── LazyTemplate descriptor ── + + +class _TestTemplateContainer: + """用于测试 LazyTemplate 描述符行为的容器类。""" + + auto_name = LazyTemplate('common/confirm_1_540p.png') + explicit_name = LazyTemplate('common/confirm_1_540p.png', name='explicit') + custom_resolution = LazyTemplate( + 'common/confirm_1_540p.png', + name='hd', + source_resolution=(1920, 1080), + ) + + +def test_lazy_template_first_access_returns_image_template() -> None: + """首次访问 LazyTemplate 应返回 ImageTemplate。""" + container = _TestTemplateContainer() + result = container.auto_name + assert isinstance(result, ImageTemplate) + + +def test_lazy_template_second_access_returns_cached_object() -> None: + """再次访问 LazyTemplate 应返回同一缓存对象。""" + container = _TestTemplateContainer() + first = container.auto_name + second = container.auto_name + assert first is second + + +def test_lazy_template_set_name_auto_derives() -> None: + """未显式指定 name 时,__set_name__ 应从属性名自动推导。""" + container = _TestTemplateContainer() + template = container.auto_name + assert template.name == 'auto_name' + + +def test_lazy_template_explicit_name_overrides() -> None: + """显式指定的 name 应覆盖自动推导的名称。""" + container = _TestTemplateContainer() + template = container.explicit_name + assert template.name == 'explicit' + + +def test_lazy_template_custom_source_resolution() -> None: + """自定义 source_resolution 应体现在返回的模板中。""" + container = _TestTemplateContainer() + template = container.custom_resolution + assert template.source_resolution == (1920, 1080) + + +def test_lazy_template_default_source_resolution() -> None: + """默认 source_resolution 应为 (960, 540)。""" + container = _TestTemplateContainer() + template = container.auto_name + assert template.source_resolution == (960, 540) + + +# ── LazyTemplate.__repr__ ── + + +def test_lazy_template_repr_default_resolution() -> None: + """默认分辨率时 repr 不应包含 source_resolution。""" + descriptor = LazyTemplate('common/confirm_1_540p.png', name='btn') + assert repr(descriptor) == "LazyTemplate('common/confirm_1_540p.png', name='btn')" + + +def test_lazy_template_repr_custom_resolution() -> None: + """自定义分辨率时 repr 应包含 source_resolution。""" + descriptor = LazyTemplate( + 'common/confirm_1_540p.png', + name='btn_hd', + source_resolution=(1920, 1080), + ) + assert ( + repr(descriptor) + == "LazyTemplate('common/confirm_1_540p.png', name='btn_hd', source_resolution=(1920, 1080))" + ) diff --git a/tests/unit/image_resources/test_combat.py b/tests/unit/image_resources/test_combat.py new file mode 100644 index 00000000..72d573dd --- /dev/null +++ b/tests/unit/image_resources/test_combat.py @@ -0,0 +1,93 @@ +"""测试 autowsgr.image_resources.combat.""" + +from __future__ import annotations + +import pytest + +from autowsgr.image_resources.combat import CombatTemplates, _ResultGrade +from autowsgr.vision import ImageTemplate + + +class TestResultGrade: + """_ResultGrade 测试。""" + + def test_all_grades_count(self) -> None: + grades = _ResultGrade.all_grades() + assert len(grades) == 6 + + def test_all_grades_order_and_no_loot(self) -> None: + grades = _ResultGrade.all_grades() + expected = [ + _ResultGrade.SS, + _ResultGrade.S, + _ResultGrade.A, + _ResultGrade.B, + _ResultGrade.C, + _ResultGrade.D, + ] + assert grades == expected + + @pytest.mark.parametrize('grade_attr', ['SS', 'S', 'A', 'B', 'C', 'D']) + def test_grade_is_image_template(self, grade_attr: str) -> None: + template = getattr(_ResultGrade, grade_attr) + assert isinstance(template, ImageTemplate) + + def test_loot_exists_and_is_image_template(self) -> None: + assert isinstance(_ResultGrade.LOOT, ImageTemplate) + + def test_loot_not_in_all_grades(self) -> None: + assert _ResultGrade.LOOT not in _ResultGrade.all_grades() + + +class TestCombatTemplates: + """CombatTemplates 属性存在性与类型测试。""" + + @pytest.mark.parametrize( + 'attr', + [ + 'FORMATION', + 'SPOT_ENEMY', + 'RESULT', + 'FLAGSHIP_DAMAGE', + 'PROCEED', + 'NIGHT_BATTLE', + 'FIGHT_CONDITION', + 'BYPASS', + 'RESULT_PAGE', + 'MISSILE_SUPPORT', + 'MISSILE_ANIMATION', + 'FIGHT_PERIOD', + 'GET_SHIP', + 'GET_ITEM', + 'END_MAP_PAGE', + 'END_BATTLE_PAGE', + 'END_EXERCISE_PAGE', + 'MVP_BADGE', + 'DOCK_FULL', + 'BATTLE_TIMES_EXCEED', + ], + ) + def test_lazy_template_returns_image_template(self, attr: str) -> None: + template = getattr(CombatTemplates, attr) + assert isinstance(template, ImageTemplate) + + def test_result_nested_class(self) -> None: + assert CombatTemplates.Result is _ResultGrade + + def test_key_attributes_exist(self) -> None: + assert hasattr(CombatTemplates, 'FORMATION') + assert hasattr(CombatTemplates, 'SPOT_ENEMY') + assert hasattr(CombatTemplates, 'RESULT') + assert hasattr(CombatTemplates, 'NIGHT_BATTLE') + assert hasattr(CombatTemplates, 'END_MAP_PAGE') + assert hasattr(CombatTemplates, 'DOCK_FULL') + assert hasattr(CombatTemplates, 'BATTLE_TIMES_EXCEED') + + def test_lazy_loading_consistency(self) -> None: + first = CombatTemplates.FORMATION + second = CombatTemplates.FORMATION + assert first is second + + first_result = CombatTemplates.Result.SS + second_result = CombatTemplates.Result.SS + assert first_result is second_result diff --git a/tests/unit/image_resources/test_keys.py b/tests/unit/image_resources/test_keys.py new file mode 100644 index 00000000..df688c5d --- /dev/null +++ b/tests/unit/image_resources/test_keys.py @@ -0,0 +1,83 @@ +"""Tests for autowsgr.image_resources.keys.""" + +from __future__ import annotations + +import pytest + +from autowsgr.image_resources.keys import ( + RESULT_GRADE_KEYS, + TemplateKey, + get_templates, +) +from autowsgr.vision import ImageTemplate + + +EXPECTED_VALUES: dict[TemplateKey, str] = { + TemplateKey.FORMATION: 'formation', + TemplateKey.SPOT_ENEMY: 'spot_enemy', + TemplateKey.RESULT: 'result', + TemplateKey.FLAGSHIP_DAMAGE: 'flagship_damage', + TemplateKey.PROCEED: 'proceed', + TemplateKey.NIGHT_BATTLE: 'night_battle', + TemplateKey.FIGHT_CONDITION: 'fight_condition', + TemplateKey.BYPASS: 'bypass', + TemplateKey.RESULT_PAGE: 'result_page', + TemplateKey.MISSILE_SUPPORT: 'missile_support', + TemplateKey.MISSILE_ANIMATION: 'missile_animation', + TemplateKey.FIGHT_PERIOD: 'fight_period', + TemplateKey.GET_SHIP: 'get_ship', + TemplateKey.GET_ITEM: 'get_item', + TemplateKey.GET_SHIP_OR_ITEM: 'get_ship_or_item', + TemplateKey.END_MAP_PAGE: 'end_map_page', + TemplateKey.END_BATTLE_PAGE: 'end_battle_page', + TemplateKey.END_EXERCISE_PAGE: 'end_exercise_page', + TemplateKey.DOCK_FULL: 'dock_full', + TemplateKey.BATTLE_TIMES_EXCEED: 'battle_times_exceed', + TemplateKey.GRADE_SS: 'grade_ss', + TemplateKey.GRADE_S: 'grade_s', + TemplateKey.GRADE_A: 'grade_a', + TemplateKey.GRADE_B: 'grade_b', + TemplateKey.GRADE_C: 'grade_c', + TemplateKey.GRADE_D: 'grade_d', + TemplateKey.GRADE_LOOT: 'grade_loot', +} + + +@pytest.mark.parametrize(('member', 'expected'), list(EXPECTED_VALUES.items())) +def test_member_string_values(member: TemplateKey, expected: str) -> None: + """Key TemplateKey members have expected string values.""" + assert member.value == expected + + +@pytest.mark.parametrize('member', list(TemplateKey)) +def test_templates_non_empty_list(member: TemplateKey) -> None: + """Every TemplateKey member's .templates returns a non-empty list of ImageTemplate.""" + templates = member.templates + assert isinstance(templates, list) + assert len(templates) > 0 + assert all(isinstance(t, ImageTemplate) for t in templates) + + +@pytest.mark.parametrize('member', list(TemplateKey)) +def test_get_templates_equivalent_to_property(member: TemplateKey) -> None: + """get_templates(key) is equivalent to key.templates.""" + assert get_templates(member) is member.templates + + +def test_result_grade_keys() -> None: + """RESULT_GRADE_KEYS keys are {'SS','S','A','B','C','D'}.""" + assert set(RESULT_GRADE_KEYS.keys()) == {'SS', 'S', 'A', 'B', 'C', 'D'} + + +def test_result_grade_values_are_valid_members() -> None: + """RESULT_GRADE_KEYS values are valid TemplateKey members.""" + for value in RESULT_GRADE_KEYS.values(): + assert isinstance(value, TemplateKey) + assert value in TemplateKey + + +def test_get_ship_or_item_has_exactly_two_templates() -> None: + """TemplateKey.GET_SHIP_OR_ITEM.templates returns exactly 2 templates.""" + templates = TemplateKey.GET_SHIP_OR_ITEM.templates + assert len(templates) == 2 + assert all(isinstance(t, ImageTemplate) for t in templates) diff --git a/tests/unit/image_resources/test_ops.py b/tests/unit/image_resources/test_ops.py new file mode 100644 index 00000000..2fce87e1 --- /dev/null +++ b/tests/unit/image_resources/test_ops.py @@ -0,0 +1,136 @@ +"""Tests for autowsgr.image_resources.ops.""" + +from __future__ import annotations + +import pytest + +from autowsgr.image_resources.ops import ( + BackButton, + Build, + Confirm, + Cook, + Decisive, + Error, + Fight, + FightResult, + GameUI, + Symbol, + Templates, +) +from autowsgr.vision import ImageTemplate + + +@pytest.mark.parametrize( + 'template', + [ + Templates.Cook.COOK_BUTTON, + Templates.Cook.HAVE_COOK, + Templates.Cook.NO_TIMES, + Templates.Build.SHIP_START, + Templates.Build.SHIP_COMPLETE, + Templates.Build.SHIP_FAST, + Templates.Build.SHIP_FULL_DEPOT, + Templates.Build.EQUIP_START, + Templates.Build.EQUIP_COMPLETE, + Templates.Build.EQUIP_FAST, + Templates.Build.EQUIP_FULL_DEPOT, + Templates.Build.RESOURCE, + Templates.GameUI.REWARD_COLLECT_ALL, + Templates.GameUI.REWARD_COLLECT, + Templates.Confirm.CONFIRM_1, + Templates.Confirm.CONFIRM_2, + Templates.Confirm.CONFIRM_3, + Templates.Confirm.CONFIRM_4, + Templates.Confirm.CONFIRM_5, + Templates.Fight.NIGHT_BATTLE, + Templates.Fight.RESULT_PAGE, + Templates.FightResult.SS, + Templates.FightResult.S, + Templates.FightResult.A, + Templates.FightResult.B, + Templates.FightResult.C, + Templates.FightResult.D, + Templates.FightResult.LOOT, + Templates.ChooseShip.PAGE_1, + Templates.ChooseShip.PAGE_2, + Templates.ChooseShip.PAGE_3, + Templates.ChooseShip.PAGE_4, + Templates.Symbol.GET_SHIP, + Templates.Symbol.GET_ITEM, + Templates.Symbol.CLICK_TO_CONTINUE, + Templates.Error.BAD_NETWORK_1, + Templates.Error.BAD_NETWORK_2, + Templates.Error.NETWORK_RETRY, + Templates.Error.REMOTE_LOGIN, + Templates.Error.REMOTE_LOGIN_CONFIRM, + Templates.Decisive.USE_LAST_FLEET, + Templates.Decisive.ENTRY_CANT_FIGHT, + Templates.Decisive.ENTRY_CHALLENGING, + Templates.Decisive.ENTRY_REFRESHED, + Templates.Decisive.ENTRY_REFRESH, + ], +) +def test_leaf_attributes_return_image_template(template: object) -> None: + """Key leaf attributes exist and return ImageTemplate instances.""" + assert isinstance(template, ImageTemplate) + + +def test_confirm_all_returns_five_image_templates() -> None: + """Confirm.all() returns a list of exactly 5 ImageTemplates.""" + templates = Confirm.all() + assert len(templates) == 5 + assert all(isinstance(t, ImageTemplate) for t in templates) + + +def test_fight_result_all_grades_returns_six_image_templates() -> None: + """FightResult.all_grades() returns SS→D (6 items, no LOOT).""" + templates = FightResult.all_grades() + assert len(templates) == 6 + assert all(isinstance(t, ImageTemplate) for t in templates) + assert FightResult.LOOT not in templates + + +@pytest.mark.xfail( + raises=FileNotFoundError, + reason='Back button images missing from data/images/common/', +) +def test_back_button_all_returns_non_empty_list() -> None: + """BackButton.all() returns a non-empty list of ImageTemplates.""" + templates = BackButton.all() + assert len(templates) > 0 + assert all(isinstance(t, ImageTemplate) for t in templates) + + +def test_decisive_entry_status_templates_returns_four() -> None: + """Decisive.entry_status_templates() returns 4 ImageTemplates.""" + templates = Decisive.entry_status_templates() + assert len(templates) == 4 + assert all(isinstance(t, ImageTemplate) for t in templates) + + +def test_fight_result_pages_returns_list() -> None: + """Fight.result_pages() returns a list of ImageTemplates.""" + templates = Fight.result_pages() + assert len(templates) > 0 + assert all(isinstance(t, ImageTemplate) for t in templates) + + +def test_templates_aggregates_all_sub_containers() -> None: + """Templates exposes all sub-container classes.""" + assert Templates.Cook is Cook + assert Templates.GameUI is GameUI + assert Templates.Confirm is Confirm + assert Templates.Build is Build + assert Templates.Fight is Fight + assert Templates.FightResult is FightResult + assert Templates.Symbol is Symbol + assert Templates.BackButton is BackButton + assert Templates.Error is Error + assert Templates.Decisive is Decisive + + +def test_lazy_loading_consistency() -> None: + """Accessing the same LazyTemplate twice returns the identical object.""" + first = Templates.Cook.COOK_BUTTON + second = Templates.Cook.COOK_BUTTON + assert first is second diff --git a/tests/unit/infra/__init__.py b/tests/unit/infra/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/testing/infra/test_config.py b/tests/unit/infra/test_config.py similarity index 100% rename from testing/infra/test_config.py rename to tests/unit/infra/test_config.py diff --git a/testing/infra/test_exceptions.py b/tests/unit/infra/test_exceptions.py similarity index 100% rename from testing/infra/test_exceptions.py rename to tests/unit/infra/test_exceptions.py diff --git a/testing/infra/test_file_utils.py b/tests/unit/infra/test_file_utils.py similarity index 100% rename from testing/infra/test_file_utils.py rename to tests/unit/infra/test_file_utils.py diff --git a/tests/unit/infra/test_logger.py b/tests/unit/infra/test_logger.py new file mode 100644 index 00000000..d88cbfc9 --- /dev/null +++ b/tests/unit/infra/test_logger.py @@ -0,0 +1,104 @@ +"""测试 autowsgr.infra.logger。""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import numpy as np + +from autowsgr.infra.logger import ( + _make_channel_filter, + _resolve_channel_level, + caller_info, + get_logger, + save_image, + setup_logger, +) + + +def test_get_logger_returns_bound_logger() -> None: + """get_logger 应返回绑定了 ch 的 logger。""" + log = get_logger('test.channel') + assert log is not None + + +def _helper_for_caller_info() -> str: + """辅助函数,用于验证 caller_info 能正确追踪调用者。""" + return caller_info() + + +def test_caller_info_returns_string() -> None: + """caller_info 应返回包含文件名与函数名的字符串。""" + info = _helper_for_caller_info() + assert isinstance(info, str) + assert 'test_logger.py' in info + assert 'test_caller_info_returns_string' in info + + +def test_resolve_channel_level_empty() -> None: + """无配置时应返回默认 None。""" + assert _resolve_channel_level('combat') is None + + +def test_resolve_channel_level_exact() -> None: + """精确匹配应返回对应级别。""" + with patch('autowsgr.infra.logger._channel_levels', {'combat': 20}): + assert _resolve_channel_level('combat') == 20 + + +def test_resolve_channel_level_prefix() -> None: + """前缀匹配应返回最长前缀对应的级别。""" + with patch( + 'autowsgr.infra.logger._channel_levels', + {'vision': 10, 'vision.ocr': 30}, + ): + assert _resolve_channel_level('vision.ocr') == 30 + assert _resolve_channel_level('vision.pixel') == 10 + + +def test_make_channel_filter_level() -> None: + """filter 应阻止低于 sink 级别的消息。""" + f = _make_channel_filter(20) + record = {'level': MagicMock(no=10), 'extra': {}} + assert f(record) is False + record['level'].no = 20 + assert f(record) is True + + +def test_make_channel_filter_channel() -> None: + """filter 应阻止低于通道级别的消息。""" + with patch( + 'autowsgr.infra.logger._channel_levels', + {'combat': 30}, + ): + f = _make_channel_filter(10) + record = {'level': MagicMock(no=20), 'extra': {'ch': 'combat'}} + assert f(record) is False + record['level'].no = 30 + assert f(record) is True + + +def test_setup_logger_no_crash() -> None: + """setup_logger 应能正常完成初始化。""" + setup_logger(log_dir=None, level='INFO') + # loguru 全局状态已变更,后续测试仍可正常工作 + + +def test_save_image_with_mock_cv2() -> None: + """save_image 应在 cv2 成功编码时返回路径。""" + img = np.zeros((10, 10, 3), dtype=np.uint8) + target = Path('/tmp/test_images') + mock_buf = MagicMock() + mock_buf.tobytes.return_value = b'PNG' + with ( + patch( + 'autowsgr.infra.logger.cv2.cvtColor', + return_value=img, + ), + patch('autowsgr.infra.logger.cv2.imencode', return_value=(True, mock_buf)), + ): + path = save_image(img, tag='test', img_dir=target) + assert path is not None + assert path.name.startswith('test_') + assert path.suffix == '.png' diff --git a/tests/unit/ops/__init__.py b/tests/unit/ops/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/ops/decisive/__init__.py b/tests/unit/ops/decisive/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/ops/decisive/test_base.py b/tests/unit/ops/decisive/test_base.py new file mode 100644 index 00000000..f130c53a --- /dev/null +++ b/tests/unit/ops/decisive/test_base.py @@ -0,0 +1,153 @@ +"""测试 autowsgr.ops.decisive.base。""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +import pytest + +from autowsgr.constants import DECISIVE_SKILL_NAMES +from autowsgr.ops.decisive.base import DecisiveBase + + +if TYPE_CHECKING: + from collections.abc import Iterator + + +def _make_ctx(decisive_battle: object | None = None) -> MagicMock: + ctx = MagicMock() + ctx.config.decisive_battle = decisive_battle + ctx.ctrl = MagicMock() + ctx.ocr = MagicMock() + return ctx + + +class TestDecisiveBaseInit: + """DecisiveBase.__init__ 测试。""" + + @pytest.fixture(autouse=True) + def _setup_patches(self) -> Iterator[None]: + self.p_update_shipnames = patch('autowsgr.ops.decisive.base.update_shipnames') + self.p_config_cls = patch('autowsgr.ops.decisive.base.DecisiveConfig') + self.p_state_cls = patch('autowsgr.ops.decisive.base.DecisiveState') + self.p_logic_cls = patch('autowsgr.ops.decisive.base.DecisiveLogic') + self.p_battle_page_cls = patch('autowsgr.ops.decisive.base.DecisiveBattlePage') + self.p_map_cls = patch('autowsgr.ops.decisive.base.DecisiveMapController') + self.p_log_warning = patch('autowsgr.ops.decisive.base._log.warning') + + self.mock_update_shipnames = self.p_update_shipnames.start() + self.mock_config_cls = self.p_config_cls.start() + self.mock_state_cls = self.p_state_cls.start() + self.mock_logic_cls = self.p_logic_cls.start() + self.mock_battle_page_cls = self.p_battle_page_cls.start() + self.mock_map_cls = self.p_map_cls.start() + self.mock_log_warning = self.p_log_warning.start() + + yield + + patch.stopall() + + @pytest.mark.parametrize( + ('ctx_config_dump', 'expected_base'), + [ + (None, {}), + ({'chapter': 3, 'level1': ['X'] * 6}, {'chapter': 3, 'level1': ['X'] * 6}), + ], + ) + def test_config_merge( + self, + ctx_config_dump: dict[str, object] | None, + expected_base: dict[str, object], + ) -> None: + if ctx_config_dump is not None: + ctx_decisive = MagicMock() + ctx_decisive.model_dump.return_value = ctx_config_dump + ctx = _make_ctx(decisive_battle=ctx_decisive) + else: + ctx = _make_ctx(decisive_battle=None) + + config = MagicMock() + config.model_dump.return_value = {'chapter': 5, 'level2': ['Y']} + + self.mock_config_cls.return_value.level1 = ['A'] * 6 + self.mock_config_cls.return_value.level2 = ['B'] + + DecisiveBase(ctx, config) + + config.model_dump.assert_called_once_with(exclude_unset=True) + self.mock_config_cls.assert_called_once_with( + **{**expected_base, 'chapter': 5, 'level2': ['Y']}, + ) + + def test_level1_short_warning(self) -> None: + ctx = _make_ctx(decisive_battle=None) + config = MagicMock() + config.model_dump.return_value = {} + + self.mock_config_cls.return_value.level1 = ['A'] * 5 + self.mock_config_cls.return_value.level2 = ['B'] + + DecisiveBase(ctx, config) + + self.mock_log_warning.assert_called_once() + + def test_update_shipnames_called(self) -> None: + ctx = _make_ctx(decisive_battle=None) + config = MagicMock() + config.model_dump.return_value = {} + + self.mock_config_cls.return_value.level1 = ['A1', 'A2'] + self.mock_config_cls.return_value.level2 = ['B1'] + + DecisiveBase(ctx, config) + + self.mock_update_shipnames.assert_called_once_with( + ['A1', 'A2', 'B1'] + DECISIVE_SKILL_NAMES, + ) + + def test_state_property_returns_created_state(self) -> None: + ctx = _make_ctx(decisive_battle=None) + config = MagicMock() + config.model_dump.return_value = {} + + expected_state = self.mock_state_cls.return_value + + obj = DecisiveBase(ctx, config) + + assert obj.state is expected_state + + def test_attributes_initialized(self) -> None: + ctx = _make_ctx(decisive_battle=None) + config = MagicMock() + config.model_dump.return_value = {} + + merged_config = self.mock_config_cls.return_value + expected_state = self.mock_state_cls.return_value + expected_logic = self.mock_logic_cls.return_value + expected_battle_page = self.mock_battle_page_cls.return_value + expected_map = self.mock_map_cls.return_value + + obj = DecisiveBase(ctx, config) + + assert obj._ctx is ctx + assert obj._ctrl is ctx.ctrl + assert obj._ocr is ctx.ocr + assert obj._config is merged_config + assert obj._state is expected_state + assert obj._logic is expected_logic + assert obj._battle_page is expected_battle_page + assert obj._map is expected_map + assert obj._resume_mode is False + assert obj._has_chosen_fleet is False + assert obj._wait_deadline == 0.0 + assert obj._use_last_fleet_attempts == 0 + + self.mock_state_cls.assert_called_once_with(chapter=merged_config.chapter) + self.mock_logic_cls.assert_called_once_with( + merged_config, + expected_state, + ctx=ctx, + ) + self.mock_battle_page_cls.assert_called_once_with(ctx, ocr=ctx.ocr) + self.mock_map_cls.assert_called_once_with(ctx, merged_config) diff --git a/tests/unit/ops/decisive/test_chapter.py b/tests/unit/ops/decisive/test_chapter.py new file mode 100644 index 00000000..fa4f2bdf --- /dev/null +++ b/tests/unit/ops/decisive/test_chapter.py @@ -0,0 +1,83 @@ +"""测试 autowsgr.ops.decisive.chapter。""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from autowsgr.infra import DockFullError +from autowsgr.ops.decisive.chapter import DecisiveChapterOps + + +class TestDecisiveChapterOps: + """DecisiveChapterOps 测试。""" + + def setup_method(self) -> None: + self.ctx = MagicMock() + self.ctx.config.decisive_battle = None + + def _make_config(self, **kwargs: object) -> tuple[MagicMock, MagicMock]: + config = MagicMock() + config.model_dump.return_value = {} + config.level1 = ['a'] * 6 + config.level2 = ['b'] * 6 + for k, v in kwargs.items(): + setattr(config, k, v) + merged = MagicMock() + for k, v in kwargs.items(): + setattr(merged, k, v) + merged.level1 = ['a'] * 6 + merged.level2 = ['b'] * 6 + return config, merged + + def test_init(self) -> None: + config, merged = self._make_config(chapter=3) + with ( + patch('autowsgr.ops.decisive.base.update_shipnames') as mock_update, + patch('autowsgr.ops.decisive.base.DecisiveBattlePage'), + patch('autowsgr.ops.decisive.base.DecisiveMapController'), + patch('autowsgr.ops.decisive.base.DecisiveConfig', return_value=merged), + ): + ops = DecisiveChapterOps(self.ctx, config) + assert ops.state.chapter == 3 + mock_update.assert_called_once() + + def test_prepare_entry_state(self) -> None: + config, merged = self._make_config(chapter=2) + with ( + patch('autowsgr.ops.decisive.base.update_shipnames'), + patch('autowsgr.ops.decisive.base.DecisiveBattlePage'), + patch('autowsgr.ops.decisive.base.DecisiveMapController'), + patch('autowsgr.ops.decisive.base.DecisiveConfig', return_value=merged), + patch('autowsgr.ops.navigate.goto_page') as mock_goto, + ): + ops = DecisiveChapterOps(self.ctx, config) + ops._prepare_entry_state() + mock_goto.assert_called_once() + ops._battle_page.navigate_to_chapter.assert_called_once_with(2) + + def test_dock_full_destroy_enabled(self) -> None: + config, merged = self._make_config(chapter=1, full_destroy=True) + with ( + patch('autowsgr.ops.decisive.base.update_shipnames'), + patch('autowsgr.ops.decisive.base.DecisiveBattlePage'), + patch('autowsgr.ops.decisive.base.DecisiveMapController'), + patch('autowsgr.ops.decisive.base.DecisiveConfig', return_value=merged), + patch('autowsgr.ops.destroy.destroy_ships') as mock_destroy, + ): + ops = DecisiveChapterOps(self.ctx, config) + ops._do_dock_full_destroy() + mock_destroy.assert_called_once() + + def test_dock_full_destroy_disabled(self) -> None: + config, merged = self._make_config(chapter=1, full_destroy=False) + with ( + patch('autowsgr.ops.decisive.base.update_shipnames'), + patch('autowsgr.ops.decisive.base.DecisiveBattlePage'), + patch('autowsgr.ops.decisive.base.DecisiveMapController'), + patch('autowsgr.ops.decisive.base.DecisiveConfig', return_value=merged), + ): + ops = DecisiveChapterOps(self.ctx, config) + with pytest.raises(DockFullError, match='船坞已满'): + ops._do_dock_full_destroy() diff --git a/tests/unit/ops/decisive/test_config.py b/tests/unit/ops/decisive/test_config.py new file mode 100644 index 00000000..79f6706d --- /dev/null +++ b/tests/unit/ops/decisive/test_config.py @@ -0,0 +1,68 @@ +"""测试 autowsgr.ops.decisive.config。""" + +from __future__ import annotations + +import pytest + +from autowsgr.ops.decisive.config import MapData + + +class TestMapDataGetStageEndNode: + """MapData.get_stage_end_node 测试。""" + + def test_chapter1_stage1(self) -> None: + assert MapData.get_stage_end_node(1, 1) == 'F' + + def test_chapter1_stage3(self) -> None: + assert MapData.get_stage_end_node(1, 3) == 'H' + + def test_chapter6_stage3(self) -> None: + assert MapData.get_stage_end_node(6, 3) == 'J' + + def test_invalid_chapter_raises(self) -> None: + with pytest.raises(ValueError, match='无效'): + MapData.get_stage_end_node(0, 1) + + def test_invalid_stage_raises(self) -> None: + with pytest.raises(ValueError, match='无效'): + MapData.get_stage_end_node(1, 4) + + +class TestMapDataIsStageEnd: + """MapData.is_stage_end 测试。""" + + def test_is_end(self) -> None: + assert MapData.is_stage_end(1, 1, 'F') is True + + def test_is_not_end(self) -> None: + assert MapData.is_stage_end(1, 1, 'A') is False + + +class TestMapDataGetKeyPoints: + """MapData.get_key_points 测试。""" + + def test_chapter1_stage2(self) -> None: + kp = MapData.get_key_points(1, 2) + assert 'B' in kp + assert 'F' in kp + assert 'H' in kp + + def test_invalid_returns_empty(self) -> None: + assert MapData.get_key_points(1, 10) == set() + + +class TestMapDataIsKeyPoint: + """MapData.is_key_point 测试。""" + + def test_is_key_point(self) -> None: + assert MapData.is_key_point(1, 2, 'B') is True + + def test_is_not_key_point(self) -> None: + assert MapData.is_key_point(1, 2, 'A') is False + + +class TestMapDataGetEnemy: + """MapData.get_enemy 测试。""" + + def test_invalid_returns_empty(self) -> None: + assert MapData.get_enemy(99, 1, 'A') == [] diff --git a/tests/unit/ops/decisive/test_controller.py b/tests/unit/ops/decisive/test_controller.py new file mode 100644 index 00000000..0a84462f --- /dev/null +++ b/tests/unit/ops/decisive/test_controller.py @@ -0,0 +1,246 @@ +"""测试 autowsgr.ops.decisive.controller。""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +from autowsgr.ops.decisive.base import DecisiveBase +from autowsgr.ops.decisive.controller import DecisiveController, DecisiveResult +from autowsgr.ops.decisive.state import DecisiveState +from autowsgr.types import DecisivePhase + + +if TYPE_CHECKING: + from unittest.mock import MagicMock as _MagicMock + + +class TestDecisiveResult: + """DecisiveResult 枚举测试。""" + + def test_enum_values(self) -> None: + assert DecisiveResult.CHAPTER_CLEAR.value == 'chapter_clear' + assert DecisiveResult.RETREAT.value == 'retreat' + assert DecisiveResult.LEAVE.value == 'leave' + assert DecisiveResult.ERROR.value == 'error' + + +class TestDecisiveControllerRun: + """DecisiveController.run() 测试。""" + + def _make_controller(self) -> DecisiveController: + with patch.object(DecisiveBase, '__init__', lambda _self, _ctx, _config: None): + ctrl = DecisiveController(MagicMock(), MagicMock()) + ctrl._state = DecisiveState(chapter=2) + ctrl._config = MagicMock() + ctrl._config.chapter = 2 + return ctrl + + def test_run_success(self) -> None: + ctrl = self._make_controller() + object.__setattr__(ctrl, '_prepare_entry_state', MagicMock()) + object.__setattr__(ctrl, '_main_loop', MagicMock(return_value=DecisiveResult.CHAPTER_CLEAR)) + + result = ctrl.run() + + assert result == DecisiveResult.CHAPTER_CLEAR + assert ctrl._state.phase == DecisivePhase.ENTER_MAP + assert ctrl._state.chapter == 2 + assert ctrl._state.stage == 0 + assert ctrl._state.node == 'U' + assert ctrl._resume_mode is True + assert ctrl._has_chosen_fleet is False + ctrl._prepare_entry_state.assert_called_once() + ctrl._main_loop.assert_called_once() + + def test_run_exception(self) -> None: + ctrl = self._make_controller() + object.__setattr__(ctrl, '_prepare_entry_state', MagicMock()) + object.__setattr__(ctrl, '_main_loop', MagicMock(side_effect=RuntimeError('boom'))) + + result = ctrl.run() + + assert result == DecisiveResult.ERROR + assert ctrl._state.phase == DecisivePhase.FINISHED + ctrl._main_loop.assert_called_once() + + +class TestDecisiveControllerRunForTimes: + """DecisiveController.run_for_times() 测试。""" + + def _make_controller(self) -> DecisiveController: + with patch.object(DecisiveBase, '__init__', lambda _self, _ctx, _config: None): + ctrl = DecisiveController(MagicMock(), MagicMock()) + ctrl._state = DecisiveState(chapter=1) + ctrl._config = MagicMock() + return ctrl + + def test_run_for_times(self) -> None: + ctrl = self._make_controller() + object.__setattr__( + ctrl, + 'run', + MagicMock( + side_effect=[ + DecisiveResult.CHAPTER_CLEAR, + DecisiveResult.CHAPTER_CLEAR, + ] + ), + ) + + results = ctrl.run_for_times(2) + + assert len(results) == 2 + assert results == [DecisiveResult.CHAPTER_CLEAR, DecisiveResult.CHAPTER_CLEAR] + assert ctrl.run.call_count == 2 + + def test_run_for_times_breaks_on_leave(self) -> None: + ctrl = self._make_controller() + object.__setattr__( + ctrl, + 'run', + MagicMock( + side_effect=[ + DecisiveResult.CHAPTER_CLEAR, + DecisiveResult.LEAVE, + DecisiveResult.CHAPTER_CLEAR, + ] + ), + ) + + results = ctrl.run_for_times(3) + + assert len(results) == 2 + assert results == [DecisiveResult.CHAPTER_CLEAR, DecisiveResult.LEAVE] + assert ctrl.run.call_count == 2 + + def test_run_for_times_breaks_on_error(self) -> None: + ctrl = self._make_controller() + object.__setattr__( + ctrl, + 'run', + MagicMock( + side_effect=[ + DecisiveResult.CHAPTER_CLEAR, + DecisiveResult.ERROR, + ] + ), + ) + + results = ctrl.run_for_times(3) + + assert len(results) == 2 + assert results == [DecisiveResult.CHAPTER_CLEAR, DecisiveResult.ERROR] + assert ctrl.run.call_count == 2 + + +class _TestableController(DecisiveController): + """用于测试 _main_loop 的可控子类。""" + + def __init__(self, state: DecisiveState) -> None: + self._state = state + self._config = MagicMock() + self._execute_retreat: _MagicMock = MagicMock() + self._execute_leave: _MagicMock = MagicMock() + self._handle_enter_map: _MagicMock = MagicMock() + self._handle_waiting_for_map: _MagicMock = MagicMock() + self._handle_use_last_fleet: _MagicMock = MagicMock() + self._handle_dock_full: _MagicMock = MagicMock() + self._handle_choose_fleet: _MagicMock = MagicMock() + self._handle_advance_choice: _MagicMock = MagicMock() + self._handle_prepare_combat: _MagicMock = MagicMock() + self._handle_combat: _MagicMock = MagicMock() + self._handle_node_result: _MagicMock = MagicMock() + self._handle_stage_clear: _MagicMock = MagicMock() + + +class TestDecisiveControllerMainLoop: + """DecisiveController._main_loop() 测试。""" + + def test_chapter_clear(self) -> None: + state = DecisiveState() + state.phase = DecisivePhase.CHAPTER_CLEAR + ctrl = _TestableController(state) + + result = ctrl._main_loop() + + assert result == DecisiveResult.CHAPTER_CLEAR + assert state.phase == DecisivePhase.FINISHED + ctrl._execute_retreat.assert_not_called() + ctrl._execute_leave.assert_not_called() + ctrl._handle_enter_map.assert_not_called() + + def test_retreat(self) -> None: + state = DecisiveState(chapter=3) + state.stage = 2 + state.node = 'C' + state.phase = DecisivePhase.RETREAT + ctrl = _TestableController(state) + object.__setattr__( + ctrl, + '_handle_enter_map', + MagicMock( + side_effect=lambda: setattr( + state, + 'phase', + DecisivePhase.FINISHED, + ) + ), + ) + + result = ctrl._main_loop() + + assert result == DecisiveResult.CHAPTER_CLEAR + ctrl._execute_retreat.assert_called_once() + assert state.stage == 0 + assert state.node == 'U' + assert state.chapter == 3 + ctrl._handle_enter_map.assert_called_once() + + def test_leave(self) -> None: + state = DecisiveState() + state.phase = DecisivePhase.LEAVE + ctrl = _TestableController(state) + + result = ctrl._main_loop() + + assert result == DecisiveResult.LEAVE + assert state.phase == DecisivePhase.FINISHED + ctrl._execute_leave.assert_called_once() + ctrl._execute_retreat.assert_not_called() + + def test_unknown_phase(self) -> None: + state = DecisiveState() + state.phase = DecisivePhase.INIT + ctrl = _TestableController(state) + + result = ctrl._main_loop() + + assert result == DecisiveResult.ERROR + assert state.phase == DecisivePhase.FINISHED + ctrl._execute_leave.assert_not_called() + ctrl._execute_retreat.assert_not_called() + ctrl._handle_enter_map.assert_not_called() + + def test_known_handler_phase(self) -> None: + state = DecisiveState() + state.phase = DecisivePhase.ENTER_MAP + ctrl = _TestableController(state) + object.__setattr__( + ctrl, + '_handle_enter_map', + MagicMock( + side_effect=lambda: setattr( + state, + 'phase', + DecisivePhase.FINISHED, + ) + ), + ) + + result = ctrl._main_loop() + + assert result == DecisiveResult.CHAPTER_CLEAR + ctrl._handle_enter_map.assert_called_once() + ctrl._execute_leave.assert_not_called() + ctrl._execute_retreat.assert_not_called() diff --git a/tests/unit/ops/decisive/test_handlers.py b/tests/unit/ops/decisive/test_handlers.py new file mode 100644 index 00000000..77b33301 --- /dev/null +++ b/tests/unit/ops/decisive/test_handlers.py @@ -0,0 +1,146 @@ +"""测试 autowsgr.ops.decisive.handlers。""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import numpy as np +import pytest + +from autowsgr.ops.decisive.handlers import DecisivePhaseHandlers +from autowsgr.ops.decisive.state import DecisiveState +from autowsgr.types import FleetSelection, ShipDamageState + + +class _TestableHandlers(DecisivePhaseHandlers): + """绕过 DecisiveBase.__init__ 的可测试子类。""" + + def __init__(self) -> None: + pass + + +@pytest.fixture +def handler() -> _TestableHandlers: + """返回已注入 mock 依赖的 _TestableHandlers 实例。""" + h = _TestableHandlers() + h._map = MagicMock() + h._ctx = MagicMock() + h._state = DecisiveState() + return h + + +# ═══════════════════════════════════════════════════════════════════════════════ +# _sync_ship_states +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestSyncShipStates: + """_sync_ship_states 测试。""" + + def test_normal_case_skips_no_ship(self, handler: _TestableHandlers) -> None: + handler._state.ship_stats = [ + ShipDamageState.NORMAL, + ShipDamageState.MODERATE, + ShipDamageState.NO_SHIP, + ShipDamageState.SEVERE, + ShipDamageState.NORMAL, + ShipDamageState.NO_SHIP, + ] + handler._state.fleet = ['', 'A', 'B', 'C', 'D', 'E', 'F'] + handler._sync_ship_states() + assert handler._ctx.update_ship_damage.call_count == 4 + handler._ctx.update_ship_damage.assert_any_call('A', ShipDamageState.NORMAL) + handler._ctx.update_ship_damage.assert_any_call('B', ShipDamageState.MODERATE) + handler._ctx.update_ship_damage.assert_any_call('D', ShipDamageState.SEVERE) + handler._ctx.update_ship_damage.assert_any_call('E', ShipDamageState.NORMAL) + + def test_empty_fleet_no_calls(self, handler: _TestableHandlers) -> None: + handler._state.ship_stats = [ShipDamageState.NORMAL] * 6 + handler._state.fleet = [''] * 7 + handler._sync_ship_states() + handler._ctx.update_ship_damage.assert_not_called() + + def test_partial_fleet_only_valid_entries(self, handler: _TestableHandlers) -> None: + handler._state.ship_stats = [ + ShipDamageState.NORMAL, + ShipDamageState.MODERATE, + ShipDamageState.SEVERE, + ShipDamageState.NORMAL, + ShipDamageState.NORMAL, + ShipDamageState.NORMAL, + ] + handler._state.fleet = ['', 'X', 'Y', '', '', '', ''] + handler._sync_ship_states() + assert handler._ctx.update_ship_damage.call_count == 2 + handler._ctx.update_ship_damage.assert_any_call('X', ShipDamageState.NORMAL) + handler._ctx.update_ship_damage.assert_any_call('Y', ShipDamageState.MODERATE) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# _recognize_fleet_options_with_retry +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestRecognizeFleetOptionsWithRetry: + """_recognize_fleet_options_with_retry 测试。""" + + def _make_screen(self) -> np.ndarray: + return np.zeros((10, 10, 3), dtype=np.uint8) + + def test_first_attempt_success(self, handler: _TestableHandlers) -> None: + screen = self._make_screen() + handler._map.wait_for_fleet_overlay_stable.return_value = screen + selection = FleetSelection('A', 3, (0.0, 0.0)) + handler._map.recognize_fleet_options.return_value = (5, {'A': selection}) + + result = handler._recognize_fleet_options_with_retry(fallback_score=10) + + assert result == (screen, 5, {'A': selection}) + handler._map.wait_for_fleet_overlay_stable.assert_called_once() + handler._map.recognize_fleet_options.assert_called_once_with( + screen, + fallback_score=10, + ) + + def test_second_attempt_success(self, handler: _TestableHandlers) -> None: + screen0 = self._make_screen() + screen1 = self._make_screen() + 1 + handler._map.wait_for_fleet_overlay_stable.side_effect = [screen0, screen1] + selection = FleetSelection('B', 4, (0.0, 0.0)) + handler._map.recognize_fleet_options.side_effect = [ + (0, {}), + (7, {'B': selection}), + ] + handler._map.is_fleet_overlay_open.return_value = True + + result = handler._recognize_fleet_options_with_retry(fallback_score=10) + + assert result == (screen1, 7, {'B': selection}) + assert handler._map.wait_for_fleet_overlay_stable.call_count == 2 + assert handler._map.recognize_fleet_options.call_count == 2 + + def test_all_attempts_empty_returns_fallback(self, handler: _TestableHandlers) -> None: + screens = [self._make_screen() + i for i in range(3)] + handler._map.wait_for_fleet_overlay_stable.side_effect = screens + handler._map.recognize_fleet_options.return_value = (0, {}) + handler._map.is_fleet_overlay_open.return_value = True + + result = handler._recognize_fleet_options_with_retry(fallback_score=15) + + assert result[0] is screens[-1] + assert result[1] == 15 + assert result[2] == {} + assert handler._map.wait_for_fleet_overlay_stable.call_count == 3 + assert handler._map.recognize_fleet_options.call_count == 3 + + def test_interface_closes_during_retry_raises(self, handler: _TestableHandlers) -> None: + screen = self._make_screen() + handler._map.wait_for_fleet_overlay_stable.return_value = screen + handler._map.recognize_fleet_options.return_value = (0, {}) + handler._map.is_fleet_overlay_open.return_value = False + + with pytest.raises(RuntimeError, match='战备舰队界面已关闭'): + handler._recognize_fleet_options_with_retry(fallback_score=10) + + handler._map.wait_for_fleet_overlay_stable.assert_called_once() + handler._map.is_fleet_overlay_open.assert_called_once() diff --git a/tests/unit/ops/decisive/test_logic.py b/tests/unit/ops/decisive/test_logic.py new file mode 100644 index 00000000..c0d1732d --- /dev/null +++ b/tests/unit/ops/decisive/test_logic.py @@ -0,0 +1,465 @@ +"""测试 autowsgr.ops.decisive.logic。""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from autowsgr.infra.config import DecisiveConfig +from autowsgr.ops.decisive.logic import DecisiveLogic, _count_anti_sub, _is_ship +from autowsgr.ops.decisive.state import DecisiveState +from autowsgr.types import FleetSelection, Formation, ShipDamageState + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 辅助函数 +# ═══════════════════════════════════════════════════════════════════════════════ + + +def make_logic( + *, + chapter: int = 6, + level1: list[str] | None = None, + level2: list[str] | None = None, + flagship_priority: list[str] | None = None, + repair_level: int = 1, + use_quick_repair: bool = True, + useful_skill_strict: bool = False, + state: DecisiveState | None = None, +) -> DecisiveLogic: + """创建 DecisiveLogic 实例及配套 state。""" + config = DecisiveConfig( + chapter=chapter, + level1=level1 or ['A1', 'A2'], + level2=level2 or ['B1'], + flagship_priority=flagship_priority or [], + repair_level=repair_level, + use_quick_repair=use_quick_repair, + useful_skill_strict=useful_skill_strict, + ) + if state is None: + state = DecisiveState() + return DecisiveLogic(config, state) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# _is_ship +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestIsShip: + """_is_ship 测试。""" + + @pytest.mark.parametrize( + ('name', 'expected'), + [ + ('长跑训练', False), + ('肌肉记忆', False), + ('黑科技', False), + ('U-47', True), + ('射水鱼', True), + ('普通船名', True), + ], + ) + def test_is_ship(self, name: str, expected: bool) -> None: + assert _is_ship(name) is expected + + +# ═══════════════════════════════════════════════════════════════════════════════ +# _count_anti_sub +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestCountAntiSub: + """_count_anti_sub 测试。""" + + def test_empty(self) -> None: + assert _count_anti_sub([]) == 0 + + def test_no_anti_sub(self) -> None: + assert _count_anti_sub(['BB', 'CV', 'SS']) == 0 + + def test_mixed(self) -> None: + assert _count_anti_sub(['CL', 'BB', 'DD', 'CVL', 'SS']) == 3 + + def test_all_anti_sub(self) -> None: + assert _count_anti_sub(['CL', 'DD', 'CVL']) == 3 + + +# ═══════════════════════════════════════════════════════════════════════════════ +# choose_ships +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestChooseShips: + """DecisiveLogic.choose_ships 测试。""" + + def test_empty_fleet_prioritizes_level1(self) -> None: + state = DecisiveState(fleet=[''] * 7, score=10) + logic = make_logic(level1=['A1', 'A2'], level2=['B1'], state=state) + selections = { + 'A1': FleetSelection('A1', 3, (0.0, 0.0)), + 'A2': FleetSelection('A2', 4, (0.0, 0.0)), + 'B1': FleetSelection('B1', 2, (0.0, 0.0)), + } + result = logic.choose_ships(selections) + assert result == ['A1', 'A2'] + + def test_fleet_count_one_respects_cost_limit(self) -> None: + state = DecisiveState(fleet=['', 'X', '', '', '', '', ''], score=5) + logic = make_logic(level1=['A1', 'A2'], level2=['B1'], state=state) + selections = { + 'A1': FleetSelection('A1', 3, (0.0, 0.0)), + 'A2': FleetSelection('A2', 4, (0.0, 0.0)), + 'B1': FleetSelection('B1', 2, (0.0, 0.0)), + } + result = logic.choose_ships(selections) + assert result == ['A1'] + + def test_fleet_count_two_can_buy_level1(self) -> None: + state = DecisiveState(fleet=['', 'X', 'Y', '', '', '', ''], score=10) + logic = make_logic(level1=['A1'], level2=['B1'], state=state) + selections = { + 'A1': FleetSelection('A1', 2, (0.0, 0.0)), + } + result = logic.choose_ships(selections) + assert result == ['A1'] + + def test_fleet_count_two_excludes_skills(self) -> None: + state = DecisiveState(fleet=['', 'X', 'Y', '', '', '', ''], score=10) + logic = make_logic(level1=['A1'], level2=['B1'], state=state) + selections = { + 'B1': FleetSelection('B1', 2, (0.0, 0.0)), + '长跑训练': FleetSelection('长跑训练', 1, (0.0, 0.0)), + } + result = logic.choose_ships(selections) + assert result == ['B1'] + assert '长跑训练' not in result + + def test_full_fleet_non_level1_reprioritizes_level1(self) -> None: + state = DecisiveState(fleet=['', 'A1', 'A2', 'X', 'Y', 'Z', 'W'], score=10) + logic = make_logic(level1=['A1', 'A2', 'A3'], level2=['B1'], state=state) + selections = { + 'A3': FleetSelection('A3', 2, (0.0, 0.0)), + 'B1': FleetSelection('B1', 1, (0.0, 0.0)), + } + result = logic.choose_ships(selections) + assert result == ['A3'] + + def test_full_fleet_all_level1_allows_skills(self) -> None: + state = DecisiveState( + fleet=['', 'A1', 'A2', 'A3', 'A4', 'A5', 'A6'], + score=10, + ) + logic = make_logic( + level1=['A1', 'A2', 'A3', 'A4', 'A5', 'A6'], + level2=['B1'], + state=state, + ) + selections = { + '长跑训练': FleetSelection('长跑训练', 1, (0.0, 0.0)), + } + result = logic.choose_ships(selections) + assert result == ['长跑训练'] + + def test_first_node_adds_level2(self) -> None: + state = DecisiveState(fleet=[''] * 7, score=10) + logic = make_logic(level1=['A1'], level2=['B1'], state=state) + selections = { + 'A1': FleetSelection('A1', 3, (0.0, 0.0)), + 'B1': FleetSelection('B1', 2, (0.0, 0.0)), + } + result = logic.choose_ships(selections, first_node=True) + assert result == ['A1', 'B1'] + + def test_first_node_adds_level2_when_level1_missing(self) -> None: + state = DecisiveState(fleet=[''] * 7, score=10) + logic = make_logic(level1=['A1'], level2=['B1'], state=state) + selections = { + 'B1': FleetSelection('B1', 2, (0.0, 0.0)), + } + result = logic.choose_ships(selections, first_node=True) + assert result == ['B1'] + + +# ═══════════════════════════════════════════════════════════════════════════════ +# should_retreat +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestShouldRetreat: + """DecisiveLogic.should_retreat 测试。""" + + def test_node_a_threshold_2(self) -> None: + logic = make_logic() + logic.state.node = 'A' + assert logic.should_retreat(['', '', '', '', '', '', '']) is True + assert logic.should_retreat(['', 'X', '', '', '', '', '']) is True + assert logic.should_retreat(['', 'X', 'Y', '', '', '', '']) is False + assert logic.should_retreat(['', 'X', 'Y', 'Z', '', '', '']) is False + + def test_other_nodes_threshold_1(self) -> None: + logic = make_logic() + logic.state.node = 'B' + assert logic.should_retreat(['', '', '', '', '', '', '']) is True + assert logic.should_retreat(['', 'X', '', '', '', '', '']) is False + assert logic.should_retreat(['', 'X', 'Y', '', '', '', '']) is False + + +# ═══════════════════════════════════════════════════════════════════════════════ +# should_repair +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestShouldRepair: + """DecisiveLogic.should_repair 测试。""" + + def test_disabled(self) -> None: + state = DecisiveState(ship_stats=[ShipDamageState.MODERATE] * 6) + logic = make_logic(use_quick_repair=False, state=state) + assert logic.should_repair() is False + + def test_no_damage(self) -> None: + state = DecisiveState(ship_stats=[ShipDamageState.NORMAL] * 6) + logic = make_logic(use_quick_repair=True, repair_level=1, state=state) + assert logic.should_repair() is False + + def test_moderate_at_level_1(self) -> None: + state = DecisiveState( + ship_stats=[ShipDamageState.MODERATE, ShipDamageState.NORMAL] * 3, + ) + logic = make_logic(use_quick_repair=True, repair_level=1, state=state) + assert logic.should_repair() is True + + def test_moderate_at_level_2(self) -> None: + state = DecisiveState( + ship_stats=[ShipDamageState.MODERATE, ShipDamageState.NORMAL] * 3, + ) + logic = make_logic(use_quick_repair=True, repair_level=2, state=state) + assert logic.should_repair() is False + + def test_severe_at_level_2(self) -> None: + state = DecisiveState( + ship_stats=[ShipDamageState.SEVERE, ShipDamageState.NORMAL] * 3, + ) + logic = make_logic(use_quick_repair=True, repair_level=2, state=state) + assert logic.should_repair() is True + + def test_ignores_no_ship_slots(self) -> None: + state = DecisiveState( + ship_stats=[ShipDamageState.NO_SHIP, ShipDamageState.SEVERE] * 3, + ) + logic = make_logic(use_quick_repair=True, repair_level=1, state=state) + assert logic.should_repair() is True + + +# ═══════════════════════════════════════════════════════════════════════════════ +# check_useful_skill +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestCheckUsefulSkill: + """DecisiveLogic.check_useful_skill 测试。""" + + def test_single_in_level2(self) -> None: + logic = make_logic(level1=['A1'], level2=['B1']) + assert logic.check_useful_skill(['B1']) is True + + def test_single_not_in_level2(self) -> None: + logic = make_logic(level1=['A1'], level2=['B1']) + assert logic.check_useful_skill(['C1']) is False + + def test_single_skill_not_ship(self) -> None: + logic = make_logic(level1=['A1'], level2=['B1']) + # 长跑训练 is in _level2_full but is not a ship + assert logic.check_useful_skill(['长跑训练']) is True + + def test_strict_duplicate(self) -> None: + state = DecisiveState(ships={'B1'}) + logic = make_logic( + level1=['A1'], + level2=['B1'], + useful_skill_strict=True, + state=state, + ) + assert logic.check_useful_skill(['B1']) is False + + def test_strict_new_ship(self) -> None: + state = DecisiveState(ships={'A1'}) + logic = make_logic( + level1=['A1'], + level2=['B1'], + useful_skill_strict=True, + state=state, + ) + assert logic.check_useful_skill(['B1']) is True + + def test_multi_majority_level1(self) -> None: + logic = make_logic(level1=['A1', 'A2', 'A3']) + assert logic.check_useful_skill(['A1', 'A2', 'B1']) is True + + def test_multi_below_half(self) -> None: + logic = make_logic(level1=['A1', 'A2', 'A3']) + assert logic.check_useful_skill(['A1', 'B1', 'B2']) is False + + def test_multi_even_meets_half(self) -> None: + logic = make_logic(level1=['A1', 'A2', 'A3']) + assert logic.check_useful_skill(['A1', 'A2', 'B1', 'B2']) is True + + def test_multi_even_below_half(self) -> None: + logic = make_logic(level1=['A1', 'A2', 'A3']) + assert logic.check_useful_skill(['A1', 'B1', 'B2', 'B3']) is False + + +# ═══════════════════════════════════════════════════════════════════════════════ +# get_best_fleet +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestGetBestFleet: + """DecisiveLogic.get_best_fleet 测试。""" + + def test_level1_only(self) -> None: + state = DecisiveState(ships={'A1', 'A2'}) + logic = make_logic(level1=['A1', 'A2'], level2=['B1'], state=state) + assert logic.get_best_fleet() == ['', 'A1', 'A2', '', '', '', ''] + + def test_level2_fallback(self) -> None: + state = DecisiveState(ships={'A1', 'B1', 'B2'}) + logic = make_logic(level1=['A1', 'A2'], level2=['B1', 'B2'], state=state) + assert logic.get_best_fleet() == ['', 'A1', 'B1', 'B2', '', '', ''] + + def test_flagship_priority_reorders(self) -> None: + state = DecisiveState(ships={'A1', 'A2'}) + logic = make_logic( + level1=['A1', 'A2'], + flagship_priority=['A2', 'A1'], + state=state, + ) + assert logic.get_best_fleet() == ['', 'A2', 'A1', '', '', '', ''] + + def test_padded_to_7(self) -> None: + state = DecisiveState(ships={'A1'}) + logic = make_logic(level1=['A1'], state=state) + assert logic.get_best_fleet() == ['', 'A1', '', '', '', '', ''] + + def test_no_duplicates(self) -> None: + state = DecisiveState(ships={'A1', 'B1'}) + logic = make_logic(level1=['A1'], level2=['B1', 'A1'], state=state) + assert logic.get_best_fleet() == ['', 'A1', 'B1', '', '', '', ''] + + def test_available_with_ctx(self) -> None: + ctx = object.__new__( + type('MockCtx', (), {'is_ship_available': lambda _self, name: name == 'A1'}) + ) + state = DecisiveState(ships={'A1', 'A2'}) + config = DecisiveConfig(level1=['A1', 'A2']) + logic = DecisiveLogic(config, state, ctx=ctx) + assert logic.get_best_fleet() == ['', 'A1', '', '', '', '', ''] + + +# ═══════════════════════════════════════════════════════════════════════════════ +# get_advance_choice +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestGetAdvanceChoice: + """DecisiveLogic.get_advance_choice 测试。""" + + def test_returns_zero(self) -> None: + logic = make_logic() + assert logic.get_advance_choice(['A1', 'A2']) == 0 + assert logic.get_advance_choice([]) == 0 + + +# ═══════════════════════════════════════════════════════════════════════════════ +# get_formation +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestGetFormation: + """DecisiveLogic.get_formation 测试。""" + + def test_no_anti_sub_returns_wedge(self) -> None: + with patch('autowsgr.ops.decisive.logic.MapData.get_enemy', return_value=['BB', 'CV']): + logic = make_logic(chapter=1) + logic.state.stage = 1 + logic.state.node = 'A' + assert logic.get_formation() == Formation.wedge + + def test_u1206_with_one_anti_sub_returns_wedge(self) -> None: + with patch('autowsgr.ops.decisive.logic.MapData.get_enemy', return_value=['CL', 'BB']): + logic = make_logic(chapter=1) + logic.state.stage = 1 + logic.state.node = 'A' + logic.state.fleet = ['', 'U-1206', '', '', '', '', ''] + assert logic.get_formation() == Formation.wedge + + def test_u1206_with_two_anti_sub_returns_double_column(self) -> None: + with patch( + 'autowsgr.ops.decisive.logic.MapData.get_enemy', + return_value=['CL', 'DD', 'BB'], + ): + logic = make_logic(chapter=1) + logic.state.stage = 1 + logic.state.node = 'A' + logic.state.fleet = ['', 'U-1206', '', '', '', '', ''] + assert logic.get_formation() == Formation.double_column + + def test_without_u1206_with_anti_sub_returns_double_column(self) -> None: + with patch('autowsgr.ops.decisive.logic.MapData.get_enemy', return_value=['CL', 'BB']): + logic = make_logic(chapter=1) + logic.state.stage = 1 + logic.state.node = 'A' + logic.state.fleet = ['', 'X', '', '', '', '', ''] + assert logic.get_formation() == Formation.double_column + + +# ═══════════════════════════════════════════════════════════════════════════════ +# is_stage_end / is_key_point +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestIsStageEnd: + """DecisiveLogic.is_stage_end 测试。""" + + def test_uses_state_node(self) -> None: + with patch( + 'autowsgr.ops.decisive.logic.MapData.is_stage_end', + return_value=True, + ) as mock: + logic = make_logic(chapter=3, state=DecisiveState(stage=2, node='H')) + assert logic.is_stage_end() is True + mock.assert_called_once_with(3, 2, 'H') + + def test_explicit_node(self) -> None: + with patch( + 'autowsgr.ops.decisive.logic.MapData.is_stage_end', + return_value=False, + ) as mock: + logic = make_logic(chapter=3, state=DecisiveState(stage=2, node='H')) + assert logic.is_stage_end('J') is False + mock.assert_called_once_with(3, 2, 'J') + + +class TestIsKeyPoint: + """DecisiveLogic.is_key_point 测试。""" + + def test_uses_state_node(self) -> None: + with patch( + 'autowsgr.ops.decisive.logic.MapData.is_key_point', + return_value=True, + ) as mock: + logic = make_logic(chapter=3, state=DecisiveState(stage=2, node='B')) + assert logic.is_key_point() is True + mock.assert_called_once_with(3, 2, 'B') + + def test_explicit_node(self) -> None: + with patch( + 'autowsgr.ops.decisive.logic.MapData.is_key_point', + return_value=False, + ) as mock: + logic = make_logic(chapter=3, state=DecisiveState(stage=2, node='B')) + assert logic.is_key_point('C') is False + mock.assert_called_once_with(3, 2, 'C') diff --git a/tests/unit/ops/decisive/test_state.py b/tests/unit/ops/decisive/test_state.py new file mode 100644 index 00000000..892c7efb --- /dev/null +++ b/tests/unit/ops/decisive/test_state.py @@ -0,0 +1,51 @@ +"""测试 autowsgr.ops.decisive.state。""" + +from __future__ import annotations + +from autowsgr.ops.decisive.state import DecisiveState +from autowsgr.types import DecisivePhase + + +class TestDecisiveState: + """DecisiveState 测试。""" + + def test_defaults(self) -> None: + s = DecisiveState() + assert s.chapter == 6 + assert s.stage == 0 + assert s.node == 'U' + assert s.phase == DecisivePhase.INIT + assert s.score == 10 + assert s.ships == set() + assert len(s.fleet) == 7 + assert s.fleet[0] == '' + assert len(s.ship_stats) == 6 + + def test_custom_chapter(self) -> None: + s = DecisiveState(chapter=3) + assert s.chapter == 3 + + def test_reset_preserves_chapter(self) -> None: + s = DecisiveState(chapter=2) + s.stage = 2 + s.node = 'C' + s.reset() + assert s.chapter == 2 + assert s.stage == 0 + assert s.node == 'U' + + def test_is_begin_initial(self) -> None: + s = DecisiveState() + assert s.is_begin() is True + + def test_is_begin_stage1(self) -> None: + s = DecisiveState() + s.stage = 1 + s.node = 'A' + assert s.is_begin() is True + + def test_is_begin_not_begin(self) -> None: + s = DecisiveState() + s.stage = 2 + s.node = 'B' + assert s.is_begin() is False diff --git a/tests/unit/ops/test_build.py b/tests/unit/ops/test_build.py new file mode 100644 index 00000000..4e48e60a --- /dev/null +++ b/tests/unit/ops/test_build.py @@ -0,0 +1,118 @@ +"""测试 autowsgr.ops.build。""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from autowsgr.ops.build import BuildRecipe, build_ship, collect_built_ships +from autowsgr.types import PageName + + +class TestBuildRecipe: + """BuildRecipe 数据类测试。""" + + def test_defaults(self) -> None: + recipe = BuildRecipe(30, 30, 30, 30) + assert recipe.fuel == 30 + assert recipe.ammo == 30 + assert recipe.steel == 30 + assert recipe.bauxite == 30 + + +class TestCollectBuiltShips: + """collect_built_ships 测试。""" + + @patch('autowsgr.ops.build.goto_page') + @patch('autowsgr.ui.build_page.BuildPage') + def test_collect_ship( + self, + mock_page_cls: MagicMock, + mock_goto: MagicMock, + ) -> None: + ctx = MagicMock() + mock_page = mock_page_cls.return_value + mock_page.collect_all.return_value = 3 + # 模拟当前已在建造标签 + mock_page_cls.get_active_tab.return_value = MagicMock() + + result = collect_built_ships(ctx) + + mock_goto.assert_called_once_with(ctx, PageName.BUILD) + mock_page.collect_all.assert_called_once_with('ship', allow_fast_build=False) + assert result == 3 + + @patch('autowsgr.ops.build.goto_page') + @patch('autowsgr.ui.build_page.BuildPage') + def test_collect_equipment( + self, + mock_page_cls: MagicMock, + mock_goto: MagicMock, + ) -> None: + ctx = MagicMock() + mock_page = mock_page_cls.return_value + mock_page.collect_all.return_value = 2 + + result = collect_built_ships(ctx, build_type='equipment') + + mock_goto.assert_called_once_with(ctx, PageName.BUILD) + mock_page.switch_tab.assert_called_once() + mock_page.collect_all.assert_called_once_with('equipment', allow_fast_build=False) + assert result == 2 + + @patch('autowsgr.ops.build.goto_page') + @patch('autowsgr.ui.build_page.BuildPage') + def test_switch_tab_when_not_build( + self, + mock_page_cls: MagicMock, + mock_goto: MagicMock, + ) -> None: + ctx = MagicMock() + mock_page = mock_page_cls.return_value + mock_page.collect_all.return_value = 1 + # get_active_tab 返回的 tab 不等于当前标签,触发 switch_tab + other_tab = MagicMock() + mock_page_cls.get_active_tab.return_value = other_tab + + collect_built_ships(ctx) + + mock_goto.assert_called_once_with(ctx, PageName.BUILD) + mock_page.switch_tab.assert_called_once() + + +class TestBuildShip: + """build_ship 测试。""" + + @patch('autowsgr.ops.build.collect_built_ships') + @patch('autowsgr.ui.build_page.BuildPage') + def test_build_ship_no_recipe( + self, + mock_page_cls: MagicMock, + mock_collect: MagicMock, + ) -> None: + ctx = MagicMock() + mock_page = mock_page_cls.return_value + + build_ship(ctx) + + mock_collect.assert_called_once_with(ctx, build_type='ship', allow_fast_build=False) + mock_page.start_new_build.assert_called_once_with('ship') + + @patch('autowsgr.ops.build.collect_built_ships') + @patch('autowsgr.ui.build_page.BuildPage') + def test_build_ship_with_recipe( + self, + mock_page_cls: MagicMock, + mock_collect: MagicMock, + ) -> None: + ctx = MagicMock() + mock_page = mock_page_cls.return_value + recipe = BuildRecipe(400, 30, 600, 130) + + build_ship(ctx, recipe=recipe, build_type='equipment', allow_fast_build=True) + + mock_collect.assert_called_once_with( + ctx, + build_type='equipment', + allow_fast_build=True, + ) + mock_page.start_new_build.assert_called_once_with('equipment') diff --git a/tests/unit/ops/test_campaign.py b/tests/unit/ops/test_campaign.py new file mode 100644 index 00000000..3cd13492 --- /dev/null +++ b/tests/unit/ops/test_campaign.py @@ -0,0 +1,63 @@ +"""测试 autowsgr.ops.campaign。""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from autowsgr.ops.campaign import CampaignRunner, parse_campaign_name +from autowsgr.types import Formation, RepairMode + + +class TestParseCampaignName: + """parse_campaign_name 测试。""" + + def test_easy_destroyer(self) -> None: + assert parse_campaign_name('简单驱逐') == (1, 'easy') + + def test_hard_carrier(self) -> None: + assert parse_campaign_name('困难航母') == (4, 'hard') + + def test_hard_submarine(self) -> None: + assert parse_campaign_name('困难潜艇') == (5, 'hard') + + def test_invalid_name_raises(self) -> None: + with pytest.raises(ValueError, match='无法识别'): + parse_campaign_name('不存在的战役') + + +class TestCampaignRunner: + """CampaignRunner 初始化与解析测试。""" + + def test_init_parses_name(self) -> None: + with patch('autowsgr.ops.campaign.CombatEngine'): + ctx = MagicMock() + runner = CampaignRunner(ctx, '困难战列') + assert runner._map_index == 3 + assert runner._difficulty == 'hard' + + def test_init_defaults(self) -> None: + with patch('autowsgr.ops.campaign.CombatEngine'): + ctx = MagicMock() + runner = CampaignRunner(ctx, '简单驱逐') + assert runner._times == 3 + assert runner._formation == Formation.double_column + assert runner._night is True + assert runner._repair_mode == RepairMode.moderate_damage + + def test_init_custom_params(self) -> None: + with patch('autowsgr.ops.campaign.CombatEngine'): + ctx = MagicMock() + runner = CampaignRunner( + ctx, + '困难航母', + times=5, + formation=Formation.single_column, + night=False, + repair_mode=RepairMode.severe_damage, + ) + assert runner._times == 5 + assert runner._formation == Formation.single_column + assert runner._night is False + assert runner._repair_mode == RepairMode.severe_damage diff --git a/tests/unit/ops/test_cook.py b/tests/unit/ops/test_cook.py new file mode 100644 index 00000000..0a1232e9 --- /dev/null +++ b/tests/unit/ops/test_cook.py @@ -0,0 +1,36 @@ +"""测试 autowsgr.ops.cook。""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from autowsgr.ops.cook import cook + + +class TestCook: + """cook 测试。""" + + @patch('autowsgr.ops.cook.CanteenPage') + def test_cook_success(self, mock_page_cls: MagicMock) -> None: + ctx = MagicMock() + mock_page = mock_page_cls.return_value + mock_page.cook.return_value = True + + with patch('autowsgr.ops.cook.goto_page') as mock_goto: + result = cook(ctx, position=2, force_cook=True) + + mock_goto.assert_called_once() + mock_page.cook.assert_called_once_with(2, force_cook=True) + assert result is True + + @patch('autowsgr.ops.cook.CanteenPage') + def test_cook_failure(self, mock_page_cls: MagicMock) -> None: + ctx = MagicMock() + mock_page = mock_page_cls.return_value + mock_page.cook.return_value = False + + with patch('autowsgr.ops.cook.goto_page'): + result = cook(ctx) + + mock_page.cook.assert_called_once_with(1, force_cook=False) + assert result is False diff --git a/tests/unit/ops/test_destroy.py b/tests/unit/ops/test_destroy.py new file mode 100644 index 00000000..bdbcd16a --- /dev/null +++ b/tests/unit/ops/test_destroy.py @@ -0,0 +1,47 @@ +"""测试 autowsgr.ops.destroy。""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from autowsgr.ops.destroy import destroy_ships +from autowsgr.types import PageName, ShipType + + +class TestDestroyShips: + """destroy_ships 测试。""" + + @patch('autowsgr.ops.destroy.goto_page') + @patch('autowsgr.ui.build_page.BuildPage') + def test_destroy_all( + self, + mock_page_cls: MagicMock, + mock_goto: MagicMock, + ) -> None: + ctx = MagicMock() + mock_page = mock_page_cls.return_value + + destroy_ships(ctx) + + mock_goto.assert_any_call(ctx, PageName.BUILD) + mock_page.switch_tab.assert_called_once() + mock_page.destroy_ships.assert_called_once_with(None, remove_equipment=True) + + @patch('autowsgr.ops.destroy.goto_page') + @patch('autowsgr.ui.build_page.BuildPage') + def test_destroy_specific_types( + self, + mock_page_cls: MagicMock, + mock_goto: MagicMock, + ) -> None: + ctx = MagicMock() + mock_page = mock_page_cls.return_value + ship_types = [ShipType.DD, ShipType.CL] + + destroy_ships(ctx, ship_types=ship_types, remove_equipment=False) + + mock_goto.assert_any_call(ctx, PageName.BUILD) + mock_page.destroy_ships.assert_called_once_with( + ship_types, + remove_equipment=False, + ) diff --git a/tests/unit/ops/test_event_fight.py b/tests/unit/ops/test_event_fight.py new file mode 100644 index 00000000..d4a8d6fc --- /dev/null +++ b/tests/unit/ops/test_event_fight.py @@ -0,0 +1,290 @@ +"""测试 autowsgr.ops.event_fight。""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +from autowsgr.combat import CombatMode, CombatPlan +from autowsgr.combat.history import CombatResult +from autowsgr.ops.event_fight import ( + EventFightRunner, + run_event_fight, + run_event_fight_from_yaml, +) +from autowsgr.types import ConditionFlag + + +def _make_ctx() -> MagicMock: + """构造一个满足 EventFightRunner 初始化需求的 mock 上下文。""" + ctx = MagicMock() + ctx.config.dock_full_destroy = False + ctx.config.destroy_ship_types = None + return ctx + + +def _make_plan(**kwargs: Any) -> CombatPlan: + """使用指定字段构造 CombatPlan,其余保持默认值。""" + return CombatPlan(**kwargs) + + +class TestInitMapCode: + """__init__ map_code 推导测试。""" + + def test_explicit_map_code_used(self) -> None: + ctx = _make_ctx() + plan = _make_plan(chapter='H', map_id=3) + runner = EventFightRunner(ctx, plan, map_code='Z9') + assert runner._map_code == 'Z9' + + def test_derived_h3(self) -> None: + ctx = _make_ctx() + plan = _make_plan(chapter='H', map_id=3) + runner = EventFightRunner(ctx, plan) + assert runner._map_code == 'H3' + + def test_derived_e1(self) -> None: + ctx = _make_ctx() + plan = _make_plan(chapter='E', map_id=1) + runner = EventFightRunner(ctx, plan) + assert runner._map_code == 'E1' + + def test_fallback_x5(self) -> None: + ctx = _make_ctx() + plan = _make_plan(chapter='X', map_id=5) + runner = EventFightRunner(ctx, plan) + assert runner._map_code == 'H5' + + def test_defaults_none(self) -> None: + ctx = _make_ctx() + plan = _make_plan(chapter=None, map_id=None) + runner = EventFightRunner(ctx, plan) + assert runner._map_code == 'H1' + + +class TestInitEventName: + """__init__ event_name 优先级测试。""" + + def test_param_provided_sets_plan(self) -> None: + ctx = _make_ctx() + plan = _make_plan(event_name=None) + runner = EventFightRunner(ctx, plan, event_name='20260212') + assert runner._plan.event_name == '20260212' + + def test_param_none_plan_set(self) -> None: + ctx = _make_ctx() + plan = _make_plan(event_name='existing') + runner = EventFightRunner(ctx, plan, event_name=None) + assert runner._plan.event_name == 'existing' + + def test_both_none(self) -> None: + ctx = _make_ctx() + plan = _make_plan(event_name=None) + runner = EventFightRunner(ctx, plan, event_name=None) + assert runner._plan.event_name is None + + +class TestInitMode: + """__init__ mode 强制修正测试。""" + + def test_normal_corrected_to_event(self) -> None: + ctx = _make_ctx() + plan = _make_plan(mode=CombatMode.NORMAL) + runner = EventFightRunner(ctx, plan) + assert runner._plan.mode == CombatMode.EVENT + + def test_event_unchanged(self) -> None: + ctx = _make_ctx() + plan = _make_plan(mode=CombatMode.EVENT) + runner = EventFightRunner(ctx, plan) + assert runner._plan.mode == CombatMode.EVENT + + +class TestInitFleetId: + """__init__ fleet_id 回退测试。""" + + def test_param_provided(self) -> None: + ctx = _make_ctx() + plan = _make_plan(fleet_id=2) + runner = EventFightRunner(ctx, plan, fleet_id=3) + assert runner._fleet_id == 3 + + def test_plan_fallback(self) -> None: + ctx = _make_ctx() + plan = _make_plan(fleet_id=2) + runner = EventFightRunner(ctx, plan) + assert runner._fleet_id == 2 + + def test_default_to_one(self) -> None: + ctx = _make_ctx() + plan = _make_plan(fleet_id=None) + runner = EventFightRunner(ctx, plan, fleet_id=None) + assert runner._fleet_id == 1 + + +class TestPrimaryNamesFromRules: + """_primary_names_from_rules 静态方法测试。""" + + def test_none_returns_none(self) -> None: + assert EventFightRunner._primary_names_from_rules(None) is None + + def test_empty_list_returns_none(self) -> None: + assert EventFightRunner._primary_names_from_rules([]) is None + + def test_strings(self) -> None: + rules = ['A', 'B', 'C'] + assert EventFightRunner._primary_names_from_rules(rules) == ['A', 'B', 'C'] + + def test_whitespace(self) -> None: + rules = [' A ', '', ' '] + assert EventFightRunner._primary_names_from_rules(rules) == ['A', None, None] + + def test_dicts(self) -> None: + rules = [ + {'candidates': ['X', 'Y']}, + {'candidates': []}, + {'candidates': ['Z']}, + ] + assert EventFightRunner._primary_names_from_rules(rules) == ['X', None, 'Z'] + + def test_objects(self) -> None: + class Rule: + def __init__(self, candidates: list[str] | None) -> None: + self.candidates = candidates + + rules = [Rule(['A']), Rule([]), Rule(None)] + assert EventFightRunner._primary_names_from_rules(rules) == ['A', None, None] + + def test_more_than_six_slots(self) -> None: + rules = [f'ship{i}' for i in range(8)] + result = EventFightRunner._primary_names_from_rules(rules) + assert result is not None + assert len(result) == 6 + assert result == ['ship0', 'ship1', 'ship2', 'ship3', 'ship4', 'ship5'] + + def test_mixed(self) -> None: + class Rule: + def __init__(self, candidates: list[str] | None) -> None: + self.candidates = candidates + + rules = [ + 'A', + {'candidates': ['B']}, + Rule(['C']), + None, + '', + {'candidates': []}, + 'D', + ] + result = EventFightRunner._primary_names_from_rules(rules) + assert result is not None + assert len(result) == 6 + assert result == ['A', 'B', 'C', None, None, None] + + +class TestRunForTimes: + """run_for_times 循环与中断测试。""" + + def test_loop_count(self) -> None: + ctx = _make_ctx() + plan = _make_plan(mode=CombatMode.EVENT) + runner = EventFightRunner(ctx, plan) + with patch.object(runner, 'run') as mock_run: + mock_run.return_value = CombatResult(flag=ConditionFlag.OPERATION_SUCCESS) + results = runner.run_for_times(3) + assert len(results) == 3 + assert mock_run.call_count == 3 + + def test_dock_full_break(self) -> None: + ctx = _make_ctx() + plan = _make_plan(mode=CombatMode.EVENT) + runner = EventFightRunner(ctx, plan) + success = CombatResult(flag=ConditionFlag.OPERATION_SUCCESS) + dock_full = CombatResult(flag=ConditionFlag.DOCK_FULL) + with patch.object(runner, 'run', side_effect=[success, dock_full, success]): + results = runner.run_for_times(3) + assert len(results) == 2 + + def test_gap_sleep(self) -> None: + ctx = _make_ctx() + plan = _make_plan(mode=CombatMode.EVENT) + runner = EventFightRunner(ctx, plan) + with ( + patch.object( + runner, 'run', return_value=CombatResult(flag=ConditionFlag.OPERATION_SUCCESS) + ), + patch('autowsgr.ops.event_fight.time.sleep') as mock_sleep, + ): + runner.run_for_times(3, gap=1.5) + assert mock_sleep.call_count == 2 + mock_sleep.assert_called_with(1.5) + + +class TestRunEventFight: + """run_event_fight 便捷函数测试。""" + + @patch('autowsgr.ops.event_fight.EventFightRunner') + def test_calls_runner(self, mock_cls: MagicMock) -> None: + ctx = _make_ctx() + plan = _make_plan(mode=CombatMode.EVENT) + mock_runner = mock_cls.return_value + mock_runner.run_for_times.return_value = ['r1', 'r2'] + + result = run_event_fight(ctx, plan, map_code='H3', times=2, gap=1.0) + + mock_cls.assert_called_once_with( + ctx, + plan, + map_code='H3', + entrance=None, + fleet_id=None, + fleet=None, + fleet_rules=None, + ) + mock_runner.run_for_times.assert_called_once_with(2, gap=1.0) + assert result == ['r1', 'r2'] + + +class TestRunEventFightFromYaml: + """run_event_fight_from_yaml 便捷函数测试。""" + + @patch('autowsgr.ops.event_fight.run_event_fight') + @patch('autowsgr.ops.event_fight.CombatPlan.from_yaml') + @patch('autowsgr.infra.file_utils.resolve_plan_path') + def test_calls_chain( + self, + mock_resolve: MagicMock, + mock_from_yaml: MagicMock, + mock_run: MagicMock, + ) -> None: + ctx = _make_ctx() + mock_resolve.return_value = Path('/fake/plan.yaml') + plan = _make_plan(mode=CombatMode.EVENT) + mock_from_yaml.return_value = plan + mock_run.return_value = ['r1'] + + result = run_event_fight_from_yaml( + ctx, + 'my_plan', + map_code='E1', + entrance='alpha', + times=3, + fleet_id=2, + fleet=['ship1'], + fleet_rules=['rule1'], + ) + + mock_resolve.assert_called_once_with('my_plan', category='event') + mock_from_yaml.assert_called_once_with(Path('/fake/plan.yaml')) + mock_run.assert_called_once_with( + ctx, + plan, + map_code='E1', + entrance='alpha', + times=3, + fleet_id=2, + fleet=['ship1'], + fleet_rules=['rule1'], + ) + assert result == ['r1'] diff --git a/tests/unit/ops/test_exercise.py b/tests/unit/ops/test_exercise.py new file mode 100644 index 00000000..e8bc8e39 --- /dev/null +++ b/tests/unit/ops/test_exercise.py @@ -0,0 +1,50 @@ +"""测试 autowsgr.ops.exercise。""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from autowsgr.ops.exercise import ExerciseRunner, run_exercise + + +class TestExerciseRunner: + """ExerciseRunner 初始化测试。""" + + def test_init_defaults(self) -> None: + ctx = MagicMock() + runner = ExerciseRunner(ctx) + assert runner._fleet_id == 1 + assert runner._results == [] + + def test_init_custom_fleet(self) -> None: + ctx = MagicMock() + runner = ExerciseRunner(ctx, fleet_id=2) + assert runner._fleet_id == 2 + + +class TestRunExercise: + """run_exercise 便捷函数测试。""" + + @patch('autowsgr.ops.exercise.ExerciseRunner') + def test_run_all(self, mock_runner_cls: MagicMock) -> None: + ctx = MagicMock() + mock_runner = mock_runner_cls.return_value + mock_runner.run.return_value = ['result1', 'result2'] + + result = run_exercise(ctx, rival=None) + + mock_runner_cls.assert_called_once_with(ctx, 1) + mock_runner.run.assert_called_once() + assert result == ['result1', 'result2'] + + @patch('autowsgr.ops.exercise.ExerciseRunner') + def test_run_specific_rival(self, mock_runner_cls: MagicMock) -> None: + ctx = MagicMock() + mock_runner = mock_runner_cls.return_value + mock_runner._challenge_rival.return_value = 'result1' + + result = run_exercise(ctx, fleet_id=2, rival=3) + + mock_runner_cls.assert_called_once_with(ctx, 2) + mock_runner._challenge_rival.assert_called_once_with(3) + assert result == ['result1'] diff --git a/tests/unit/ops/test_expedition.py b/tests/unit/ops/test_expedition.py new file mode 100644 index 00000000..5f2d4447 --- /dev/null +++ b/tests/unit/ops/test_expedition.py @@ -0,0 +1,73 @@ +"""测试 autowsgr.ops.expedition。""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from autowsgr.ops.expedition import collect_expedition +from autowsgr.types import PageName + + +class TestCollectExpedition: + """collect_expedition 测试。""" + + @patch('autowsgr.ops.expedition.MainPage') + def test_no_expedition(self, mock_main_cls: MagicMock) -> None: + ctx = MagicMock() + mock_main_cls.has_expedition_ready.return_value = False + + with ( + patch('autowsgr.ops.expedition.goto_page') as mock_goto, + patch('autowsgr.ops.expedition.recover_to_main_or_restart') as mock_recover, + ): + result = collect_expedition(ctx) + + assert result is False + mock_recover.assert_called_once() + mock_goto.assert_called_once_with(ctx, PageName.MAIN) + + @patch('autowsgr.ops.expedition.MainPage') + @patch('autowsgr.ops.expedition.MapPage') + def test_has_expedition( + self, + mock_map_cls: MagicMock, + mock_main_cls: MagicMock, + ) -> None: + ctx = MagicMock() + mock_main_cls.has_expedition_ready.return_value = True + mock_map = mock_map_cls.return_value + mock_map_cls.has_expedition_notification.return_value = True + mock_map.collect_expedition.return_value = 2 + + with ( + patch('autowsgr.ops.expedition.goto_page') as mock_goto, + patch('autowsgr.ops.expedition.recover_to_main_or_restart'), + ): + result = collect_expedition(ctx) + + assert result is True + mock_goto.assert_any_call(ctx, PageName.MAIN) + mock_goto.assert_any_call(ctx, PageName.MAP) + mock_map.switch_panel.assert_called_once() + mock_map.collect_expedition.assert_called_once() + + @patch('autowsgr.ops.expedition.MainPage') + @patch('autowsgr.ops.expedition.MapPage') + def test_notification_gone( + self, + mock_map_cls: MagicMock, + mock_main_cls: MagicMock, + ) -> None: + ctx = MagicMock() + mock_main_cls.has_expedition_ready.return_value = True + mock_map_cls.has_expedition_notification.return_value = False + + with ( + patch('autowsgr.ops.expedition.goto_page') as mock_goto, + patch('autowsgr.ops.expedition.recover_to_main_or_restart'), + ): + result = collect_expedition(ctx) + + assert result is False + mock_goto.assert_any_call(ctx, PageName.MAIN) + mock_goto.assert_any_call(ctx, PageName.MAP) diff --git a/tests/unit/ops/test_navigate.py b/tests/unit/ops/test_navigate.py new file mode 100644 index 00000000..78cf8d2e --- /dev/null +++ b/tests/unit/ops/test_navigate.py @@ -0,0 +1,179 @@ +"""测试 autowsgr.ops.navigate。""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +import pytest + +from autowsgr.ops.navigate import _goto_page, goto_page, identify_current_page +from autowsgr.ui.utils import NavigationError + + +if TYPE_CHECKING: + from collections.abc import Iterator + + +class TestIdentifyCurrentPage: + """identify_current_page 测试。""" + + def test_first_attempt_success(self) -> None: + ctx = MagicMock() + with patch( + 'autowsgr.ops.navigate.get_current_page', + return_value='main_page', + ): + assert identify_current_page(ctx) == 'main_page' + + def test_retry_then_success(self) -> None: + ctx = MagicMock() + with ( + patch( + 'autowsgr.ops.navigate.get_current_page', + side_effect=[None, None, 'main_page'], + ), + patch('autowsgr.ops.navigate.time.sleep') as mock_sleep, + ): + assert identify_current_page(ctx) == 'main_page' + assert mock_sleep.call_count == 2 + + def test_all_attempts_fail(self) -> None: + ctx = MagicMock() + with ( + patch( + 'autowsgr.ops.navigate.get_current_page', + return_value=None, + ), + patch('autowsgr.ops.navigate.time.sleep'), + ): + assert identify_current_page(ctx) is None + + +class TestGotoPage: + """_goto_page 导航逻辑测试。""" + + @pytest.fixture(autouse=True) + def _mock_save_image(self) -> Iterator[None]: + with patch('autowsgr.infra.logger.save_image'): + yield + + def test_already_at_target(self) -> None: + ctx = MagicMock() + with ( + patch( + 'autowsgr.ops.navigate.identify_current_page', + return_value='main_page', + ), + patch('autowsgr.ops.navigate.find_path') as mock_find, + ): + _goto_page(ctx, 'main_page') + mock_find.assert_not_called() + + def test_normal_navigation(self) -> None: + ctx = MagicMock() + edge = MagicMock() + edge.source = 'main_page' + edge.target = 'map_page' + edge.description = 'go_to_sortie' + edge.action = MagicMock() + + with ( + patch( + 'autowsgr.ops.navigate.identify_current_page', + side_effect=['main_page', 'map_page'], + ), + patch( + 'autowsgr.ops.navigate.find_path', + return_value=[edge], + ), + ): + _goto_page(ctx, 'map_page') + edge.action.assert_called_once_with(ctx) + + def test_path_not_found(self) -> None: + ctx = MagicMock() + with ( + patch( + 'autowsgr.ops.navigate.identify_current_page', + return_value='main_page', + ), + patch( + 'autowsgr.ops.navigate.find_path', + return_value=None, + ), + pytest.raises(NavigationError, match='无法找到'), + ): + _goto_page(ctx, 'unknown_page') + + def test_step_limit_exceeded(self) -> None: + ctx = MagicMock() + edge = MagicMock() + edge.action = MagicMock() + + # 每次 identify 都返回同一个页面,edge.action 不改变页面 + with ( + patch( + 'autowsgr.ops.navigate.identify_current_page', + return_value='main_page', + ), + patch( + 'autowsgr.ops.navigate.find_path', + return_value=[edge], + ), + pytest.raises(NavigationError, match='步数超限'), + ): + _goto_page(ctx, 'map_page') + + def test_cannot_identify_page(self) -> None: + ctx = MagicMock() + with ( + patch( + 'autowsgr.ops.navigate.identify_current_page', + return_value=None, + ), + pytest.raises(NavigationError, match='无法识别'), + ): + _goto_page(ctx, 'map_page') + + +class TestGotoPageWrapper: + """goto_page 包装器重试测试。""" + + def test_success_no_retry(self) -> None: + ctx = MagicMock() + with patch( + 'autowsgr.ops.navigate._goto_page', + ) as mock_goto: + goto_page(ctx, 'main_page') + mock_goto.assert_called_once_with(ctx, 'main_page') + + def test_retry_on_navigation_error(self) -> None: + ctx = MagicMock() + with ( + patch( + 'autowsgr.ops.navigate._goto_page', + side_effect=[NavigationError('fail'), None], + ) as mock_goto, + patch( + 'autowsgr.ops.navigate.identify_current_page', + return_value='main_page', + ), + ): + goto_page(ctx, 'map_page') + assert mock_goto.call_count == 2 + + def test_retry_raises_again(self) -> None: + ctx = MagicMock() + with ( + patch( + 'autowsgr.ops.navigate._goto_page', + side_effect=NavigationError('fail'), + ), + patch( + 'autowsgr.ops.navigate.identify_current_page', + return_value='main_page', + ), + pytest.raises(NavigationError), + ): + goto_page(ctx, 'map_page') diff --git a/tests/unit/ops/test_normal_fight.py b/tests/unit/ops/test_normal_fight.py new file mode 100644 index 00000000..e1e3e444 --- /dev/null +++ b/tests/unit/ops/test_normal_fight.py @@ -0,0 +1,351 @@ +"""测试 autowsgr.ops.normal_fight。""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from autowsgr.combat import CombatMode, CombatPlan, CombatResult +from autowsgr.combat.history import FightResult +from autowsgr.ops.normal_fight import ( + NormalFightRunner, + get_normal_fight_plan, + run_normal_fight, + run_normal_fight_from_yaml, +) +from autowsgr.types import ConditionFlag + + +class TestNormalFightRunnerInit: + """NormalFightRunner.__init__ 测试。""" + + def _make_ctx(self) -> MagicMock: + ctx = MagicMock() + ctx.config.dock_full_destroy = False + ctx.config.destroy_ship_types = None + return ctx + + def test_mode_correction(self) -> None: + """plan.mode 非 NORMAL 时警告并修正。""" + ctx = self._make_ctx() + plan = CombatPlan(mode=CombatMode.BATTLE, fleet_id=1) + + with patch('autowsgr.ops.normal_fight._log.warning') as mock_warn: + runner = NormalFightRunner(ctx, plan) + + mock_warn.assert_called_once() + assert plan.mode == CombatMode.NORMAL + assert runner._plan is plan + + def test_mode_no_correction(self) -> None: + """plan.mode 已经是 NORMAL 时不修正。""" + ctx = self._make_ctx() + plan = CombatPlan(mode=CombatMode.NORMAL, fleet_id=1) + + with patch('autowsgr.ops.normal_fight._log.warning') as mock_warn: + runner = NormalFightRunner(ctx, plan) + + mock_warn.assert_not_called() + assert runner._plan is plan + + def test_fleet_id_fallback(self) -> None: + """fleet_id 默认从 plan.fleet_id 回退。""" + ctx = self._make_ctx() + plan = CombatPlan(fleet_id=3) + runner = NormalFightRunner(ctx, plan) + assert runner._fleet_id == 3 + + def test_fleet_id_override(self) -> None: + """显式传入 fleet_id 覆盖 plan.fleet_id。""" + ctx = self._make_ctx() + plan = CombatPlan(fleet_id=3) + runner = NormalFightRunner(ctx, plan, fleet_id=5) + assert runner._fleet_id == 5 + + def test_fleet_fallback(self) -> None: + """fleet 默认从 plan.fleet 回退。""" + ctx = self._make_ctx() + plan = CombatPlan(fleet=['ship1', 'ship2']) + runner = NormalFightRunner(ctx, plan) + assert runner._fleet == ['ship1', 'ship2'] + + def test_fleet_override(self) -> None: + """显式传入 fleet 覆盖 plan.fleet。""" + ctx = self._make_ctx() + plan = CombatPlan(fleet=['ship1', 'ship2']) + runner = NormalFightRunner(ctx, plan, fleet=['ship3']) + assert runner._fleet == ['ship3'] + + def test_fleet_rules_set(self) -> None: + """fleet_rules 被正确设置。""" + ctx = self._make_ctx() + plan = CombatPlan() + rules = [{'candidates': ['A', 'B']}] + runner = NormalFightRunner(ctx, plan, fleet_rules=rules) + assert runner._fleet_rules is rules + + def test_dock_full_destroy_from_config(self) -> None: + """从 ctx.config 读取 dock_full_destroy 和 destroy_ship_types。""" + ctx = self._make_ctx() + ctx.config.dock_full_destroy = True + ctx.config.destroy_ship_types = ['DD', 'CL'] + plan = CombatPlan() + runner = NormalFightRunner(ctx, plan) + assert runner._dock_full_destroy is True + assert runner._destroy_ship_types == ['DD', 'CL'] + + +class TestPrimaryNamesFromRules: + """_primary_names_from_rules 静态方法测试。""" + + def test_none_returns_none(self) -> None: + assert NormalFightRunner._primary_names_from_rules(None) is None + + def test_empty_returns_none(self) -> None: + assert NormalFightRunner._primary_names_from_rules([]) is None + + def test_list_of_strings(self) -> None: + rules = ['ShipA', ' ShipB ', 'ShipC'] + result = NormalFightRunner._primary_names_from_rules(rules) + assert result == ['ShipA', 'ShipB', 'ShipC'] + + def test_dict_with_candidates(self) -> None: + rules = [{'candidates': ['First', 'Second']}, {'candidates': ['Only']}] + result = NormalFightRunner._primary_names_from_rules(rules) + assert result == ['First', 'Only'] + + def test_object_with_candidates(self) -> None: + class Rule: + def __init__(self, candidates: list[str]) -> None: + self.candidates = candidates + + rules = [Rule(['ObjA', 'ObjB']), Rule(['ObjC'])] + result = NormalFightRunner._primary_names_from_rules(rules) + assert result == ['ObjA', 'ObjC'] + + def test_more_than_6_slots_ignored(self) -> None: + rules = [f'ship{i}' for i in range(10)] + result = NormalFightRunner._primary_names_from_rules(rules) + assert result is not None + assert len(result) == 6 + assert result == [f'ship{i}' for i in range(6)] + + def test_empty_string_returns_none(self) -> None: + rules = ['', ' ', 'ShipA'] + result = NormalFightRunner._primary_names_from_rules(rules) + assert result == [None, None, 'ShipA'] + + def test_mixed_types(self) -> None: + class Rule: + def __init__(self, candidates: list[str]) -> None: + self.candidates = candidates + + rules = ['ShipA', {'candidates': ['DictA']}, Rule(['ObjA']), ''] + result = NormalFightRunner._primary_names_from_rules(rules) + assert result == ['ShipA', 'DictA', 'ObjA', None] + + +class TestRunForTimes: + """run_for_times 测试。""" + + def _make_runner(self) -> NormalFightRunner: + ctx = MagicMock() + ctx.config.dock_full_destroy = False + ctx.config.destroy_ship_types = None + plan = CombatPlan() + return NormalFightRunner(ctx, plan) + + @patch('autowsgr.ops.normal_fight.time.sleep') + def test_loop_count(self, mock_sleep: MagicMock) -> None: + """正常循环指定次数。""" + runner = self._make_runner() + with patch.object(runner, 'run') as mock_run: + mock_run.return_value = CombatResult(flag=ConditionFlag.OPERATION_SUCCESS) + results = runner.run_for_times(3) + + assert len(results) == 3 + assert mock_run.call_count == 3 + mock_sleep.assert_not_called() + + @patch('autowsgr.ops.normal_fight.time.sleep') + def test_gap_sleep(self, mock_sleep: MagicMock) -> None: + """gap 大于 0 时在非最后一次循环后 sleep。""" + runner = self._make_runner() + with patch.object(runner, 'run') as mock_run: + mock_run.return_value = CombatResult(flag=ConditionFlag.OPERATION_SUCCESS) + runner.run_for_times(3, gap=1.5) + + assert mock_sleep.call_count == 2 + mock_sleep.assert_called_with(1.5) + + @patch('autowsgr.ops.normal_fight.time.sleep') + def test_dock_full_break(self, mock_sleep: MagicMock) -> None: + """DOCK_FULL 时提前终止循环。""" + runner = self._make_runner() + with patch.object(runner, 'run') as mock_run: + mock_run.side_effect = [ + CombatResult(flag=ConditionFlag.OPERATION_SUCCESS), + CombatResult(flag=ConditionFlag.DOCK_FULL), + CombatResult(flag=ConditionFlag.OPERATION_SUCCESS), + ] + results = runner.run_for_times(3) + + assert len(results) == 2 + assert mock_run.call_count == 2 + mock_sleep.assert_not_called() + + +class TestRunForTimesCondition: + """run_for_times_condition 测试。""" + + def _make_runner(self) -> NormalFightRunner: + ctx = MagicMock() + ctx.config.dock_full_destroy = False + ctx.config.destroy_ship_types = None + plan = CombatPlan() + return NormalFightRunner(ctx, plan) + + def test_invalid_result_raises(self) -> None: + """非法 result 参数抛出 ValueError。""" + runner = self._make_runner() + with pytest.raises(ValueError, match='战果要求'): + runner.run_for_times_condition(1, 'A', result='X') + + def test_invalid_last_point_raises(self) -> None: + """非法 last_point 参数抛出 ValueError。""" + runner = self._make_runner() + with pytest.raises(ValueError, match='最后一个节点'): + runner.run_for_times_condition(1, 'AB') + with pytest.raises(ValueError, match='最后一个节点'): + runner.run_for_times_condition(1, '1') + + @patch('autowsgr.ops.normal_fight.time.time') + def test_condition_met_decrements_times(self, mock_time: MagicMock) -> None: + """满足条件时 times 递减。""" + runner = self._make_runner() + mock_time.side_effect = [0.0, 1.0, 2.0, 3.0] + + result = CombatResult(flag=ConditionFlag.OPERATION_SUCCESS) + result.history.events = [] + # Inject fight_results via history + fr = FightResult(node='A', grade='S') + with ( + patch.object(result.history, 'get_fight_results_list', return_value=[fr]), + patch.object(runner, 'run', return_value=result), + ): + results = runner.run_for_times_condition(2, 'A', result='S') + + assert isinstance(results, list) + assert len(results) == 2 + + @patch('autowsgr.ops.normal_fight.time.time') + def test_condition_not_met_no_decrement(self, mock_time: MagicMock) -> None: + """不满足条件时 times 不减, 超时时返回 False。""" + runner = self._make_runner() + mock_time.side_effect = [0.0, 1.0, 2.0, 15.0] + + result = CombatResult(flag=ConditionFlag.OPERATION_SUCCESS) + fr = FightResult(node='B', grade='A') + with ( + patch.object(result.history, 'get_fight_results_list', return_value=[fr]), + patch.object(runner, 'run', return_value=result), + ): + results = runner.run_for_times_condition(1, 'A', result='S', insist_time=10.0) + + assert results is False + + @patch('autowsgr.ops.normal_fight.time.time') + def test_timeout_returns_false(self, mock_time: MagicMock) -> None: + """超时返回 False。""" + runner = self._make_runner() + mock_time.side_effect = [0.0, 100.0, 200.0] + + result = CombatResult(flag=ConditionFlag.OPERATION_SUCCESS) + fr = FightResult(node='B', grade='A') + with ( + patch.object(result.history, 'get_fight_results_list', return_value=[fr]), + patch.object(runner, 'run', return_value=result), + ): + results = runner.run_for_times_condition(1, 'A', result='S', insist_time=50.0) + + assert results is False + + @patch('autowsgr.ops.normal_fight.time.time') + def test_dock_full_returns_early(self, mock_time: MagicMock) -> None: + """DOCK_FULL 时提前返回已收集结果。""" + runner = self._make_runner() + mock_time.side_effect = [0.0, 1.0] + + result = CombatResult(flag=ConditionFlag.DOCK_FULL) + with patch.object(runner, 'run', return_value=result): + results = runner.run_for_times_condition(1, 'A') + + assert isinstance(results, list) + assert len(results) == 1 + + +class TestGetNormalFightPlan: + """get_normal_fight_plan 测试。""" + + @patch('autowsgr.infra.file_utils.resolve_plan_path') + @patch('autowsgr.combat.plan.CombatPlan.from_yaml') + def test_load_plan(self, mock_from_yaml: MagicMock, mock_resolve: MagicMock) -> None: + mock_resolve.return_value = '/resolved/path.yaml' + mock_plan = MagicMock() + mock_from_yaml.return_value = mock_plan + + result = get_normal_fight_plan('some_plan') + + mock_resolve.assert_called_once_with('some_plan', category='normal_fight') + mock_from_yaml.assert_called_once_with('/resolved/path.yaml') + assert result is mock_plan + + +class TestRunNormalFight: + """run_normal_fight 与 run_normal_fight_from_yaml 测试。""" + + @patch('autowsgr.ops.normal_fight.NormalFightRunner') + def test_run_normal_fight(self, mock_runner_cls: MagicMock) -> None: + ctx = MagicMock() + plan = MagicMock() + mock_runner = mock_runner_cls.return_value + mock_runner.run_for_times.return_value = ['result1', 'result2'] + + results = run_normal_fight(ctx, plan, times=2, gap=1.0, fleet_id=2) + + mock_runner_cls.assert_called_once_with( + ctx, + plan, + fleet_id=2, + fleet=None, + fleet_rules=None, + ) + mock_runner.run_for_times.assert_called_once_with(2, gap=1.0) + assert results == ['result1', 'result2'] + + @patch('autowsgr.ops.normal_fight.get_normal_fight_plan') + @patch('autowsgr.ops.normal_fight.NormalFightRunner') + def test_run_normal_fight_from_yaml( + self, + mock_runner_cls: MagicMock, + mock_get_plan: MagicMock, + ) -> None: + ctx = MagicMock() + mock_plan = MagicMock() + mock_get_plan.return_value = mock_plan + mock_runner = mock_runner_cls.return_value + mock_runner.run_for_times.return_value = ['result'] + + results = run_normal_fight_from_yaml(ctx, 'my_plan', times=3, fleet=['ship']) + + mock_get_plan.assert_called_once_with('my_plan') + mock_runner_cls.assert_called_once_with( + ctx, + mock_plan, + fleet_id=None, + fleet=['ship'], + fleet_rules=None, + ) + mock_runner.run_for_times.assert_called_once_with(3, gap=0.0) + assert results == ['result'] diff --git a/tests/unit/ops/test_repair.py b/tests/unit/ops/test_repair.py new file mode 100644 index 00000000..f3a1a345 --- /dev/null +++ b/tests/unit/ops/test_repair.py @@ -0,0 +1,71 @@ +"""测试 autowsgr.ops.repair。""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from autowsgr.ops.repair import repair_in_bath, repair_ship_by_name +from autowsgr.types import PageName + + +class TestRepairInBath: + """repair_in_bath 测试。""" + + @patch('autowsgr.ops.repair.BathPage') + def test_repair_all(self, mock_page_cls: MagicMock) -> None: + ctx = MagicMock() + mock_page = mock_page_cls.return_value + + with ( + patch('autowsgr.ops.repair.goto_page') as mock_goto, + patch('time.sleep') as mock_sleep, + ): + repair_in_bath(ctx) + + mock_goto.assert_any_call(ctx, PageName.BATH) + mock_goto.assert_any_call(ctx, PageName.MAIN) + mock_page.go_to_choose_repair.assert_called_once() + mock_page.click_repair_all.assert_called_once() + mock_sleep.assert_called_once_with(1.0) + + def test_recovery_to_main_raises(self) -> None: + ctx = MagicMock() + + with ( + patch('autowsgr.ops.repair.goto_page') as mock_goto, + patch('autowsgr.ops.repair.BathPage'), + patch('time.sleep'), + ): + mock_goto.side_effect = [None, Exception('transition')] + repair_in_bath(ctx) + assert mock_goto.call_count == 2 + + +class TestRepairShipByName: + """repair_ship_by_name 测试。""" + + @patch('autowsgr.ops.repair.BathPage') + def test_repair_success(self, mock_page_cls: MagicMock) -> None: + ctx = MagicMock() + mock_ship = MagicMock() + ctx.get_ship.return_value = mock_ship + mock_page = mock_page_cls.return_value + mock_page.repair_ship.return_value = 1800 + + with patch('autowsgr.ops.repair.goto_page'): + result = repair_ship_by_name(ctx, '岛风') + + assert result == 1800 + mock_ship.set_repair.assert_called_once_with(1800) + ctx.get_ship.assert_called_once_with('岛风') + + @patch('autowsgr.ops.repair.BathPage') + def test_bath_full(self, mock_page_cls: MagicMock) -> None: + ctx = MagicMock() + mock_page = mock_page_cls.return_value + mock_page.repair_ship.return_value = -1 + + with patch('autowsgr.ops.repair.goto_page'): + result = repair_ship_by_name(ctx, '岛风') + + assert result == -1 diff --git a/tests/unit/ops/test_reward.py b/tests/unit/ops/test_reward.py new file mode 100644 index 00000000..3be62985 --- /dev/null +++ b/tests/unit/ops/test_reward.py @@ -0,0 +1,67 @@ +"""测试 autowsgr.ops.reward。""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from autowsgr.ops.reward import collect_rewards +from autowsgr.types import PageName + + +class TestCollectRewards: + """collect_rewards 测试。""" + + @patch('autowsgr.ops.reward.MainPage') + @patch('autowsgr.ops.reward.MissionPage') + def test_no_rewards( + self, + mock_mission_cls: MagicMock, + mock_main_cls: MagicMock, + ) -> None: + ctx = MagicMock() + mock_main_cls.has_task_ready.return_value = False + + with patch('autowsgr.ops.reward.goto_page') as mock_goto: + result = collect_rewards(ctx) + + assert result is False + mock_goto.assert_called_once_with(ctx, PageName.MAIN) + mock_mission_cls.assert_not_called() + + @patch('autowsgr.ops.reward.MainPage') + @patch('autowsgr.ops.reward.MissionPage') + def test_collect_success( + self, + mock_mission_cls: MagicMock, + mock_main_cls: MagicMock, + ) -> None: + ctx = MagicMock() + mock_main_cls.has_task_ready.return_value = True + mock_mission = mock_mission_cls.return_value + mock_mission.collect_rewards.return_value = True + + with patch('autowsgr.ops.reward.goto_page') as mock_goto: + result = collect_rewards(ctx) + + assert mock_goto.call_count == 3 + mock_goto.assert_any_call(ctx, PageName.MAIN) + mock_goto.assert_any_call(ctx, PageName.MISSION) + assert result is True + mock_mission.collect_rewards.assert_called_once() + + @patch('autowsgr.ops.reward.MainPage') + @patch('autowsgr.ops.reward.MissionPage') + def test_collect_failure( + self, + mock_mission_cls: MagicMock, + mock_main_cls: MagicMock, + ) -> None: + ctx = MagicMock() + mock_main_cls.has_task_ready.return_value = True + mock_mission = mock_mission_cls.return_value + mock_mission.collect_rewards.return_value = False + + with patch('autowsgr.ops.reward.goto_page'): + result = collect_rewards(ctx) + + assert result is False diff --git a/tests/unit/ops/test_startup.py b/tests/unit/ops/test_startup.py new file mode 100644 index 00000000..aadc6bf7 --- /dev/null +++ b/tests/unit/ops/test_startup.py @@ -0,0 +1,240 @@ +"""测试 autowsgr.ops.startup。""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from autowsgr.ops.startup import ( + ensure_game_ready, + go_main_page, + is_game_running, + is_on_main_page, + recover_to_main_or_restart, + restart_game, + start_game, + wait_for_game_ui, +) +from autowsgr.types import PageName +from autowsgr.ui.utils import NavigationError + + +class TestIsGameRunning: + """is_game_running 测试。""" + + def test_running(self) -> None: + ctrl = MagicMock() + ctrl.is_app_running.return_value = True + assert is_game_running(ctrl) is True + + def test_not_running(self) -> None: + ctrl = MagicMock() + ctrl.is_app_running.return_value = False + assert is_game_running(ctrl) is False + + +class TestIsOnMainPage: + """is_on_main_page 测试。""" + + @patch('autowsgr.ops.startup.MainPage') + def test_on_main_page(self, mock_main_cls: MagicMock) -> None: + ctrl = MagicMock() + mock_main_cls.is_current_page.return_value = True + assert is_on_main_page(ctrl) is True + + @patch('autowsgr.ops.startup.MainPage') + def test_not_on_main_page(self, mock_main_cls: MagicMock) -> None: + ctrl = MagicMock() + mock_main_cls.is_current_page.return_value = False + assert is_on_main_page(ctrl) is False + + +class TestWaitForGameUI: + """wait_for_game_ui 测试。""" + + @patch('autowsgr.ops.startup.StartScreenPage') + def test_detects_start_screen(self, mock_screen_cls: MagicMock) -> None: + ctrl = MagicMock() + mock_screen_cls.is_current_page.return_value = True + + with patch('time.sleep'): + result = wait_for_game_ui(ctrl, timeout=1.0, interval=0.1) + + assert result is True + + @patch('autowsgr.ops.startup.StartScreenPage') + def test_timeout(self, mock_screen_cls: MagicMock) -> None: + ctrl = MagicMock() + mock_screen_cls.is_current_page.return_value = False + + with patch('time.sleep'): + result = wait_for_game_ui(ctrl, timeout=0.1, interval=0.1) + + assert result is False + + +class TestStartGame: + """start_game 测试。""" + + @patch('autowsgr.ops.startup.wait_for_game_ui') + @patch('autowsgr.ops.startup.StartScreenPage') + def test_cold_start_no_start_screen( + self, + mock_screen_cls: MagicMock, + mock_wait: MagicMock, + ) -> None: + ctrl = MagicMock() + mock_wait.return_value = True + mock_screen_cls.is_current_page.return_value = False + + start_game(ctrl) + + ctrl.start_app.assert_called_once() + mock_wait.assert_called_once() + + @patch('autowsgr.ops.startup.wait_for_game_ui') + @patch('autowsgr.ops.startup.StartScreenPage') + def test_cold_start_with_start_screen( + self, + mock_screen_cls: MagicMock, + mock_wait: MagicMock, + ) -> None: + ctrl = MagicMock() + mock_wait.return_value = True + mock_screen_cls.is_current_page.return_value = True + + start_game(ctrl) + + mock_screen_cls.return_value.click_enter.assert_called_once() + + @patch('autowsgr.ops.startup.wait_for_game_ui') + def test_timeout_raises(self, mock_wait: MagicMock) -> None: + ctrl = MagicMock() + mock_wait.return_value = False + + with pytest.raises(TimeoutError, match='游戏启动超时'): + start_game(ctrl) + + +class TestRestartGame: + """restart_game 测试。""" + + @patch('autowsgr.ops.startup.start_game') + def test_restart(self, mock_start: MagicMock) -> None: + ctrl = MagicMock() + + with patch('time.sleep'): + restart_game(ctrl) + + ctrl.stop_app.assert_called_once() + mock_start.assert_called_once_with(ctrl, 'com.huanmeng.zhanjian2', startup_timeout=120.0) + + +class TestGoMainPage: + """go_main_page 测试。""" + + @patch('autowsgr.ops.startup.goto_page') + def test_navigates(self, mock_goto: MagicMock) -> None: + ctx = MagicMock() + go_main_page(ctx) + mock_goto.assert_called_once_with(ctx, PageName.MAIN) + + +class TestRecoverToMainOrRestart: + """recover_to_main_or_restart 测试。""" + + @patch('autowsgr.ops.navigate.identify_current_page') + @patch('autowsgr.ops.startup.restart_game') + def test_page_identified_early_return( + self, + mock_restart: MagicMock, + mock_identify: MagicMock, + ) -> None: + ctx = MagicMock() + mock_identify.return_value = 'main_page' + + with patch('time.sleep'): + recover_to_main_or_restart(ctx) + + mock_identify.assert_called_once_with(ctx) + mock_restart.assert_not_called() + + @patch('autowsgr.ops.navigate.identify_current_page') + @patch('autowsgr.ops.startup.goto_page') + @patch('autowsgr.ops.startup.restart_game') + def test_recover_success( + self, + mock_restart: MagicMock, + mock_goto: MagicMock, + mock_identify: MagicMock, + ) -> None: + ctx = MagicMock() + mock_identify.side_effect = [None, None, 'main_page'] + mock_goto.side_effect = [None, None] + + with patch('time.sleep'): + recover_to_main_or_restart(ctx, timeout=0.3) + + assert mock_goto.call_count >= 1 + mock_restart.assert_not_called() + + @patch('autowsgr.ops.navigate.identify_current_page') + @patch('autowsgr.ops.startup.goto_page') + @patch('autowsgr.ops.startup.restart_game') + def test_recover_timeout_restarts( + self, + mock_restart: MagicMock, + mock_goto: MagicMock, + mock_identify: MagicMock, + ) -> None: + ctx = MagicMock() + mock_identify.return_value = None + mock_goto.side_effect = NavigationError('nav fail') + + with patch('time.sleep'): + recover_to_main_or_restart(ctx, timeout=0.1) + + mock_restart.assert_called_once() + assert mock_goto.call_count >= 1 + + +class TestEnsureGameReady: + """ensure_game_ready 测试。""" + + @patch('autowsgr.ops.startup.is_game_running') + @patch('autowsgr.ops.startup.start_game') + def test_not_running_starts( + self, + mock_start: MagicMock, + mock_is_running: MagicMock, + ) -> None: + ctx = MagicMock() + mock_is_running.return_value = False + + ensure_game_ready(ctx) + + mock_start.assert_called_once() + + @patch('autowsgr.ops.startup.is_game_running') + @patch('autowsgr.ops.startup.recover_to_main_or_restart') + def test_running_recover( + self, + mock_recover: MagicMock, + mock_is_running: MagicMock, + ) -> None: + ctx = MagicMock() + mock_is_running.return_value = True + + ensure_game_ready(ctx) + + mock_recover.assert_called_once() + + def test_app_str_package(self) -> None: + ctx = MagicMock() + with ( + patch('autowsgr.ops.startup.is_game_running', return_value=True), + patch('autowsgr.ops.startup.recover_to_main_or_restart') as mock_recover, + ): + ensure_game_ready(ctx, app='com.test.package') + mock_recover.assert_called_once() diff --git a/tests/unit/scheduler/__init__.py b/tests/unit/scheduler/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/scheduler/test_launcher.py b/tests/unit/scheduler/test_launcher.py new file mode 100644 index 00000000..8026d94e --- /dev/null +++ b/tests/unit/scheduler/test_launcher.py @@ -0,0 +1,101 @@ +"""测试 autowsgr.scheduler.launcher。""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from autowsgr.scheduler.launcher import Launcher + + +def test_launcher_init_with_path() -> None: + """构造时应正确存储配置路径。""" + launcher = Launcher(config_path='settings.yaml') + assert launcher._config_path == Path('settings.yaml') + + +def test_launcher_init_without_path() -> None: + """未传入路径时 _config_path 应为 None。""" + launcher = Launcher() + assert launcher._config_path is None + + +def test_config_not_loaded_raises() -> None: + """未加载配置时访问 config 应抛出 RuntimeError。""" + launcher = Launcher() + with pytest.raises(RuntimeError, match='配置未加载'): + _ = launcher.config + + +def test_set_config() -> None: + """set_config 应允许手动注入配置。""" + launcher = Launcher() + cfg = MagicMock() + launcher.set_config(cfg) + assert launcher.config is cfg + + +def test_load_config_patches() -> None: + """load_config 应调用 ConfigManager.load 并初始化日志。""" + launcher = Launcher(config_path='dummy.yaml') + cfg = MagicMock() + cfg.log.dir = Path('/tmp/log') + cfg.log.level = 'INFO' + cfg.log.effective_channels = None + + with ( + patch( + 'autowsgr.scheduler.launcher.ConfigManager.load', + return_value=cfg, + ), + patch('autowsgr.scheduler.launcher.setup_logger') as mock_setup, + ): + result = launcher.load_config() + assert result is cfg + mock_setup.assert_called_once_with( + Path('/tmp/log'), + 'INFO', + save_images=False, + channels=None, + ) + + +def test_ctrl_not_connected_raises() -> None: + """未连接设备时访问 ctrl 应抛出 RuntimeError。""" + launcher = Launcher() + with pytest.raises(RuntimeError, match='设备未连接'): + _ = launcher.ctrl + + +def test_build_context_creates_game_context() -> None: + """build_context 应构造 GameContext 并注入依赖。""" + launcher = Launcher() + cfg = MagicMock() + ctrl = MagicMock() + ocr = MagicMock() + launcher.set_config(cfg) + launcher._ctrl = ctrl + launcher._ocr = ocr + + ctx = launcher.build_context() + assert ctx.ctrl is ctrl + assert ctx.config is cfg + assert ctx.ocr is ocr + + +def test_launch_sequence() -> None: + """launch 应按正确顺序调用各步骤。""" + launcher = Launcher(config_path='dummy.yaml') + with ( + patch.object(launcher, 'load_config') as mock_load, + patch.object(launcher, 'connect') as mock_connect, + patch.object(launcher, 'build_context') as mock_build, + patch.object(launcher, 'ensure_ready'), + ): + mock_build.return_value = MagicMock() + launcher.launch(ensure_game=True) + mock_load.assert_called_once() + mock_connect.assert_called_once() + mock_build.assert_called_once() diff --git a/tests/unit/scheduler/test_scheduler.py b/tests/unit/scheduler/test_scheduler.py new file mode 100644 index 00000000..2a5e443d --- /dev/null +++ b/tests/unit/scheduler/test_scheduler.py @@ -0,0 +1,113 @@ +"""测试 autowsgr.scheduler.scheduler。""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from autowsgr.combat import CombatResult +from autowsgr.scheduler.scheduler import ( + BatchRunnerAdapter, + FightTask, + TaskScheduler, +) +from autowsgr.types import ConditionFlag + + +def test_batch_runner_adapter_with_list() -> None: + """BatchRunnerAdapter 应将 list 结果取最后一个元素。""" + inner = MagicMock() + inner.run.return_value = [ + CombatResult(flag=ConditionFlag.FIGHT_CONTINUE), + CombatResult(flag=ConditionFlag.OPERATION_SUCCESS), + ] + adapter = BatchRunnerAdapter(inner) + result = adapter.run() + assert result.flag == ConditionFlag.OPERATION_SUCCESS + + +def test_batch_runner_adapter_empty_list() -> None: + """空 list 时应返回默认成功结果。""" + inner = MagicMock() + inner.run.return_value = [] + adapter = BatchRunnerAdapter(inner) + result = adapter.run() + assert result.flag == ConditionFlag.OPERATION_SUCCESS + + +def test_batch_runner_adapter_single_result() -> None: + """单条结果应原样返回。""" + inner = MagicMock() + inner.run.return_value = CombatResult(flag=ConditionFlag.FIGHT_END) + adapter = BatchRunnerAdapter(inner) + result = adapter.run() + assert result.flag == ConditionFlag.FIGHT_END + + +def test_batch_runner_adapter_no_run_method() -> None: + """inner 没有 run 方法时应抛出 TypeError。""" + with pytest.raises(TypeError, match='没有 run\\(\\) 方法'): + BatchRunnerAdapter(object()) + + +def test_fight_task_default_name() -> None: + """未指定名称时应自动使用 runner 类名。""" + runner = MagicMock() + task = FightTask(runner=runner, times=3) + assert task.name == 'MagicMock' + assert task.times == 3 + + +def test_task_scheduler_add_and_tasks() -> None: + """add 应支持链式调用,tasks 应返回只读副本。""" + ctx = MagicMock() + scheduler = TaskScheduler(ctx) + t1 = FightTask(MagicMock(), times=1) + t2 = FightTask(MagicMock(), times=2) + ret = scheduler.add(t1).add(t2) + assert ret is scheduler + assert len(scheduler.tasks) == 2 + + +def test_task_scheduler_run_empty() -> None: + """无任务时应直接返回空列表。""" + ctx = MagicMock() + scheduler = TaskScheduler(ctx) + assert scheduler.run() == [] + + +def test_task_scheduler_run_success() -> None: + """正常 runner 应被正确执行指定次数。""" + ctx = MagicMock() + scheduler = TaskScheduler(ctx, expedition_interval=0) + runner = MagicMock() + runner.run.return_value = CombatResult(flag=ConditionFlag.OPERATION_SUCCESS) + task = FightTask(runner=runner, times=3) + scheduler.add(task) + results = scheduler.run() + assert len(results) == 1 + assert results[0].completed == 3 + assert len(results[0].results) == 3 + assert runner.run.call_count == 3 + + +def test_task_scheduler_dock_full_stops() -> None: + """船坞满时应中断当前任务。""" + ctx = MagicMock() + scheduler = TaskScheduler(ctx, expedition_interval=0) + runner = MagicMock() + runner.run.return_value = CombatResult(flag=ConditionFlag.DOCK_FULL) + task = FightTask(runner=runner, times=5) + scheduler.add(task) + scheduler.run() + assert task.completed == 1 + assert runner.run.call_count == 1 + + +def test_task_scheduler_may_collect_expedition_disabled() -> None: + """interval <= 0 时不应触发远征检查。""" + ctx = MagicMock() + scheduler = TaskScheduler(ctx, expedition_interval=-1) + scheduler._maybe_collect_expedition() + # 只要不抛出异常即可 diff --git a/tests/unit/server/__init__.py b/tests/unit/server/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/server/routes/__init__.py b/tests/unit/server/routes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/server/routes/test_game.py b/tests/unit/server/routes/test_game.py new file mode 100644 index 00000000..4ab85c69 --- /dev/null +++ b/tests/unit/server/routes/test_game.py @@ -0,0 +1,257 @@ +"""测试 autowsgr.server.routes.game.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock, patch + +from fastapi.testclient import TestClient + +from autowsgr.server.main import app + + +client = TestClient(app) + + +class TestGameAcquisition: + """game_acquisition 测试。""" + + @patch('autowsgr.server.routes.game.get_context') + def test_no_context(self, mock_get_context: MagicMock) -> None: + """无上下文时应返回 503。""" + mock_get_context.side_effect = RuntimeError('系统未启动') + + response = client.get('/api/game/acquisition') + assert response.status_code == 503 + assert response.json()['detail'] == '系统未启动' + + @patch('autowsgr.server.routes.game.task_manager') + @patch('autowsgr.server.routes.game.get_context') + def test_task_running( + self, + mock_get_context: MagicMock, + mock_task_manager: MagicMock, + ) -> None: + """任务执行中时应返回 409。""" + mock_get_context.return_value = MagicMock() + mock_task_manager.is_running = True + + response = client.get('/api/game/acquisition') + assert response.status_code == 409 + assert response.json()['detail'] == '任务执行中,无法查询获取数量' + + @patch('autowsgr.ui.map.page.MapPage') + @patch('autowsgr.ops.navigate.goto_page') + @patch('autowsgr.server.routes.game.task_manager') + @patch('autowsgr.server.routes.game.get_context') + def test_success( + self, + mock_get_context: MagicMock, + mock_task_manager: MagicMock, + mock_goto_page: MagicMock, + mock_map_page_cls: MagicMock, + ) -> None: + """空闲时应成功识别并返回获取数量。""" + mock_get_context.return_value = MagicMock() + mock_task_manager.is_running = False + + mock_counts = MagicMock() + mock_counts.ship_count = 100 + mock_counts.ship_max = 500 + mock_counts.loot_count = 10 + mock_counts.loot_max = 50 + + mock_map_page = MagicMock() + mock_map_page.get_acquisition_counts.return_value = mock_counts + mock_map_page_cls.return_value = mock_map_page + + response = client.get('/api/game/acquisition') + assert response.status_code == 200 + + data: dict[str, Any] = response.json() + assert data['success'] is True + assert data['data'] == { + 'ship_count': 100, + 'ship_max': 500, + 'loot_count': 10, + 'loot_max': 50, + } + assert data['message'] == '获取数量识别完成' + mock_goto_page.assert_called_once() + mock_map_page_cls.assert_called_once() + mock_map_page.get_acquisition_counts.assert_called_once() + + +class TestGameContextInfo: + """game_context_info 测试。""" + + @patch('autowsgr.server.routes.game.get_context') + def test_no_context(self, mock_get_context: MagicMock) -> None: + """无上下文时应返回 503。""" + mock_get_context.side_effect = RuntimeError('系统未启动') + + response = client.get('/api/game/context') + assert response.status_code == 503 + assert response.json()['detail'] == '系统未启动' + + @patch('autowsgr.server.routes.game.get_context') + def test_success(self, mock_get_context: MagicMock) -> None: # noqa: PLR0915 + """有上下文时应返回完整游戏状态。""" + mock_resources = MagicMock() + mock_resources.fuel = 1000 + mock_resources.ammo = 2000 + mock_resources.steel = 3000 + mock_resources.aluminum = 4000 + mock_resources.diamond = 500 + mock_resources.fast_repair = 10 + mock_resources.fast_build = 5 + mock_resources.ship_blueprint = 3 + mock_resources.equipment_blueprint = 2 + + mock_ship = MagicMock() + mock_ship.name = '驱逐' + mock_ship.ship_type = None + mock_ship.level = 100 + mock_ship.health = 30 + mock_ship.max_health = 30 + mock_ship.damage_state = MagicMock() + mock_ship.damage_state.value = 0 + mock_ship.locked = True + + mock_fleet = MagicMock() + mock_fleet.fleet_id = 1 + mock_fleet.ships = [mock_ship] + mock_fleet.size = 1 + mock_fleet.has_severely_damaged = False + + mock_expedition = MagicMock() + mock_expedition.chapter = 2 + mock_expedition.node = 'A' + mock_expedition.fleet = mock_fleet + mock_expedition.is_active = True + mock_expedition.remaining_seconds = 3600 + + mock_expeditions = MagicMock() + mock_expeditions.expeditions = [mock_expedition] + mock_expeditions.active_count = 1 + mock_expeditions.idle_count = 3 + + mock_build_slot = MagicMock() + mock_build_slot.occupied = True + mock_build_slot.remaining_seconds = 1800 + mock_build_slot.is_complete = False + mock_build_slot.is_idle = False + + mock_build_queue = MagicMock() + mock_build_queue.slots = [mock_build_slot] + mock_build_queue.idle_count = 3 + mock_build_queue.complete_count = 0 + + mock_ctx = MagicMock() + mock_ctx.resources = mock_resources + mock_ctx.fleets = [mock_fleet] + mock_ctx.expeditions = mock_expeditions + mock_ctx.build_queue = mock_build_queue + mock_ctx.dropped_ship_count = 5 + mock_ctx.dropped_loot_count = 2 + mock_ctx.quick_repair_used = 1 + mock_ctx.current_page = '主页面' + mock_get_context.return_value = mock_ctx + + response = client.get('/api/game/context') + assert response.status_code == 200 + + data: dict[str, Any] = response.json() + assert data['success'] is True + assert data['data']['dropped_ship_count'] == 5 + assert data['data']['dropped_loot_count'] == 2 + assert data['data']['quick_repair_used'] == 1 + assert data['data']['current_page'] == '主页面' + assert data['data']['resources']['fuel'] == 1000 + assert len(data['data']['fleets']) == 1 + assert data['data']['fleets'][0]['fleet_id'] == 1 + assert data['data']['expeditions']['active_count'] == 1 + assert data['data']['build_queue']['idle_count'] == 3 + + +class TestExpeditionStatus: + """expedition_status 测试。""" + + @patch('autowsgr.server.routes.game.get_context') + def test_no_context(self, mock_get_context: MagicMock) -> None: + """无上下文时应返回 503。""" + mock_get_context.side_effect = RuntimeError('系统未启动') + + response = client.get('/api/expedition/status') + assert response.status_code == 503 + assert response.json()['detail'] == '系统未启动' + + @patch('autowsgr.server.routes.game.get_context') + def test_success(self, mock_get_context: MagicMock) -> None: + """有上下文时应返回远征队列。""" + mock_expedition = MagicMock() + mock_expedition.chapter = 1 + mock_expedition.node = 'B' + mock_expedition.fleet = None + mock_expedition.is_active = False + mock_expedition.remaining_seconds = 0 + + mock_expeditions = MagicMock() + mock_expeditions.expeditions = [mock_expedition] + mock_expeditions.active_count = 0 + mock_expeditions.idle_count = 4 + + mock_ctx = MagicMock() + mock_ctx.expeditions = mock_expeditions + mock_get_context.return_value = mock_ctx + + response = client.get('/api/expedition/status') + assert response.status_code == 200 + + data: dict[str, Any] = response.json() + assert data['success'] is True + assert data['data']['active_count'] == 0 + assert data['data']['idle_count'] == 4 + assert len(data['data']['slots']) == 1 + assert data['data']['slots'][0]['chapter'] == 1 + + +class TestBuildStatus: + """build_status 测试。""" + + @patch('autowsgr.server.routes.game.get_context') + def test_no_context(self, mock_get_context: MagicMock) -> None: + """无上下文时应返回 503。""" + mock_get_context.side_effect = RuntimeError('系统未启动') + + response = client.get('/api/build/status') + assert response.status_code == 503 + assert response.json()['detail'] == '系统未启动' + + @patch('autowsgr.server.routes.game.get_context') + def test_success(self, mock_get_context: MagicMock) -> None: + """有上下文时应返回建造队列。""" + mock_build_slot = MagicMock() + mock_build_slot.occupied = False + mock_build_slot.remaining_seconds = 0 + mock_build_slot.is_complete = False + mock_build_slot.is_idle = True + + mock_build_queue = MagicMock() + mock_build_queue.slots = [mock_build_slot] + mock_build_queue.idle_count = 4 + mock_build_queue.complete_count = 0 + + mock_ctx = MagicMock() + mock_ctx.build_queue = mock_build_queue + mock_get_context.return_value = mock_ctx + + response = client.get('/api/build/status') + assert response.status_code == 200 + + data: dict[str, Any] = response.json() + assert data['success'] is True + assert data['data']['idle_count'] == 4 + assert data['data']['complete_count'] == 0 + assert len(data['data']['slots']) == 1 + assert data['data']['slots'][0]['is_idle'] is True diff --git a/tests/unit/server/routes/test_health.py b/tests/unit/server/routes/test_health.py new file mode 100644 index 00000000..1377eac7 --- /dev/null +++ b/tests/unit/server/routes/test_health.py @@ -0,0 +1,85 @@ +"""测试 autowsgr.server.routes.health.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from autowsgr.server.routes.health import router + + +@pytest.fixture +def client() -> TestClient: + """创建包含 health 路由的最小 FastAPI 应用。""" + app = FastAPI() + app.include_router(router) + return TestClient(app) + + +def test_health_check_no_context_no_task(client: TestClient) -> None: + """无上下文、无任务时应返回基本健康信息。""" + with ( + patch('autowsgr.server.routes.health._main') as mock_main, + patch('autowsgr.server.routes.health.task_manager') as mock_task_manager, + ): + mock_main._ctx = None + mock_task_manager.current_task = None + mock_task_manager.is_running = False + + response = client.get('/api/health') + assert response.status_code == 200 + + data: dict[str, Any] = response.json() + assert data['success'] is True + assert data['data']['status'] == 'ok' + assert data['data']['emulator_connected'] is False + assert data['data']['current_task'] is None + assert isinstance(data['data']['uptime_seconds'], int) + assert data['data']['uptime_seconds'] >= 0 + + +def test_health_check_emulator_connected(client: TestClient) -> None: + """模拟器已连接时 emulator_connected 应为 True。""" + with ( + patch('autowsgr.server.routes.health._main') as mock_main, + patch('autowsgr.server.routes.health.task_manager') as mock_task_manager, + ): + mock_main._ctx = object() + mock_task_manager.current_task = None + mock_task_manager.is_running = False + + response = client.get('/api/health') + assert response.status_code == 200 + + data: dict[str, Any] = response.json() + assert data['data']['emulator_connected'] is True + assert data['data']['current_task'] is None + + +def test_health_check_with_running_task(client: TestClient) -> None: + """有正在运行的任务时应返回任务信息。""" + mock_task = MagicMock() + mock_task.task_id = 'task_abc123' + mock_task.status.value = 'running' + + with ( + patch('autowsgr.server.routes.health._main') as mock_main, + patch('autowsgr.server.routes.health.task_manager') as mock_task_manager, + ): + mock_main._ctx = None + mock_task_manager.current_task = mock_task + mock_task_manager.is_running = True + + response = client.get('/api/health') + assert response.status_code == 200 + + data: dict[str, Any] = response.json() + assert data['data']['emulator_connected'] is False + assert data['data']['current_task'] == { + 'task_id': 'task_abc123', + 'status': 'running', + } diff --git a/tests/unit/server/routes/test_ops.py b/tests/unit/server/routes/test_ops.py new file mode 100644 index 00000000..63061fb4 --- /dev/null +++ b/tests/unit/server/routes/test_ops.py @@ -0,0 +1,641 @@ +"""测试 autowsgr.server.routes.ops.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +from autowsgr.server.routes.ops import router + + +@pytest.fixture +def client() -> TestClient: + """创建包含 ops 路由的最小 FastAPI 应用。""" + from fastapi import FastAPI + + app = FastAPI() + app.include_router(router) + return TestClient(app) + + +class TestRequireIdle: + """_require_idle 行为测试。""" + + @patch('autowsgr.server.routes.ops.task_manager') + def test_raises_409_when_running(self, mock_task_manager: MagicMock) -> None: + """任务执行中时应抛出 409。""" + from fastapi import HTTPException + + from autowsgr.server.routes.ops import _require_idle + + mock_task_manager.is_running = True + with pytest.raises(HTTPException) as exc_info: + _require_idle() + assert exc_info.value.status_code == 409 + assert '任务执行中' in exc_info.value.detail + + +class TestExpeditionCheck: + """POST /api/expedition/check 测试。""" + + @patch('autowsgr.server.routes.ops.get_context') + def test_503_no_context(self, mock_get_context: MagicMock, client: TestClient) -> None: + """无上下文时返回 503。""" + mock_get_context.side_effect = RuntimeError('context not initialized') + + response = client.post('/api/expedition/check') + + assert response.status_code == 503 + assert 'context not initialized' in response.json()['detail'] + + @patch('autowsgr.server.routes.ops.task_manager') + @patch('autowsgr.server.routes.ops.get_context') + def test_409_busy( + self, + mock_get_context: MagicMock, + mock_task_manager: MagicMock, + client: TestClient, + ) -> None: + """任务执行中时返回 409。""" + mock_get_context.return_value = MagicMock() + mock_task_manager.is_running = True + + response = client.post('/api/expedition/check') + + assert response.status_code == 409 + + @patch('autowsgr.ops.expedition.collect_expedition') + @patch('autowsgr.server.routes.ops.task_manager') + @patch('autowsgr.server.routes.ops.get_context') + def test_success( + self, + mock_get_context: MagicMock, + mock_task_manager: MagicMock, + mock_collect: MagicMock, + client: TestClient, + ) -> None: + """正常收取远征。""" + mock_get_context.return_value = MagicMock() + mock_task_manager.is_running = False + mock_collect.return_value = 3 + + response = client.post('/api/expedition/check') + data: dict[str, Any] = response.json() + + assert response.status_code == 200 + assert data['success'] is True + assert data['data']['collected'] == 3 + assert '远征检查完成' in data['message'] + mock_collect.assert_called_once() + + +class TestExpeditionAutoCheck: + """POST /api/expedition/auto_check 测试。""" + + @patch('autowsgr.server.routes.ops.get_context') + def test_503_no_context(self, mock_get_context: MagicMock, client: TestClient) -> None: + """无上下文时返回 503。""" + mock_get_context.side_effect = RuntimeError('context not initialized') + + response = client.post('/api/expedition/auto_check', json={}) + + assert response.status_code == 503 + + @patch('autowsgr.ops.repair.repair_in_bath') + @patch('autowsgr.ops.reward.collect_rewards') + @patch('autowsgr.ops.expedition.collect_expedition') + @patch('autowsgr.server.routes.ops.task_manager') + @patch('autowsgr.server.routes.ops.get_context') + def test_repair_skipped_when_task_running( + self, + mock_get_context: MagicMock, + mock_task_manager: MagicMock, + mock_collect_exp: MagicMock, + mock_collect_rew: MagicMock, + mock_repair: MagicMock, + client: TestClient, + ) -> None: + """有战斗任务运行时跳过维修。""" + mock_get_context.return_value = MagicMock() + mock_task_manager.is_running = True + mock_collect_exp.return_value = 2 + mock_collect_rew.return_value = 5 + + response = client.post('/api/expedition/auto_check', json={'allow_repair': True}) + data: dict[str, Any] = response.json() + + assert response.status_code == 200 + assert data['success'] is True + assert data['data']['repair_skipped'] is True + assert data['data']['repair_reason'] == '战斗任务进行中' + mock_repair.assert_not_called() + + @patch('autowsgr.ops.repair.repair_in_bath') + @patch('autowsgr.ops.reward.collect_rewards') + @patch('autowsgr.ops.expedition.collect_expedition') + @patch('autowsgr.server.routes.ops.task_manager') + @patch('autowsgr.server.routes.ops.get_context') + def test_repair_skipped_when_not_allowed( + self, + mock_get_context: MagicMock, + mock_task_manager: MagicMock, + mock_collect_exp: MagicMock, + mock_collect_rew: MagicMock, + mock_repair: MagicMock, + client: TestClient, + ) -> None: + """allow_repair=False 时跳过维修。""" + mock_get_context.return_value = MagicMock() + mock_task_manager.is_running = False + mock_collect_exp.return_value = 2 + mock_collect_rew.return_value = 5 + + response = client.post('/api/expedition/auto_check', json={'allow_repair': False}) + data: dict[str, Any] = response.json() + + assert response.status_code == 200 + assert data['success'] is True + assert data['data']['repair_skipped'] is True + assert data['data']['repair_reason'] == '队列中有后续战斗任务' + mock_repair.assert_not_called() + + @patch('autowsgr.ops.repair.repair_in_bath') + @patch('autowsgr.ops.reward.collect_rewards') + @patch('autowsgr.ops.expedition.collect_expedition') + @patch('autowsgr.server.routes.ops.task_manager') + @patch('autowsgr.server.routes.ops.get_context') + def test_repair_runs_when_allowed( + self, + mock_get_context: MagicMock, + mock_task_manager: MagicMock, + mock_collect_exp: MagicMock, + mock_collect_rew: MagicMock, + mock_repair: MagicMock, + client: TestClient, + ) -> None: + """allow_repair=True 且无任务时执行维修。""" + mock_get_context.return_value = MagicMock() + mock_task_manager.is_running = False + mock_collect_exp.return_value = 2 + mock_collect_rew.return_value = 5 + + response = client.post('/api/expedition/auto_check', json={'allow_repair': True}) + data: dict[str, Any] = response.json() + + assert response.status_code == 200 + assert data['success'] is True + assert data['data']['repair'] is True + assert 'repair_skipped' not in data['data'] + mock_repair.assert_called_once() + + +class TestBuildCollect: + """POST /api/build/collect 测试。""" + + @patch('autowsgr.server.routes.ops.get_context') + def test_503_no_context(self, mock_get_context: MagicMock, client: TestClient) -> None: + """无上下文时返回 503。""" + mock_get_context.side_effect = RuntimeError('context not initialized') + + response = client.post('/api/build/collect') + + assert response.status_code == 503 + + @patch('autowsgr.server.routes.ops.task_manager') + @patch('autowsgr.server.routes.ops.get_context') + def test_409_busy( + self, + mock_get_context: MagicMock, + mock_task_manager: MagicMock, + client: TestClient, + ) -> None: + """任务执行中时返回 409。""" + mock_get_context.return_value = MagicMock() + mock_task_manager.is_running = True + + response = client.post('/api/build/collect') + + assert response.status_code == 409 + + @patch('autowsgr.ops.collect_built_ships') + @patch('autowsgr.server.routes.ops.task_manager') + @patch('autowsgr.server.routes.ops.get_context') + def test_success( + self, + mock_get_context: MagicMock, + mock_task_manager: MagicMock, + mock_collect: MagicMock, + client: TestClient, + ) -> None: + """正常收取建造。""" + mock_get_context.return_value = MagicMock() + mock_task_manager.is_running = False + mock_collect.return_value = 2 + + response = client.post('/api/build/collect') + data: dict[str, Any] = response.json() + + assert response.status_code == 200 + assert data['success'] is True + assert data['data']['collected'] == 2 + mock_collect.assert_called_once() + + +class TestBuildStart: + """POST /api/build/start 测试。""" + + @patch('autowsgr.server.routes.ops.get_context') + def test_503_no_context(self, mock_get_context: MagicMock, client: TestClient) -> None: + """无上下文时返回 503。""" + mock_get_context.side_effect = RuntimeError('context not initialized') + + response = client.post('/api/build/start', json={}) + + assert response.status_code == 503 + + @patch('autowsgr.server.routes.ops.task_manager') + @patch('autowsgr.server.routes.ops.get_context') + def test_409_busy( + self, + mock_get_context: MagicMock, + mock_task_manager: MagicMock, + client: TestClient, + ) -> None: + """任务执行中时返回 409。""" + mock_get_context.return_value = MagicMock() + mock_task_manager.is_running = True + + response = client.post('/api/build/start', json={}) + + assert response.status_code == 409 + + @patch('autowsgr.ops.build_ship') + @patch('autowsgr.ops.BuildRecipe') + @patch('autowsgr.server.routes.ops.task_manager') + @patch('autowsgr.server.routes.ops.get_context') + def test_success_and_recipe_fields( + self, + mock_get_context: MagicMock, + mock_task_manager: MagicMock, + mock_recipe_cls: MagicMock, + mock_build_ship: MagicMock, + client: TestClient, + ) -> None: + """建造成功且 BuildRecipe 使用请求字段构造。""" + mock_get_context.return_value = MagicMock() + mock_task_manager.is_running = False + mock_recipe = MagicMock() + mock_recipe_cls.return_value = mock_recipe + + response = client.post( + '/api/build/start', + json={ + 'fuel': 100, + 'ammo': 200, + 'steel': 300, + 'bauxite': 400, + 'build_type': 'equipment', + 'allow_fast_build': True, + }, + ) + data: dict[str, Any] = response.json() + + assert response.status_code == 200 + assert data['success'] is True + mock_recipe_cls.assert_called_once_with( + fuel=100, + ammo=200, + steel=300, + bauxite=400, + ) + mock_build_ship.assert_called_once() + call_kwargs = mock_build_ship.call_args.kwargs + assert call_kwargs['recipe'] is mock_recipe + assert call_kwargs['build_type'] == 'equipment' + assert call_kwargs['allow_fast_build'] is True + + +class TestRewardCollect: + """POST /api/reward/collect 测试。""" + + @patch('autowsgr.server.routes.ops.get_context') + def test_503_no_context(self, mock_get_context: MagicMock, client: TestClient) -> None: + """无上下文时返回 503。""" + mock_get_context.side_effect = RuntimeError('context not initialized') + + response = client.post('/api/reward/collect') + + assert response.status_code == 503 + + @patch('autowsgr.server.routes.ops.task_manager') + @patch('autowsgr.server.routes.ops.get_context') + def test_409_busy( + self, + mock_get_context: MagicMock, + mock_task_manager: MagicMock, + client: TestClient, + ) -> None: + """任务执行中时返回 409。""" + mock_get_context.return_value = MagicMock() + mock_task_manager.is_running = True + + response = client.post('/api/reward/collect') + + assert response.status_code == 409 + + @patch('autowsgr.ops.collect_rewards') + @patch('autowsgr.server.routes.ops.task_manager') + @patch('autowsgr.server.routes.ops.get_context') + def test_success( + self, + mock_get_context: MagicMock, + mock_task_manager: MagicMock, + mock_collect: MagicMock, + client: TestClient, + ) -> None: + """正常收取奖励。""" + mock_get_context.return_value = MagicMock() + mock_task_manager.is_running = False + mock_collect.return_value = 4 + + response = client.post('/api/reward/collect') + data: dict[str, Any] = response.json() + + assert response.status_code == 200 + assert data['success'] is True + assert data['data']['collected'] == 4 + mock_collect.assert_called_once() + + +class TestCook: + """POST /api/cook 测试。""" + + @patch('autowsgr.server.routes.ops.get_context') + def test_503_no_context(self, mock_get_context: MagicMock, client: TestClient) -> None: + """无上下文时返回 503。""" + mock_get_context.side_effect = RuntimeError('context not initialized') + + response = client.post('/api/cook', json={}) + + assert response.status_code == 503 + + @patch('autowsgr.server.routes.ops.task_manager') + @patch('autowsgr.server.routes.ops.get_context') + def test_409_busy( + self, + mock_get_context: MagicMock, + mock_task_manager: MagicMock, + client: TestClient, + ) -> None: + """任务执行中时返回 409。""" + mock_get_context.return_value = MagicMock() + mock_task_manager.is_running = True + + response = client.post('/api/cook', json={}) + + assert response.status_code == 409 + + @patch('autowsgr.ops.cook') + @patch('autowsgr.server.routes.ops.task_manager') + @patch('autowsgr.server.routes.ops.get_context') + def test_success( + self, + mock_get_context: MagicMock, + mock_task_manager: MagicMock, + mock_cook: MagicMock, + client: TestClient, + ) -> None: + """正常烹饪。""" + mock_get_context.return_value = MagicMock() + mock_task_manager.is_running = False + mock_cook.return_value = True + + response = client.post('/api/cook', json={'position': 2, 'force_cook': True}) + data: dict[str, Any] = response.json() + + assert response.status_code == 200 + assert data['success'] is True + assert data['data']['cooked'] is True + mock_cook.assert_called_once() + call_kwargs = mock_cook.call_args.kwargs + assert call_kwargs['position'] == 2 + assert call_kwargs['force_cook'] is True + + +class TestRepairBath: + """POST /api/repair/bath 测试。""" + + @patch('autowsgr.server.routes.ops.get_context') + def test_503_no_context(self, mock_get_context: MagicMock, client: TestClient) -> None: + """无上下文时返回 503。""" + mock_get_context.side_effect = RuntimeError('context not initialized') + + response = client.post('/api/repair/bath') + + assert response.status_code == 503 + + @patch('autowsgr.server.routes.ops.task_manager') + @patch('autowsgr.server.routes.ops.get_context') + def test_409_busy( + self, + mock_get_context: MagicMock, + mock_task_manager: MagicMock, + client: TestClient, + ) -> None: + """任务执行中时返回 409。""" + mock_get_context.return_value = MagicMock() + mock_task_manager.is_running = True + + response = client.post('/api/repair/bath') + + assert response.status_code == 409 + + @patch('autowsgr.ops.repair_in_bath') + @patch('autowsgr.server.routes.ops.task_manager') + @patch('autowsgr.server.routes.ops.get_context') + def test_success( + self, + mock_get_context: MagicMock, + mock_task_manager: MagicMock, + mock_repair: MagicMock, + client: TestClient, + ) -> None: + """正常浴室修理。""" + mock_get_context.return_value = MagicMock() + mock_task_manager.is_running = False + + response = client.post('/api/repair/bath') + data: dict[str, Any] = response.json() + + assert response.status_code == 200 + assert data['success'] is True + mock_repair.assert_called_once() + + +class TestRepairShip: + """POST /api/repair/ship 测试。""" + + @patch('autowsgr.server.routes.ops.get_context') + def test_503_no_context(self, mock_get_context: MagicMock, client: TestClient) -> None: + """无上下文时返回 503。""" + mock_get_context.side_effect = RuntimeError('context not initialized') + + response = client.post('/api/repair/ship', json={'ship_name': 'TestShip'}) + + assert response.status_code == 503 + + @patch('autowsgr.server.routes.ops.task_manager') + @patch('autowsgr.server.routes.ops.get_context') + def test_409_busy( + self, + mock_get_context: MagicMock, + mock_task_manager: MagicMock, + client: TestClient, + ) -> None: + """任务执行中时返回 409。""" + mock_get_context.return_value = MagicMock() + mock_task_manager.is_running = True + + response = client.post('/api/repair/ship', json={'ship_name': 'TestShip'}) + + assert response.status_code == 409 + + @patch('autowsgr.ops.repair.repair_ship_by_name') + @patch('autowsgr.server.routes.ops.task_manager') + @patch('autowsgr.server.routes.ops.get_context') + def test_bath_full( + self, + mock_get_context: MagicMock, + mock_task_manager: MagicMock, + mock_repair_ship: MagicMock, + client: TestClient, + ) -> None: + """repair_secs < 0 时返回浴场已满错误。""" + mock_get_context.return_value = MagicMock() + mock_task_manager.is_running = False + mock_repair_ship.return_value = -1 + + response = client.post('/api/repair/ship', json={'ship_name': 'TestShip'}) + data: dict[str, Any] = response.json() + + assert response.status_code == 200 + assert data['success'] is False + assert '浴场已满' in data['error'] + mock_repair_ship.assert_called_once() + + @patch('autowsgr.ops.repair.repair_ship_by_name') + @patch('autowsgr.server.routes.ops.task_manager') + @patch('autowsgr.server.routes.ops.get_context') + def test_success( + self, + mock_get_context: MagicMock, + mock_task_manager: MagicMock, + mock_repair_ship: MagicMock, + client: TestClient, + ) -> None: + """repair_secs >= 0 时返回成功。""" + mock_get_context.return_value = MagicMock() + mock_task_manager.is_running = False + mock_repair_ship.return_value = 3600 + + response = client.post('/api/repair/ship', json={'ship_name': 'TestShip'}) + data: dict[str, Any] = response.json() + + assert response.status_code == 200 + assert data['success'] is True + assert data['data']['ship_name'] == 'TestShip' + assert data['data']['repair_seconds'] == 3600 + mock_repair_ship.assert_called_once() + + +class TestDestroy: + """POST /api/destroy 测试。""" + + @patch('autowsgr.server.routes.ops.get_context') + def test_503_no_context(self, mock_get_context: MagicMock, client: TestClient) -> None: + """无上下文时返回 503。""" + mock_get_context.side_effect = RuntimeError('context not initialized') + + response = client.post('/api/destroy', json={}) + + assert response.status_code == 503 + + @patch('autowsgr.server.routes.ops.task_manager') + @patch('autowsgr.server.routes.ops.get_context') + def test_409_busy( + self, + mock_get_context: MagicMock, + mock_task_manager: MagicMock, + client: TestClient, + ) -> None: + """任务执行中时返回 409。""" + mock_get_context.return_value = MagicMock() + mock_task_manager.is_running = True + + response = client.post('/api/destroy', json={}) + + assert response.status_code == 409 + + @patch('autowsgr.types.ShipType') + @patch('autowsgr.ops.destroy_ships') + @patch('autowsgr.server.routes.ops.task_manager') + @patch('autowsgr.server.routes.ops.get_context') + def test_ship_type_conversion( + self, + mock_get_context: MagicMock, + mock_task_manager: MagicMock, + mock_destroy: MagicMock, + mock_ship_type: MagicMock, + client: TestClient, + ) -> None: + """ship_types 被正确转换为 ShipType 枚举。""" + mock_get_context.return_value = MagicMock() + mock_task_manager.is_running = False + mock_dd = MagicMock() + mock_cl = MagicMock() + mock_ship_type.side_effect = [mock_dd, mock_cl] + + response = client.post( + '/api/destroy', + json={ + 'ship_types': ['DD', 'CL'], + 'remove_equipment': False, + }, + ) + data: dict[str, Any] = response.json() + + assert response.status_code == 200 + assert data['success'] is True + mock_ship_type.assert_any_call('DD') + mock_ship_type.assert_any_call('CL') + mock_destroy.assert_called_once() + call_kwargs = mock_destroy.call_args.kwargs + assert call_kwargs['ship_types'] == [mock_dd, mock_cl] + assert call_kwargs['remove_equipment'] is False + + @patch('autowsgr.ops.destroy_ships') + @patch('autowsgr.server.routes.ops.task_manager') + @patch('autowsgr.server.routes.ops.get_context') + def test_success_no_ship_types( + self, + mock_get_context: MagicMock, + mock_task_manager: MagicMock, + mock_destroy: MagicMock, + client: TestClient, + ) -> None: + """未传入 ship_types 时正常解装。""" + mock_get_context.return_value = MagicMock() + mock_task_manager.is_running = False + + response = client.post('/api/destroy', json={'remove_equipment': True}) + data: dict[str, Any] = response.json() + + assert response.status_code == 200 + assert data['success'] is True + mock_destroy.assert_called_once() + call_kwargs = mock_destroy.call_args.kwargs + assert call_kwargs['ship_types'] is None + assert call_kwargs['remove_equipment'] is True diff --git a/tests/unit/server/routes/test_system.py b/tests/unit/server/routes/test_system.py new file mode 100644 index 00000000..6675a910 --- /dev/null +++ b/tests/unit/server/routes/test_system.py @@ -0,0 +1,204 @@ +"""测试 autowsgr.server.routes.system.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from fastapi.testclient import TestClient + +from autowsgr.server.main import app + + +client = TestClient(app) + + +class TestSystemStart: + """system_start 测试。""" + + @patch('autowsgr.server.routes.system._main') + def test_already_running(self, mock_main: MagicMock) -> None: + """已启动时直接返回 '系统已启动'。""" + mock_main._ctx = MagicMock() + + response = client.post('/api/system/start', json={}) + data = response.json() + + assert response.status_code == 200 + assert data['success'] is True + assert data['message'] == '系统已启动' + + @patch('autowsgr.scheduler.launch') + @patch('autowsgr.server.routes.system._main') + def test_start_with_default_config( + self, + mock_main: MagicMock, + mock_launch: MagicMock, + ) -> None: + """未启动时使用默认配置路径调用 launch。""" + mock_main._ctx = None + mock_ctx = MagicMock() + mock_launch.return_value = mock_ctx + + response = client.post('/api/system/start', json={}) + data = response.json() + + assert response.status_code == 200 + assert data['success'] is True + assert data['message'] == '系统启动成功' + mock_launch.assert_called_once_with('usersettings.yaml') + assert mock_main._ctx is mock_ctx + + @patch('autowsgr.scheduler.launch') + @patch('autowsgr.server.routes.system._main') + def test_start_with_custom_config( + self, + mock_main: MagicMock, + mock_launch: MagicMock, + ) -> None: + """支持通过请求体传入自定义配置路径。""" + mock_main._ctx = None + mock_ctx = MagicMock() + mock_launch.return_value = mock_ctx + + response = client.post( + '/api/system/start', + json={'config_path': '/custom/config.yaml'}, + ) + data = response.json() + + assert response.status_code == 200 + assert data['success'] is True + mock_launch.assert_called_once_with('/custom/config.yaml') + + @patch('autowsgr.scheduler.launch') + @patch('autowsgr.server.routes.system._main') + def test_start_failure( + self, + mock_main: MagicMock, + mock_launch: MagicMock, + ) -> None: + """launch 抛出异常时返回 success=False 并携带错误信息。""" + mock_main._ctx = None + mock_launch.side_effect = ValueError('config not found') + + response = client.post('/api/system/start', json={}) + data = response.json() + + assert response.status_code == 200 + assert data['success'] is False + assert data['error'] == 'config not found' + + +class TestSystemStop: + """system_stop 测试。""" + + @patch('autowsgr.server.routes.system._main') + def test_not_running(self, mock_main: MagicMock) -> None: + """未运行时返回 '系统未运行'。""" + mock_main._ctx = None + + response = client.post('/api/system/stop') + data = response.json() + + assert response.status_code == 200 + assert data['success'] is True + assert data['message'] == '系统未运行' + + @patch('autowsgr.server.routes.system.task_manager') + @patch('autowsgr.server.routes.system._main') + def test_stop_running_with_task( + self, + mock_main: MagicMock, + mock_task_manager: MagicMock, + ) -> None: + """运行中且有任务时先停止任务,再将上下文置为 None。""" + mock_main._ctx = MagicMock() + mock_task_manager.is_running = True + + response = client.post('/api/system/stop') + data = response.json() + + assert response.status_code == 200 + assert data['success'] is True + assert data['message'] == '系统已停止' + mock_task_manager.stop_task.assert_called_once() + assert mock_main._ctx is None + + +class TestSystemStatus: + """system_status 测试。""" + + @patch('autowsgr.server.routes.system.task_manager') + @patch('autowsgr.server.routes.system._main') + def test_idle(self, mock_main: MagicMock, mock_task_manager: MagicMock) -> None: + """空闲状态返回正确字段值。""" + mock_main._ctx = None + mock_task_manager.current_task = None + + response = client.get('/api/system/status') + data = response.json() + + assert response.status_code == 200 + assert data['success'] is True + assert data['data']['status'] == 'idle' + assert data['data']['emulator_connected'] is False + assert data['data']['game_running'] is False + assert data['data']['current_task'] is None + + @patch('autowsgr.server.routes.system.task_manager') + @patch('autowsgr.server.routes.system._main') + def test_running_with_task( + self, + mock_main: MagicMock, + mock_task_manager: MagicMock, + ) -> None: + """有任务运行时返回对应状态与任务 ID。""" + mock_main._ctx = MagicMock() + mock_task = MagicMock() + mock_task.status.value = 'running' + mock_task.task_id = 'task_123' + mock_task_manager.current_task = mock_task + + response = client.get('/api/system/status') + data = response.json() + + assert response.status_code == 200 + assert data['success'] is True + assert data['data']['status'] == 'running' + assert data['data']['emulator_connected'] is True + assert data['data']['game_running'] is True + assert data['data']['current_task'] == 'task_123' + + +class TestEmulatorDevices: + """emulator_devices 测试。""" + + @patch('autowsgr.emulator.detector.connect_and_list_devices') + def test_success(self, mock_connect: MagicMock) -> None: + """成功查询设备列表并返回规范结构。""" + mock_connect.return_value = [ + ('emulator-5554', 'device'), + ('emulator-5556', 'offline'), + ] + + response = client.get('/api/system/emulator/devices') + data = response.json() + + assert response.status_code == 200 + assert data['success'] is True + assert data['data'] == [ + {'serial': 'emulator-5554', 'status': 'device'}, + {'serial': 'emulator-5556', 'status': 'offline'}, + ] + + @patch('autowsgr.emulator.detector.connect_and_list_devices') + def test_failure(self, mock_connect: MagicMock) -> None: + """查询异常时返回 success=False 与错误信息。""" + mock_connect.side_effect = RuntimeError('adb error') + + response = client.get('/api/system/emulator/devices') + data = response.json() + + assert response.status_code == 200 + assert data['success'] is False + assert data['error'] == 'adb error' diff --git a/tests/unit/server/routes/test_task.py b/tests/unit/server/routes/test_task.py new file mode 100644 index 00000000..409009dd --- /dev/null +++ b/tests/unit/server/routes/test_task.py @@ -0,0 +1,297 @@ +"""测试 autowsgr.server.routes.task.""" + +from __future__ import annotations + +import asyncio +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient + +from autowsgr.server.routes.task import ( + _start_normal_fight, + router, + task_start, +) +from autowsgr.server.schemas import ( + CombatPlanRequest, + NormalFightRequest, + TaskType, +) + + +@pytest.fixture +def client() -> TestClient: + """创建包含 task 路由的最小 FastAPI 应用。""" + app = FastAPI() + app.include_router(router) + return TestClient(app) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# task_start 端点 +# ═══════════════════════════════════════════════════════════════════════════════ + + +def test_task_start_already_running(client: TestClient) -> None: + """任务已在运行时返回 409。""" + with patch('autowsgr.server.routes.task.task_manager') as mock_tm: + mock_tm.is_running = True + response = client.post('/api/task/start', json={'type': 'normal_fight', 'times': 1}) + assert response.status_code == 409 + assert response.json()['detail'] == '已有任务正在运行' + + +def test_task_start_no_context(client: TestClient) -> None: + """无上下文时返回 503。""" + with ( + patch('autowsgr.server.routes.task.task_manager') as mock_tm, + patch('autowsgr.server.routes.task.get_context') as mock_get_ctx, + ): + mock_tm.is_running = False + mock_get_ctx.side_effect = RuntimeError('系统未启动,请先调用 POST /api/system/start') + response = client.post('/api/task/start', json={'type': 'normal_fight', 'times': 1}) + assert response.status_code == 503 + assert '系统未启动' in response.json()['detail'] + + +def test_task_start_unknown_type() -> None: + """未知任务类型时返回 400(绕过 Pydantic discriminator 直接调用)。""" + mock_request = MagicMock() + with ( + patch('autowsgr.server.routes.task.task_manager') as mock_tm, + patch('autowsgr.server.routes.task.get_context'), + ): + mock_tm.is_running = False + with pytest.raises(HTTPException) as exc_info: + asyncio.run(task_start(mock_request)) + assert exc_info.value.status_code == 400 + assert exc_info.value.detail == '未知的任务类型' + + +@pytest.mark.parametrize( + ('task_type', 'request_body', 'helper_name'), + [ + ( + 'normal_fight', + {'type': 'normal_fight', 'times': 2, 'plan_id': 'test_plan'}, + '_start_normal_fight', + ), + ( + 'event_fight', + {'type': 'event_fight', 'times': 1, 'plan_id': 'event_plan'}, + '_start_event_fight', + ), + ( + 'campaign', + {'type': 'campaign', 'campaign_name': '困难航母', 'times': 1}, + '_start_campaign', + ), + ( + 'exercise', + {'type': 'exercise', 'fleet_id': 2}, + '_start_exercise', + ), + ( + 'decisive', + {'type': 'decisive', 'chapter': 3, 'decisive_rounds': 2}, + '_start_decisive', + ), + ], +) +def test_task_start_valid_types( + client: TestClient, + task_type: str, + request_body: dict[str, Any], + helper_name: str, +) -> None: + """各有效任务类型正确分派到对应 helper 并返回 200。""" + with ( + patch('autowsgr.server.routes.task.get_context') as mock_get_ctx, + patch('autowsgr.server.routes.task.task_manager') as mock_tm, + patch( + f'autowsgr.server.routes.task.{helper_name}', + new_callable=AsyncMock, + ) as mock_helper, + ): + mock_get_ctx.return_value = MagicMock() + mock_tm.is_running = False + mock_helper.return_value = { + 'success': True, + 'data': {'task_id': f'task_{task_type}', 'status': 'running'}, + 'message': '任务已启动', + } + + response = client.post('/api/task/start', json=request_body) + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + assert data['data']['task_id'] == f'task_{task_type}' + assert data['data']['status'] == 'running' + mock_helper.assert_awaited_once() + + +# ═══════════════════════════════════════════════════════════════════════════════ +# task_stop 端点 +# ═══════════════════════════════════════════════════════════════════════════════ + + +def test_task_stop_not_running(client: TestClient) -> None: + """无运行任务时返回提示消息。""" + with patch('autowsgr.server.routes.task.task_manager') as mock_tm: + mock_tm.is_running = False + response = client.post('/api/task/stop') + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + assert data['message'] == '没有正在运行的任务' + + +def test_task_stop_running(client: TestClient) -> None: + """停止运行中的任务返回 task_id 和 stopped 状态。""" + mock_task = MagicMock() + mock_task.task_id = 'task_abc123' + + with patch('autowsgr.server.routes.task.task_manager') as mock_tm: + mock_tm.is_running = True + mock_tm.current_task = mock_task + mock_tm.stop_task.return_value = True + + response = client.post('/api/task/stop') + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + assert data['data']['task_id'] == 'task_abc123' + assert data['data']['status'] == 'stopped' + assert data['message'] == '已请求停止任务' + + +def test_task_stop_failure(client: TestClient) -> None: + """停止任务失败时返回 success=False。""" + with patch('autowsgr.server.routes.task.task_manager') as mock_tm: + mock_tm.is_running = True + mock_tm.stop_task.return_value = False + + response = client.post('/api/task/stop') + assert response.status_code == 200 + data = response.json() + assert data['success'] is False + assert data['error'] == '停止失败' + + +# ═══════════════════════════════════════════════════════════════════════════════ +# task_status 端点 +# ═══════════════════════════════════════════════════════════════════════════════ + + +def test_task_status(client: TestClient) -> None: + """查询状态返回 task_manager.get_status() 的 ApiResponse 包装。""" + with patch('autowsgr.server.routes.task.task_manager') as mock_tm: + mock_tm.get_status.return_value = { + 'task_id': None, + 'status': 'idle', + 'progress': None, + 'result': None, + } + + response = client.get('/api/task/status') + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + assert data['data']['status'] == 'idle' + assert data['data']['task_id'] is None + + +# ═══════════════════════════════════════════════════════════════════════════════ +# _start_normal_fight helper +# ═══════════════════════════════════════════════════════════════════════════════ + + +def test_start_normal_fight_plan_id() -> None: + """_start_normal_fight 通过 plan_id 解析计划。""" + request = NormalFightRequest(type=TaskType.NORMAL_FIGHT, plan_id='my_plan', times=1) + ctx = MagicMock() + + with ( + patch('autowsgr.combat.CombatPlan') as mock_plan_cls, + patch('autowsgr.ops.run_normal_fight') as mock_run, + patch('autowsgr.server.routes.task.convert_combat_result') as mock_convert, + patch('autowsgr.server.routes.task.build_combat_plan') as mock_build, + patch('autowsgr.server.routes.task.task_manager') as mock_tm, + ): + mock_plan = MagicMock() + mock_plan_cls.from_yaml.return_value = mock_plan + mock_run.return_value = [MagicMock()] + mock_convert.return_value = {'round': 1, 'success': True} + mock_tm.start_task.return_value = 'task_123' + mock_tm.should_stop.return_value = True # 立刻终止循环 + + result = asyncio.run(_start_normal_fight(ctx, request)) + assert result.success is True + assert result.data == {'task_id': 'task_123', 'status': 'running'} + + # 运行 executor 验证计划解析逻辑 + executor = mock_tm.start_task.call_args[1]['executor'] + task_info = MagicMock() + executor(task_info) + + mock_plan_cls.from_yaml.assert_called_once_with('my_plan') + mock_build.assert_not_called() + + +def test_start_normal_fight_plan_object() -> None: + """_start_normal_fight 通过 plan 对象构建计划。""" + plan_req = CombatPlanRequest(name='test_plan', chapter=2, map=3) + request = NormalFightRequest(type=TaskType.NORMAL_FIGHT, plan=plan_req, times=1) + ctx = MagicMock() + + with ( + patch('autowsgr.combat.CombatPlan') as mock_plan_cls, + patch('autowsgr.ops.run_normal_fight') as mock_run, + patch('autowsgr.server.routes.task.convert_combat_result') as mock_convert, + patch('autowsgr.server.routes.task.build_combat_plan') as mock_build, + patch('autowsgr.server.routes.task.task_manager') as mock_tm, + ): + mock_plan = MagicMock() + mock_build.return_value = mock_plan + mock_run.return_value = [MagicMock()] + mock_convert.return_value = {'round': 1, 'success': True} + mock_tm.start_task.return_value = 'task_456' + mock_tm.should_stop.return_value = True + + result = asyncio.run(_start_normal_fight(ctx, request)) + assert result.success is True + assert result.data == {'task_id': 'task_456', 'status': 'running'} + + executor = mock_tm.start_task.call_args[1]['executor'] + task_info = MagicMock() + executor(task_info) + + mock_build.assert_called_once() + mock_plan_cls.from_yaml.assert_not_called() + + +def test_start_normal_fight_missing_plan() -> None: + """_start_normal_fight 缺少 plan 和 plan_id 时 executor 抛出 ValueError。""" + request = NormalFightRequest(type=TaskType.NORMAL_FIGHT, times=1) + ctx = MagicMock() + + with ( + patch('autowsgr.combat.CombatPlan'), + patch('autowsgr.ops.run_normal_fight'), + patch('autowsgr.server.routes.task.convert_combat_result'), + patch('autowsgr.server.routes.task.build_combat_plan'), + patch('autowsgr.server.routes.task.task_manager') as mock_tm, + ): + mock_tm.start_task.return_value = 'task_789' + mock_tm.should_stop.return_value = True + + result = asyncio.run(_start_normal_fight(ctx, request)) + assert result.success is True + + executor = mock_tm.start_task.call_args[1]['executor'] + task_info = MagicMock() + with pytest.raises(ValueError, match='必须提供 plan 或 plan_id'): + executor(task_info) diff --git a/tests/unit/server/test_main.py b/tests/unit/server/test_main.py new file mode 100644 index 00000000..6af1eed7 --- /dev/null +++ b/tests/unit/server/test_main.py @@ -0,0 +1,48 @@ +"""测试 autowsgr.server.main。""" + +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from autowsgr.server.main import app, get_context + + +def test_get_context_raises_when_not_started() -> None: + """未启动时 get_context 应抛出 RuntimeError。""" + with pytest.raises(RuntimeError, match='系统未启动'): + get_context() + + +def test_app_metadata() -> None: + """FastAPI 应用元数据应正确。""" + assert app.title == 'AutoWSGR HTTP API' + assert app.version == '1.0.0' + + +def test_lifespan_triggers() -> None: + """TestClient 上下文管理器应触发 lifespan 事件。""" + with TestClient(app): + pass # lifespan 启动/关闭日志已由 loguru 输出 + + +def test_websocket_logs_ping_pong() -> None: + """/ws/logs 应响应 ping 消息。""" + with TestClient(app) as client, client.websocket_connect('/ws/logs') as websocket: + websocket.send_json({'type': 'ping'}) + data = websocket.receive_json() + assert data['type'] == 'pong' + + +def test_websocket_task_ping_pong() -> None: + """/ws/task 应响应 ping 消息。""" + with TestClient(app) as client, client.websocket_connect('/ws/task') as websocket: + websocket.send_json({'type': 'ping'}) + data = websocket.receive_json() + assert data['type'] == 'pong' + + +def test_routes_registered() -> None: + """核心路由应已被注册。""" + routes = [route.path for route in app.routes] + assert '/api/health' in routes or any('/api/health' in r for r in routes) diff --git a/tests/unit/server/test_schemas.py b/tests/unit/server/test_schemas.py new file mode 100644 index 00000000..fd7ed988 --- /dev/null +++ b/tests/unit/server/test_schemas.py @@ -0,0 +1,133 @@ +"""测试 autowsgr.server.schemas。""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from autowsgr.server.schemas import ( + ApiResponse, + CombatPlanRequest, + DecisiveRequest, + FleetRuleRequest, + LogLevel, + NodeDecisionRequest, + NormalFightRequest, + SystemStatusResponse, + TaskProgress, + TaskResult, + TaskStatusEnum, + TaskStatusResponse, + TaskType, +) + + +def test_task_type_values() -> None: + """TaskType 应包含预期的枚举值。""" + assert TaskType.NORMAL_FIGHT == 'normal_fight' + assert TaskType.EVENT_FIGHT == 'event_fight' + + +def test_task_status_enum_values() -> None: + """TaskStatusEnum 应包含预期的状态值。""" + assert TaskStatusEnum.RUNNING == 'running' + assert TaskStatusEnum.FAILED == 'failed' + + +def test_log_level_values() -> None: + """LogLevel 应包含预期的日志级别。""" + assert LogLevel.DEBUG == 'DEBUG' + assert LogLevel.ERROR == 'ERROR' + + +def test_node_decision_request_defaults() -> None: + """NodeDecisionRequest 默认值应符合预期。""" + nd = NodeDecisionRequest() + assert nd.formation == 2 + assert nd.night is False + assert nd.proceed is True + + +def test_node_decision_request_formation_bounds() -> None: + """formation 超出 1-5 应校验失败。""" + with pytest.raises(ValidationError): + NodeDecisionRequest(formation=0) + with pytest.raises(ValidationError): + NodeDecisionRequest(formation=6) + + +def test_fleet_rule_request_candidates_empty() -> None: + """candidates 全空时应校验失败。""" + with pytest.raises(ValidationError, match='candidates 不能为空'): + FleetRuleRequest(candidates=['', ' ']) + + +def test_fleet_rule_request_ship_type_invalid() -> None: + """ship_type 不在白名单时应校验失败。""" + with pytest.raises(ValidationError, match='ship_type 不合法'): + FleetRuleRequest(candidates=['A'], ship_type='xx') + + +def test_fleet_rule_request_level_range() -> None: + """max_level < min_level 时应校验失败。""" + with pytest.raises(ValidationError, match='max_level 必须大于或等于 min_level'): + FleetRuleRequest(candidates=['A'], min_level=10, max_level=5) + + +def test_combat_plan_request_defaults() -> None: + """CombatPlanRequest 默认值应符合预期。""" + plan = CombatPlanRequest() + assert plan.fleet_id == 1 + assert plan.repair_mode == [2, 2, 2, 2, 2, 2] + + +def test_normal_fight_request_defaults() -> None: + """NormalFightRequest 默认值应符合预期。""" + req = NormalFightRequest() + assert req.times == 1 + assert req.gap == 0.0 + assert req.type == 'normal_fight' + + +def test_decisive_request_defaults() -> None: + """DecisiveRequest 默认值应符合预期。""" + req = DecisiveRequest() + assert req.chapter == 6 + assert req.use_quick_repair is True + assert len(req.level1) > 0 + + +def test_api_response_serialization() -> None: + """ApiResponse 应能正确序列化。""" + resp = ApiResponse(success=True, data={'key': 'value'}, message='ok') + assert resp.model_dump()['success'] is True + assert resp.model_dump()['data'] == {'key': 'value'} + + +def test_task_status_response_defaults() -> None: + """TaskStatusResponse 默认值应符合预期。""" + ts = TaskStatusResponse() + assert ts.status == TaskStatusEnum.IDLE + assert ts.task_id is None + + +def test_task_result_defaults() -> None: + """TaskResult 默认值应符合预期。""" + tr = TaskResult() + assert tr.total_runs == 0 + assert tr.details == [] + + +def test_system_status_response() -> None: + """SystemStatusResponse 应正确存储状态。""" + ss = SystemStatusResponse(status=TaskStatusEnum.RUNNING, emulator_connected=True) + assert ss.status == 'running' + assert ss.emulator_connected is True + + +def test_task_progress() -> None: + """TaskProgress 应正确存储进度信息。""" + tp = TaskProgress(current=3, total=10, node='B') + assert tp.current == 3 + assert tp.total == 10 + assert tp.node == 'B' diff --git a/tests/unit/server/test_serializers.py b/tests/unit/server/test_serializers.py new file mode 100644 index 00000000..8aab1d12 --- /dev/null +++ b/tests/unit/server/test_serializers.py @@ -0,0 +1,119 @@ +"""测试 autowsgr.server.serializers。""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from autowsgr.context.build import BuildQueue, BuildSlot +from autowsgr.context.expedition import Expedition, ExpeditionQueue +from autowsgr.context.fleet import Fleet +from autowsgr.context.resources import Resources +from autowsgr.context.ship import Ship +from autowsgr.server.serializers import ( + build_combat_plan, + convert_combat_result, + serialize_build_queue, + serialize_expedition_queue, + serialize_fleet, + serialize_resources, + serialize_ship, +) +from autowsgr.types import ConditionFlag, ShipDamageState + + +def test_serialize_resources() -> None: + """serialize_resources 应包含所有字段。""" + res = Resources(fuel=1000, ammo=2000, fast_repair=5) + data = serialize_resources(res) + assert data['fuel'] == 1000 + assert data['ammo'] == 2000 + assert data['fast_repair'] == 5 + + +def test_serialize_ship() -> None: + """serialize_ship 应正确转换 Ship 字段。""" + ship = Ship(name='弗莱彻', level=90, damage_state=ShipDamageState.NORMAL) + data = serialize_ship(ship) + assert data['name'] == '弗莱彻' + assert data['level'] == 90 + assert data['damage_state'] == ShipDamageState.NORMAL.value + assert data['ship_type'] is None + + +def test_serialize_fleet() -> None: + """serialize_fleet 应包含舰队成员和统计。""" + fleet = Fleet(fleet_id=2, ships=[Ship(name='Z1'), Ship(name='Z16')]) + data = serialize_fleet(fleet) + assert data['fleet_id'] == 2 + assert data['size'] == 2 + assert len(data['ships']) == 2 + assert data['has_severely_damaged'] is False + + +def test_serialize_expedition_queue() -> None: + """serialize_expedition_queue 应正确反映激活状态。""" + queue = ExpeditionQueue( + expeditions=[ + Expedition(chapter=1, node=1, fleet=Fleet(fleet_id=2)), + Expedition(), + ], + ) + data = serialize_expedition_queue(queue) + assert data['active_count'] == 1 + assert data['idle_count'] == 1 + assert data['slots'][0]['fleet_id'] == 2 + assert data['slots'][1]['fleet_id'] is None + + +def test_serialize_build_queue() -> None: + """serialize_build_queue 应正确反映槽位状态。""" + queue = BuildQueue( + slots=[ + BuildSlot(occupied=True, remaining_seconds=0), + BuildSlot(), + ], + ) + data = serialize_build_queue(queue) + assert data['idle_count'] == 1 + assert data['complete_count'] == 1 + assert data['slots'][0]['is_complete'] is True + assert data['slots'][1]['is_idle'] is True + + +def test_convert_combat_result() -> None: + """convert_combat_result 应提取节点与战果。""" + result = MagicMock() + result.flag = ConditionFlag.OPERATION_SUCCESS + result.ship_stats = [ShipDamageState.NORMAL] * 6 + result.node_count = 2 + result.history = None + data = convert_combat_result(result, round_num=1) + assert data['round'] == 1 + assert data['success'] is True + assert data['node_count'] == 2 + + +def test_build_combat_plan() -> None: + """build_combat_plan 应从请求构造 CombatPlan。""" + req = MagicMock() + req.name = 'test' + req.mode = 'normal' + req.chapter = 5 + req.map = 4 + req.fleet_id = 1 + req.fleet = None + req.repair_mode = [2, 2, 2, 2, 2, 2] + req.fight_condition = 4 + req.selected_nodes = ['A', 'B'] + req.node_defaults = MagicMock() + req.node_defaults.formation = 2 + req.node_defaults.night = False + req.node_defaults.proceed = True + req.node_defaults.proceed_stop = [2, 2, 2, 2, 2, 2] + req.node_defaults.detour = False + req.node_args = {} + + plan = build_combat_plan(req) + assert plan.name == 'test' + assert plan.chapter == 5 + assert plan.map_id == 4 diff --git a/tests/unit/server/test_task_manager.py b/tests/unit/server/test_task_manager.py new file mode 100644 index 00000000..a98f8c97 --- /dev/null +++ b/tests/unit/server/test_task_manager.py @@ -0,0 +1,390 @@ +"""测试 autowsgr.server.task_manager。""" + +from __future__ import annotations + +import threading +from typing import Any +from unittest.mock import patch + +import pytest + +from autowsgr.server.task_manager import ( + TaskInfo, + TaskManager, + TaskStatus, + task_manager, +) + + +def test_task_status_enum_values() -> None: + """TaskStatus 应包含预期的枚举值。""" + assert TaskStatus.IDLE.value == 'idle' + assert TaskStatus.RUNNING.value == 'running' + assert TaskStatus.COMPLETED.value == 'completed' + assert TaskStatus.FAILED.value == 'failed' + assert TaskStatus.STOPPED.value == 'stopped' + + +def test_task_info_defaults() -> None: + """TaskInfo 默认值应符合预期。""" + info = TaskInfo(task_id='t1', task_type='fight') + assert info.status == TaskStatus.IDLE + assert isinstance(info.created_at, str) + assert info.started_at is None + assert info.finished_at is None + assert info.current_round == 0 + assert info.total_rounds == 0 + assert info.current_node is None + assert info.results == [] + assert info.error is None + assert info.stop_requested is False + + +def test_task_info_progress() -> None: + """TaskInfo.progress 应返回正确的进度字典。""" + info = TaskInfo( + task_id='t1', + task_type='fight', + current_round=3, + total_rounds=10, + current_node='B', + ) + assert info.progress == {'current': 3, 'total': 10, 'node': 'B'} + + +def test_task_info_result_summary() -> None: + """TaskInfo.result_summary 应正确统计成功次数并返回详情。""" + info = TaskInfo( + task_id='t1', + task_type='fight', + total_rounds=5, + results=[ + {'success': True, 'detail': 'a'}, + {'success': False, 'detail': 'b'}, + {'success': True, 'detail': 'c'}, + ], + ) + summary = info.result_summary + assert summary['total_runs'] == 5 + assert summary['success_runs'] == 2 + assert summary['details'] == info.results + + +def test_task_manager_initial_state() -> None: + """TaskManager 初始状态应为空闲。""" + tm = TaskManager() + assert tm.is_running is False + assert tm.current_task is None + status = tm.get_status() + assert status == { + 'task_id': None, + 'status': 'idle', + 'progress': None, + 'result': None, + } + + +def test_task_manager_singleton_exists() -> None: + """全局 task_manager 单例应为 TaskManager 实例。""" + assert isinstance(task_manager, TaskManager) + + +def test_start_task_returns_task_id_and_sets_running() -> None: + """start_task 应返回 task_id 并将任务设为 RUNNING。""" + tm = TaskManager() + started = threading.Event() + block = threading.Event() + + def executor(_task: TaskInfo) -> list[dict[str, Any]]: + started.set() + block.wait(timeout=10) + return [] + + task_id = tm.start_task('normal_fight', 10, executor) + assert started.wait(timeout=2) + assert isinstance(task_id, str) + assert task_id.startswith('task_') + assert tm.is_running is True + assert tm.current_task is not None + assert tm.current_task.status == TaskStatus.RUNNING + assert tm.current_task.total_rounds == 10 + block.set() + assert tm._executor_thread is not None + tm._executor_thread.join(timeout=2) + + +def test_start_task_raises_when_already_running() -> None: + """已有任务运行时再次调用 start_task 应抛出 RuntimeError。""" + tm = TaskManager() + started = threading.Event() + block = threading.Event() + + def slow_executor(_task: TaskInfo) -> list[dict[str, Any]]: + started.set() + block.wait(timeout=10) + return [] + + tm.start_task('fight', 5, slow_executor) + assert started.wait(timeout=2) + with pytest.raises(RuntimeError, match='已有任务正在运行'): + tm.start_task('fight', 5, lambda _task: []) + block.set() + assert tm._executor_thread is not None + tm._executor_thread.join(timeout=2) + + +def test_stop_task_when_not_running() -> None: + """无任务运行时 stop_task 应返回 False。""" + tm = TaskManager() + assert tm.stop_task() is False + + +def test_stop_task_when_running() -> None: + """任务运行时 stop_task 应返回 True 并设置 stop_requested。""" + tm = TaskManager() + started = threading.Event() + block = threading.Event() + + def executor(_task: TaskInfo) -> list[dict[str, Any]]: + started.set() + block.wait(timeout=10) + return [] + + tm.start_task('fight', 5, executor) + assert started.wait(timeout=2) + assert tm.stop_task() is True + assert tm.current_task is not None + assert tm.current_task.stop_requested is True + block.set() + assert tm._executor_thread is not None + tm._executor_thread.join(timeout=2) + assert not tm._executor_thread.is_alive() + + +def test_should_stop_no_task() -> None: + """无任务时 should_stop 应返回 True。""" + tm = TaskManager() + assert tm.should_stop() is True + + +def test_should_stop_initially_false() -> None: + """任务启动后 should_stop 应返回 False。""" + tm = TaskManager() + started = threading.Event() + + def executor(_task: TaskInfo) -> list[dict[str, Any]]: + started.set() + for _ in range(100): + if tm.should_stop(): + break + threading.Event().wait(0.01) + return [] + + tm.start_task('fight', 5, executor) + assert started.wait(timeout=2) + assert tm.should_stop() is False + assert tm._executor_thread is not None + tm._executor_thread.join(timeout=2) + + +def test_should_stop_after_stop_task() -> None: + """调用 stop_task 后 should_stop 应返回 True。""" + tm = TaskManager() + started = threading.Event() + + def executor(_task: TaskInfo) -> list[dict[str, Any]]: + started.set() + while not tm.should_stop(): + threading.Event().wait(0.01) + return [] + + tm.start_task('fight', 5, executor) + assert started.wait(timeout=2) + assert tm.should_stop() is False + tm.stop_task() + assert tm.should_stop() is True + assert tm._executor_thread is not None + tm._executor_thread.join(timeout=2) + + +def test_update_progress() -> None: + """update_progress 应更新 current_round 和 current_node。""" + tm = TaskManager() + started = threading.Event() + block = threading.Event() + + def executor(_task: TaskInfo) -> list[dict[str, Any]]: + started.set() + block.wait(timeout=10) + return [] + + tm.start_task('fight', 10, executor) + assert started.wait(timeout=2) + tm.update_progress(current_round=3, current_node='B') + assert tm.current_task is not None + assert tm.current_task.current_round == 3 + assert tm.current_task.current_node == 'B' + tm.update_progress(current_round=5) + assert tm.current_task.current_round == 5 + assert tm.current_task.current_node == 'B' + block.set() + tm._executor_thread.join(timeout=2) + + +def test_add_result() -> None: + """add_result 应向 results 追加元素。""" + tm = TaskManager() + started = threading.Event() + block = threading.Event() + + def executor(_task: TaskInfo) -> list[dict[str, Any]]: + started.set() + block.wait(timeout=10) + return [] + + tm.start_task('fight', 5, executor) + assert started.wait(timeout=2) + tm.add_result({'success': True, 'round': 1}) + tm.add_result({'success': False, 'round': 2}) + assert tm.current_task is not None + assert len(tm.current_task.results) == 2 + assert tm.current_task.results[0] == {'success': True, 'round': 1} + assert tm.current_task.results[1] == {'success': False, 'round': 2} + block.set() + tm._executor_thread.join(timeout=2) + + +def test_get_status_running() -> None: + """RUNNING 状态的 get_status 应包含进度信息。""" + tm = TaskManager() + started = threading.Event() + block = threading.Event() + + def executor(_task: TaskInfo) -> list[dict[str, Any]]: + started.set() + block.wait(timeout=10) + return [] + + tm.start_task('fight', 10, executor) + assert started.wait(timeout=2) + tm.update_progress(current_round=3, current_node='B') + status = tm.get_status() + assert status['task_id'].startswith('task_') + assert status['status'] == 'running' + assert status['progress'] == {'current': 3, 'total': 10, 'node': 'B'} + assert status['result'] is None + assert status.get('error') is None + block.set() + tm._executor_thread.join(timeout=2) + + +def test_get_status_completed() -> None: + """COMPLETED 状态的 get_status 应包含结果摘要。""" + tm = TaskManager() + tm.start_task( + 'fight', + 2, + lambda _task: [{'success': True}, {'success': False}], + ) + assert tm._executor_thread is not None + tm._executor_thread.join(timeout=2) + status = tm.get_status() + assert status['status'] == 'completed' + assert status['progress'] is None + assert status['result'] is not None + assert status['result']['total_runs'] == 2 + assert status['result']['success_runs'] == 1 + assert status.get('error') is None + + +def test_get_status_failed() -> None: + """FAILED 状态的 get_status 应包含错误信息。""" + tm = TaskManager() + + def executor(_task: TaskInfo) -> list[dict[str, Any]]: + raise ValueError('模拟错误') + + tm.start_task('fight', 2, executor) + assert tm._executor_thread is not None + tm._executor_thread.join(timeout=2) + status = tm.get_status() + assert status['status'] == 'failed' + assert status['progress'] is None + assert status['result'] is None + assert status['error'] == '模拟错误' + + +def test_get_status_stopped() -> None: + """STOPPED 状态的 get_status 应无结果且无错误。""" + tm = TaskManager() + started = threading.Event() + + def executor(_task: TaskInfo) -> list[dict[str, Any]]: + started.set() + while not tm.should_stop(): + threading.Event().wait(0.01) + return [] + + tm.start_task('fight', 5, executor) + assert started.wait(timeout=2) + tm.stop_task() + assert tm._executor_thread is not None + tm._executor_thread.join(timeout=2) + status = tm.get_status() + assert status['status'] == 'stopped' + assert status['progress'] is None + assert status['result'] is None + assert status.get('error') is None + + +def test_thread_execution_completes() -> None: + """线程执行成功时应以 COMPLETED 结束并保存结果。""" + with patch('autowsgr.server.task_manager.ws_manager'): + tm = TaskManager() + tm.start_task('fight', 1, lambda _task: [{'success': True, 'round': 1}]) + assert tm._executor_thread is not None + tm._executor_thread.join(timeout=2) + assert tm.current_task is not None + assert tm.current_task.status == TaskStatus.COMPLETED + assert tm.current_task.results == [{'success': True, 'round': 1}] + assert tm.current_task.finished_at is not None + + +def test_thread_execution_fails() -> None: + """线程执行抛出异常时应以 FAILED 结束并记录错误。""" + with patch('autowsgr.server.task_manager.ws_manager'): + tm = TaskManager() + + def executor(_task: TaskInfo) -> list[dict[str, Any]]: + raise RuntimeError('执行失败') + + tm.start_task('fight', 1, executor) + assert tm._executor_thread is not None + tm._executor_thread.join(timeout=2) + assert tm.current_task is not None + assert tm.current_task.status == TaskStatus.FAILED + assert tm.current_task.error == '执行失败' + assert tm.current_task.finished_at is not None + + +def test_thread_execution_stops() -> None: + """线程检测到停止请求后提前返回,任务状态应为 STOPPED。""" + with patch('autowsgr.server.task_manager.ws_manager'): + tm = TaskManager() + started = threading.Event() + + def executor(_task: TaskInfo) -> list[dict[str, Any]]: + started.set() + while not tm.should_stop(): + threading.Event().wait(0.01) + return [] + + tm.start_task('fight', 5, executor) + assert started.wait(timeout=2) + tm.stop_task() + assert tm._executor_thread is not None + tm._executor_thread.join(timeout=2) + assert tm.current_task is not None + assert tm.current_task.status == TaskStatus.STOPPED + assert tm.current_task.stop_requested is True + assert tm.current_task.finished_at is not None diff --git a/tests/unit/server/test_ws_manager.py b/tests/unit/server/test_ws_manager.py new file mode 100644 index 00000000..0421e662 --- /dev/null +++ b/tests/unit/server/test_ws_manager.py @@ -0,0 +1,123 @@ +"""测试 autowsgr.server.ws_manager.""" + +from __future__ import annotations + +import asyncio +import json +from datetime import datetime +from typing import Any +from unittest.mock import AsyncMock + +from autowsgr.server.ws_manager import WebSocketManager + + +def _asyncio_run(coro: Any) -> Any: + return asyncio.run(coro) + + +def test_connect_adds_websocket() -> None: + manager = WebSocketManager() + ws = AsyncMock() + _asyncio_run(manager.connect(ws)) + assert ws in manager._connections + ws.accept.assert_awaited_once() + + +def test_disconnect_removes_websocket() -> None: + manager = WebSocketManager() + ws = AsyncMock() + manager._connections.add(ws) + _asyncio_run(manager.disconnect(ws)) + assert ws not in manager._connections + + +def test_broadcast_no_connections_is_noop() -> None: + manager = WebSocketManager() + _asyncio_run(manager.broadcast({'type': 'test'})) + assert manager._connections == set() + + +def test_broadcast_sends_to_all_alive_connections() -> None: + manager = WebSocketManager() + ws1 = AsyncMock() + ws2 = AsyncMock() + manager._connections.add(ws1) + manager._connections.add(ws2) + _asyncio_run(manager.broadcast({'type': 'test'})) + expected = json.dumps({'type': 'test'}, ensure_ascii=False) + ws1.send_text.assert_awaited_once_with(expected) + ws2.send_text.assert_awaited_once_with(expected) + assert ws1 in manager._connections + assert ws2 in manager._connections + + +def test_broadcast_removes_dead_connections() -> None: + manager = WebSocketManager() + alive_ws = AsyncMock() + dead_ws = AsyncMock() + dead_ws.send_text.side_effect = Exception('connection closed') + manager._connections.add(alive_ws) + manager._connections.add(dead_ws) + _asyncio_run(manager.broadcast({'type': 'test'})) + expected = json.dumps({'type': 'test'}, ensure_ascii=False) + alive_ws.send_text.assert_awaited_once_with(expected) + dead_ws.send_text.assert_awaited_once_with(expected) + assert alive_ws in manager._connections + assert dead_ws not in manager._connections + + +def test_send_log_payload_shape() -> None: + manager = WebSocketManager() + object.__setattr__(manager, 'broadcast', AsyncMock()) + _asyncio_run(manager.send_log('INFO', 'hello', channel='main')) + manager.broadcast.assert_awaited_once() + payload = manager.broadcast.call_args[0][0] + assert payload['type'] == 'log' + assert payload['level'] == 'INFO' + assert payload['message'] == 'hello' + assert payload['channel'] == 'main' + assert 'timestamp' in payload + assert isinstance(datetime.fromisoformat(payload['timestamp']), datetime) + + +def test_send_task_update_omits_none_progress_and_result() -> None: + manager = WebSocketManager() + object.__setattr__(manager, 'broadcast', AsyncMock()) + + _asyncio_run(manager.send_task_update('task-1', 'running')) + payload = manager.broadcast.call_args_list[0][0][0] + assert payload['type'] == 'task_update' + assert payload['task_id'] == 'task-1' + assert payload['status'] == 'running' + assert 'progress' not in payload + assert 'result' not in payload + + _asyncio_run( + manager.send_task_update( + 'task-2', + 'done', + progress={'p': 1}, + result={'r': 2}, + ) + ) + payload = manager.broadcast.call_args_list[1][0][0] + assert payload['progress'] == {'p': 1} + assert payload['result'] == {'r': 2} + + +def test_send_task_completed_payload() -> None: + manager = WebSocketManager() + object.__setattr__(manager, 'broadcast', AsyncMock()) + + _asyncio_run(manager.send_task_completed('task-1', True, result={'ok': True})) + payload = manager.broadcast.call_args_list[0][0][0] + assert payload['type'] == 'task_completed' + assert payload['task_id'] == 'task-1' + assert payload['success'] is True + assert payload['result'] == {'ok': True} + assert payload['error'] is None + + _asyncio_run(manager.send_task_completed('task-2', False, error='oops')) + payload = manager.broadcast.call_args_list[1][0][0] + assert payload['success'] is False + assert payload['error'] == 'oops' diff --git a/tests/unit/test_types.py b/tests/unit/test_types.py new file mode 100644 index 00000000..bff1ea34 --- /dev/null +++ b/tests/unit/test_types.py @@ -0,0 +1,113 @@ +"""测试 autowsgr.types。""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from autowsgr.types import ( + ConditionFlag, + DecisiveEntryStatus, + EmulatorType, + FightCondition, + Formation, + GameAPP, + OSType, + PageName, + RepairMode, + ShipDamageState, + ShipType, +) + + +def test_base_enum_missing() -> None: + """不存在的枚举值应抛出 ValueError。""" + with pytest.raises(ValueError, match='不是合法的'): + ShipDamageState('invalid') + + +def test_ostype_is_wsl_true() -> None: + """_is_wsl 应在检测到 WSL 环境变量时返回 True。""" + with patch.dict('os.environ', {'WSL_DISTRO_NAME': 'Ubuntu'}, clear=False): + assert OSType._is_wsl() is True + + +def test_ostype_is_wsl_false() -> None: + """_is_wsl 在无 WSL 标记时应返回 False。""" + with patch.dict('os.environ', {}, clear=True), patch('builtins.open', side_effect=OSError): + assert OSType._is_wsl() is False + + +def test_emulator_type_default_name_windows() -> None: + """Windows 下雷电模拟器默认 serial 应正确。""" + serial = EmulatorType.leidian.default_emulator_name(OSType.windows) + assert serial == 'emulator-5554' + + +def test_emulator_type_default_name_macos() -> None: + """macOS 下蓝叠模拟器默认 serial 应正确。""" + serial = EmulatorType.bluestacks.default_emulator_name(OSType.macos) + assert serial == '127.0.0.1:5555' + + +def test_game_app_package_names() -> None: + """各渠道服包名应正确。""" + assert GameAPP.official.package_name == 'com.huanmeng.zhanjian2' + assert GameAPP.xiaomi.package_name == 'com.hoolai.zjsnr.mi' + assert GameAPP.tencent.package_name == 'com.tencent.tmgp.zhanjian2' + + +def test_fight_condition_positions() -> None: + """FightCondition 应返回有效的相对坐标。""" + pos = FightCondition.aim.relative_click_position + assert isinstance(pos, tuple) + assert len(pos) == 2 + assert 0.0 < pos[0] < 1.0 + assert 0.0 < pos[1] < 1.0 + + +def test_formation_relative_position() -> None: + """Formation 应返回有效的相对坐标。""" + pos = Formation.double_column.relative_position + assert isinstance(pos, tuple) + assert len(pos) == 2 + + +def test_ship_type_destroy_position() -> None: + """ShipType 应返回拆解界面的相对坐标。""" + pos = ShipType.DD.relative_position_in_destroy + assert isinstance(pos, tuple) + assert len(pos) == 2 + + +def test_ship_damage_state_values() -> None: + """ShipDamageState 数值应符合预期。""" + assert ShipDamageState.NORMAL.value == 0 + assert ShipDamageState.MODERATE.value == 1 + assert ShipDamageState.SEVERE.value == 2 + assert ShipDamageState.NO_SHIP.value == -1 + + +def test_repair_mode_values() -> None: + """RepairMode 数值应符合预期。""" + assert RepairMode.moderate_damage.value == 1 + assert RepairMode.severe_damage.value == 2 + + +def test_decisive_entry_status_values() -> None: + """DecisiveEntryStatus 应包含预期值。""" + assert DecisiveEntryStatus.CANT_FIGHT == 'cant_fight' + assert DecisiveEntryStatus.CHALLENGING == 'challenging' + + +def test_condition_flag_values() -> None: + """ConditionFlag 应包含预期值。""" + assert ConditionFlag.OPERATION_SUCCESS == 'success' + assert ConditionFlag.DOCK_FULL == 'dock is full' + + +def test_page_name_values() -> None: + """PageName 应包含预期值。""" + assert PageName.MAIN == '主页面' + assert PageName.BATTLE_PREP == '出征准备' diff --git a/testing/ui/README.md b/tests/unit/ui/README.md similarity index 92% rename from testing/ui/README.md rename to tests/unit/ui/README.md index 76f348e8..d0242985 100644 --- a/testing/ui/README.md +++ b/tests/unit/ui/README.md @@ -1,6 +1,6 @@ # UI 控制器测试说明 -`testing/ui/` 下每个 UI 控制器对应一个子目录,目录内包含两类测试: +`tests/unit/ui/` 和 `tests/manual/ui/` 下每个 UI 控制器对应一个子目录,目录内包含两类测试: | 文件 | 类型 | 运行方式 | 需要真实设备 | |---|---|---|---| @@ -12,7 +12,7 @@ ## 单元测试 ```bash -pytest testing/ui/ +pytest tests/unit/ui/ ``` 不需要连接设备,由 CI 自动运行。 @@ -29,16 +29,16 @@ pytest testing/ui/ ```bash # 自动检测设备,序列运行所有测试 -python testing/ui/run_all_e2e.py +python tests/manual/ui/run_all_e2e.py # 指定设备并启用调试 -python testing/ui/run_all_e2e.py --serial emulator-5554 --debug +python tests/manual/ui/run_all_e2e.py --serial emulator-5554 --debug # 自定义等待时间(每步 2 秒) -python testing/ui/run_all_e2e.py --pause 2.0 +python tests/manual/ui/run_all_e2e.py --pause 2.0 # 指定汇总报告输出路径 -python testing/ui/run_all_e2e.py --output my_report.json +python tests/manual/ui/run_all_e2e.py --output my_report.json ``` **PowerShell(Windows)**: @@ -59,7 +59,7 @@ python testing/ui/run_all_e2e.py --output my_report.json 脚本会: -1. 自动搜索所有 `testing/ui/*/e2e.py` 脚本 +1. 自动搜索所有 `tests/manual/ui/*/e2e.py` 脚本 2. 按顺序执行(--parallel 参数当前为预留,实装为序列) 3. 自动启用 `--auto` 模式(不等待用户输入) 4. 收集每个测试的退出码、日志路径、耗时 @@ -120,7 +120,7 @@ python testing/ui/run_all_e2e.py --output my_report.json ### 通用命令格式 ``` -python testing/ui//e2e.py [serial] [--auto] [--debug] [--pause SECONDS] +python tests/ui//e2e.py [serial] [--auto] [--debug] [--pause SECONDS] ``` | 参数 | 说明 | 默认值 | @@ -134,13 +134,13 @@ python testing/ui//e2e.py [serial] [--auto] [--debug] [--pause SECONDS] ```bash # 交互模式,自动检测设备 -python testing/ui/main_page/e2e.py +python tests/manual/ui/main_page/e2e.py # 自动模式,指定设备序列号 -python testing/ui/main_page/e2e.py emulator-5554 --auto +python tests/manual/ui/main_page/e2e.py emulator-5554 --auto # 调试详细日志,动作间隔 2 秒 -python testing/ui/map_page/e2e.py --auto --debug --pause 2.0 +python tests/manual/ui/map_page/e2e.py --auto --debug --pause 2.0 ``` --- @@ -292,5 +292,5 @@ JSON 报告格式: ## 补充说明 - `e2e.py` 文件名不以 `test_` 开头,**不会被 `pytest` 自动收集**,避免无设备的 CI 环境误触发。 -- 若要在 CI 中运行 e2e,需显式调用 `python testing/ui//e2e.py --auto`,并确保 ADB 已连接。 +- 若要在 CI 中运行 e2e,需显式调用 `python tests/manual/ui//e2e.py --auto`,并确保 ADB 已连接。 - 每次测试只覆盖**单个控制器**,适合在修改对应页面逻辑后做针对性回归。 diff --git a/tests/unit/ui/__init__.py b/tests/unit/ui/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/ui/backyard_page/__init__.py b/tests/unit/ui/backyard_page/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/ui/bath_page/__init__.py b/tests/unit/ui/bath_page/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/ui/bath_page/test_recognition.py b/tests/unit/ui/bath_page/test_recognition.py new file mode 100644 index 00000000..e055073c --- /dev/null +++ b/tests/unit/ui/bath_page/test_recognition.py @@ -0,0 +1,10 @@ +"""测试 autowsgr.ui.bath_page.recognition.""" + +from __future__ import annotations + + +def test_module_importable() -> None: + """验证模块可被导入。""" + import autowsgr.ui.bath_page.recognition as _mod + + assert _mod is not None diff --git a/tests/unit/ui/bath_page/test_signatures.py b/tests/unit/ui/bath_page/test_signatures.py new file mode 100644 index 00000000..59508eaa --- /dev/null +++ b/tests/unit/ui/bath_page/test_signatures.py @@ -0,0 +1,100 @@ +"""Tests for autowsgr.ui.bath_page.signatures.""" + +from __future__ import annotations + +import pytest + +from autowsgr.ui.bath_page.signatures import ( + BATH_FULL_TIMEOUT, + CHOOSE_REPAIR_OVERLAY_SIGNATURE, + CLICK_BACK, + CLICK_CHOOSE_REPAIR, + CLICK_CLOSE_OVERLAY, + CLICK_FIRST_REPAIR_SHIP, + CLICK_REPAIR_ALL, + CLOSE_OVERLAY_BUTTON_COLOR, + PAGE_SIGNATURE, + REPAIR_ALL_BUTTON_COLOR, + SWIPE_DELAY, + SWIPE_DURATION, + SWIPE_END, + SWIPE_START, +) +from autowsgr.vision import MatchStrategy, PixelSignature + + +CLICK_COORDS: list[tuple[float, float]] = [ + CLICK_REPAIR_ALL, + CLICK_BACK, + CLICK_CHOOSE_REPAIR, + CLICK_CLOSE_OVERLAY, + CLICK_FIRST_REPAIR_SHIP, +] + +COLOR_SPECS: list[tuple[tuple[int, int, int], float]] = [ + REPAIR_ALL_BUTTON_COLOR, + CLOSE_OVERLAY_BUTTON_COLOR, +] + +SWIPE_COORDS: list[tuple[float, float]] = [SWIPE_START, SWIPE_END] + +POSITIVE_FLOATS: list[float] = [SWIPE_DURATION, SWIPE_DELAY, BATH_FULL_TIMEOUT] + + +class TestSignatures: + """Tests for page signatures.""" + + def test_page_signature_type_and_strategy(self) -> None: + """PAGE_SIGNATURE must be a PixelSignature using MatchStrategy.ALL.""" + assert isinstance(PAGE_SIGNATURE, PixelSignature) + assert PAGE_SIGNATURE.strategy is MatchStrategy.ALL + + def test_choose_repair_overlay_signature_type(self) -> None: + """CHOOSE_REPAIR_OVERLAY_SIGNATURE must be a PixelSignature.""" + assert isinstance(CHOOSE_REPAIR_OVERLAY_SIGNATURE, PixelSignature) + + +class TestClickCoordinates: + """Tests for click coordinate constants.""" + + @pytest.mark.parametrize('coord', CLICK_COORDS) + def test_click_coords_are_valid(self, coord: tuple[float, float]) -> None: + """Each CLICK_* must be a 2-float tuple with values in [0, 1].""" + assert len(coord) == 2 + x, y = coord + assert isinstance(x, float) + assert isinstance(y, float) + assert 0.0 <= x <= 1.0 + assert 0.0 <= y <= 1.0 + + +class TestColorSpecs: + """Tests for colour constants.""" + + @pytest.mark.parametrize('color_spec', COLOR_SPECS) + def test_color_specs_are_valid(self, color_spec: tuple[tuple[int, int, int], float]) -> None: + """Each colour constant must be ((R, G, B), tolerance).""" + color, tolerance = color_spec + assert len(color) == 3 + r, g, b = color + assert isinstance(r, int) + assert isinstance(g, int) + assert isinstance(b, int) + assert isinstance(tolerance, float) + + +class TestSwipeConstants: + """Tests for swipe-related constants.""" + + @pytest.mark.parametrize('coord', SWIPE_COORDS) + def test_swipe_coords_are_valid(self, coord: tuple[float, float]) -> None: + """SWIPE_START and SWIPE_END must be 2-float tuples.""" + assert len(coord) == 2 + x, y = coord + assert isinstance(x, float) + assert isinstance(y, float) + + @pytest.mark.parametrize('value', POSITIVE_FLOATS) + def test_positive_float_constants(self, value: float) -> None: + """SWIPE_DURATION, SWIPE_DELAY, and BATH_FULL_TIMEOUT must be > 0.""" + assert value > 0.0 diff --git a/tests/unit/ui/battle/__init__.py b/tests/unit/ui/battle/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/ui/battle/fleet_change/__init__.py b/tests/unit/ui/battle/fleet_change/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/ui/battle/fleet_change/test__change.py b/tests/unit/ui/battle/fleet_change/test__change.py new file mode 100644 index 00000000..cdbb31b2 --- /dev/null +++ b/tests/unit/ui/battle/fleet_change/test__change.py @@ -0,0 +1,10 @@ +"""测试 autowsgr.ui.battle.fleet_change._change.""" + +from __future__ import annotations + + +def test_module_importable() -> None: + """验证模块可被导入。""" + import autowsgr.ui.battle.fleet_change._change as _mod + + assert _mod is not None diff --git a/tests/unit/ui/battle/fleet_change/test__detect.py b/tests/unit/ui/battle/fleet_change/test__detect.py new file mode 100644 index 00000000..abd4b12c --- /dev/null +++ b/tests/unit/ui/battle/fleet_change/test__detect.py @@ -0,0 +1,10 @@ +"""测试 autowsgr.ui.battle.fleet_change._detect.""" + +from __future__ import annotations + + +def test_module_importable() -> None: + """验证模块可被导入。""" + import autowsgr.ui.battle.fleet_change._detect as _mod + + assert _mod is not None diff --git a/tests/unit/ui/battle/test_base.py b/tests/unit/ui/battle/test_base.py new file mode 100644 index 00000000..e5c9aba0 --- /dev/null +++ b/tests/unit/ui/battle/test_base.py @@ -0,0 +1,115 @@ +"""测试 autowsgr.ui.battle.base.""" + +from __future__ import annotations + +import pytest + +from autowsgr.ui.battle.base import ( + CLICK_PANEL, + PAGE_SIGNATURE, + PANEL_PROBE, + BaseBattlePreparation, + Panel, + RepairStrategy, +) +from autowsgr.vision import PixelSignature + + +class TestRepairStrategy: + """测试 RepairStrategy 枚举.""" + + def test_members(self) -> None: + """成员名与值应与源码一致.""" + assert RepairStrategy.MODERATE.value == 'moderate' + assert RepairStrategy.SEVERE.value == 'severe' + assert RepairStrategy.ALWAYS.value == 'always' + assert RepairStrategy.NEVER.value == 'never' + + def test_all_members_present(self) -> None: + """应包含全部四个成员.""" + members = {member.name for member in RepairStrategy} + assert members == {'MODERATE', 'SEVERE', 'ALWAYS', 'NEVER'} + + +class TestPanel: + """测试 Panel 枚举.""" + + def test_members(self) -> None: + """成员名与值应与源码一致.""" + assert Panel.STATS.value == '综合战力' + assert Panel.QUICK_SUPPLY.value == '快速补给' + assert Panel.QUICK_REPAIR.value == '快速修理' + assert Panel.EQUIPMENT.value == '装备预览' + + def test_all_members_present(self) -> None: + """应包含全部四个成员.""" + members = {member.name for member in Panel} + assert members == {'STATS', 'QUICK_SUPPLY', 'QUICK_REPAIR', 'EQUIPMENT'} + + +class TestPanelProbe: + """测试 PANEL_PROBE 映射.""" + + def test_keys_match_panel_members(self) -> None: + """键集合应与 Panel 成员完全一致.""" + assert set(PANEL_PROBE.keys()) == set(Panel) + + @pytest.mark.parametrize('panel', list(Panel)) + def test_values_are_normalized_2d_coordinates(self, panel: Panel) -> None: + """每个值应为两个 [0, 1] 范围内浮点数构成的元组.""" + value = PANEL_PROBE[panel] + assert isinstance(value, tuple) + assert len(value) == 2 + x, y = value + assert isinstance(x, float) + assert isinstance(y, float) + assert 0.0 <= x <= 1.0 + assert 0.0 <= y <= 1.0 + + +class TestClickPanel: + """测试 CLICK_PANEL 映射.""" + + def test_keys_match_panel_members(self) -> None: + """键集合应与 Panel 成员完全一致.""" + assert set(CLICK_PANEL.keys()) == set(Panel) + + @pytest.mark.parametrize('panel', list(Panel)) + def test_values_are_normalized_2d_coordinates(self, panel: Panel) -> None: + """每个值应为两个 [0, 1] 范围内浮点数构成的元组.""" + value = CLICK_PANEL[panel] + assert isinstance(value, tuple) + assert len(value) == 2 + x, y = value + assert isinstance(x, float) + assert isinstance(y, float) + assert 0.0 <= x <= 1.0 + assert 0.0 <= y <= 1.0 + + +class TestPageSignature: + """测试 PAGE_SIGNATURE.""" + + def test_type_is_pixel_signature(self) -> None: + """PAGE_SIGNATURE 应为 PixelSignature 实例.""" + assert isinstance(PAGE_SIGNATURE, PixelSignature) + + +class TestBaseBattlePreparation: + """测试 BaseBattlePreparation 基类.""" + + def test_class_is_importable(self) -> None: + """类应可被正常导入.""" + assert BaseBattlePreparation is not None + + def test_expected_static_methods(self) -> None: + """应包含预期的静态方法.""" + assert hasattr(BaseBattlePreparation, 'is_current_page') + assert hasattr(BaseBattlePreparation, 'get_selected_fleet') + assert hasattr(BaseBattlePreparation, 'get_active_panel') + assert hasattr(BaseBattlePreparation, 'is_auto_supply_enabled') + + assert type(BaseBattlePreparation.__dict__['is_current_page']) is staticmethod + assert type(BaseBattlePreparation.__dict__['get_selected_fleet']) is staticmethod + assert type(BaseBattlePreparation.__dict__['get_active_panel']) is staticmethod + assert type(BaseBattlePreparation.__dict__['is_auto_supply_enabled']) is staticmethod diff --git a/tests/unit/ui/battle/test_blood.py b/tests/unit/ui/battle/test_blood.py new file mode 100644 index 00000000..1548ea33 --- /dev/null +++ b/tests/unit/ui/battle/test_blood.py @@ -0,0 +1,57 @@ +"""测试 autowsgr.ui.battle.blood.""" + +from __future__ import annotations + +import pytest + +from autowsgr.types import ShipDamageState +from autowsgr.ui.battle.blood import ( + _BLOOD_COLORS, + BLOOD_EMPTY, + BLOOD_GREEN, + BLOOD_NO_SHIP, + BLOOD_RED, + BLOOD_RED_PREPARE, + BLOOD_YELLOW, + classify_blood, +) +from autowsgr.vision import Color + + +@pytest.mark.parametrize( + ('pixel', 'expected'), + [ + (BLOOD_GREEN, ShipDamageState.NORMAL), + (BLOOD_YELLOW, ShipDamageState.MODERATE), + (BLOOD_RED, ShipDamageState.SEVERE), + (BLOOD_RED_PREPARE, ShipDamageState.SEVERE), + (BLOOD_EMPTY, ShipDamageState.SEVERE), + (BLOOD_NO_SHIP, ShipDamageState.NO_SHIP), + ], +) +def test_classify_blood_exact_colors(pixel: Color, expected: ShipDamageState) -> None: + """每种参考颜色应被正确分类到对应状态。""" + assert classify_blood(pixel) is expected + + +def test_classify_blood_closer_to_green() -> None: + """距离绿血更近的颜色应被判为 NORMAL.""" + closer_to_green = Color.of(100, 170, 100) + assert classify_blood(closer_to_green) is ShipDamageState.NORMAL + + +def test_classify_blood_closer_to_yellow() -> None: + """距离黄血更近的颜色应被判为 MODERATE.""" + closer_to_yellow = Color.of(230, 160, 45) + assert classify_blood(closer_to_yellow) is ShipDamageState.MODERATE + + +def test_blood_colors_structure() -> None: + """_BLOOD_COLORS 长度与结构校验。""" + assert len(_BLOOD_COLORS) == 6 + for item in _BLOOD_COLORS: + assert isinstance(item, tuple) + assert len(item) == 2 + color, state = item + assert isinstance(color, Color) + assert isinstance(state, ShipDamageState) diff --git a/tests/unit/ui/battle/test_constants.py b/tests/unit/ui/battle/test_constants.py new file mode 100644 index 00000000..fd36a339 --- /dev/null +++ b/tests/unit/ui/battle/test_constants.py @@ -0,0 +1,133 @@ +"""测试 autowsgr.ui.battle.constants.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +import autowsgr.ui.battle.constants as battle_consts +from autowsgr.ui.battle.constants import ( + AUTO_SUPPLY_ON, + AUTO_SUPPLY_PROBE, + BLOOD_BAR_PROBE, + CLICK_FLEET, + CLICK_SHIP_SLOT, + FLEET_ACTIVE, + FLEET_PROBE, + PANEL_ACTIVE, + STATE_TOLERANCE, + SUPPORT_DISABLE, + SUPPORT_ENABLE, + SUPPORT_EXHAUSTED, +) +from autowsgr.vision import Color + + +if TYPE_CHECKING: + from collections.abc import Sequence + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 类型与基础值 +# ═══════════════════════════════════════════════════════════════════════════════ + + +def test_color_constants_are_color_instances() -> None: + """Color 常量应为 Color 实例。""" + assert isinstance(FLEET_ACTIVE, Color) + assert isinstance(PANEL_ACTIVE, Color) + assert isinstance(AUTO_SUPPLY_ON, Color) + + +def test_state_tolerance_positive() -> None: + """STATE_TOLERANCE 应大于 0。""" + assert STATE_TOLERANCE > 0.0 + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 字典键集合 +# ═══════════════════════════════════════════════════════════════════════════════ + + +def test_fleet_probe_keys() -> None: + """FLEET_PROBE 键集合应为 {1, 2, 3, 4}。""" + assert set(FLEET_PROBE.keys()) == {1, 2, 3, 4} + + +def test_click_fleet_keys() -> None: + """CLICK_FLEET 键集合应为 {1, 2, 3, 4}。""" + assert set(CLICK_FLEET.keys()) == {1, 2, 3, 4} + + +def test_click_ship_slot_keys() -> None: + """CLICK_SHIP_SLOT 键集合应为 {0, 1, 2, 3, 4, 5}。""" + assert set(CLICK_SHIP_SLOT.keys()) == set(range(6)) + + +def test_blood_bar_probe_keys() -> None: + """BLOOD_BAR_PROBE 键集合应为 {0, 1, 2, 3, 4, 5}。""" + assert set(BLOOD_BAR_PROBE.keys()) == set(range(6)) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 坐标结构校验 +# ═══════════════════════════════════════════════════════════════════════════════ + + +def _assert_2d_coordinate(coord: Sequence[float]) -> None: + """校验坐标为 2 个 float 且均在 [0, 1] 区间内。""" + assert isinstance(coord, (tuple, list)) + assert len(coord) == 2 + x, y = coord + assert isinstance(x, float) + assert isinstance(y, float) + assert 0.0 <= x <= 1.0 + assert 0.0 <= y <= 1.0 + + +@pytest.mark.parametrize( + 'name', + [ + 'FLEET_PROBE', + 'CLICK_FLEET', + 'CLICK_SHIP_SLOT', + 'BLOOD_BAR_PROBE', + 'CLICK_BACK', + 'CLICK_SUPPORT', + 'AUTO_SUPPLY_PROBE', + ], +) +def test_all_coordinate_tuples_are_valid(name: str) -> None: + """所有坐标元组均为 2-float 且落在 [0, 1]。""" + value = getattr(battle_consts, name) + if isinstance(value, dict): + for coord in value.values(): + _assert_2d_coordinate(coord) + else: + _assert_2d_coordinate(value) + + +def test_auto_supply_probe_non_empty() -> None: + """AUTO_SUPPLY_PROBE 为非空 list / tuple。""" + assert isinstance(AUTO_SUPPLY_PROBE, (list, tuple)) + assert len(AUTO_SUPPLY_PROBE) > 0 + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 战役支援颜色 +# ═══════════════════════════════════════════════════════════════════════════════ + + +@pytest.mark.parametrize( + 'color', + [SUPPORT_ENABLE, SUPPORT_DISABLE, SUPPORT_EXHAUSTED], + ids=['enable', 'disable', 'exhausted'], +) +def test_support_colors_structure(color: Color) -> None: + """战役支援颜色为 Color 实例且 RGB 通道在合法范围。""" + assert isinstance(color, Color) + r, g, b = color.as_rgb_tuple() + assert 0 <= r <= 255 + assert 0 <= g <= 255 + assert 0 <= b <= 255 diff --git a/tests/unit/ui/battle/test_detection.py b/tests/unit/ui/battle/test_detection.py new file mode 100644 index 00000000..16eb9817 --- /dev/null +++ b/tests/unit/ui/battle/test_detection.py @@ -0,0 +1,135 @@ +"""测试 autowsgr.ui.battle.detection.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from autowsgr.context.ship import Ship +from autowsgr.types import ShipDamageState +from autowsgr.ui.battle.detection import DetectionMixin, FleetInfo + + +class TestFleetInfo: + def test_defaults(self) -> None: + info = FleetInfo() + assert info.fleet_id is None + assert info.ship_levels == {} + assert info.ship_damage == {} + + def test_to_ships_with_names(self) -> None: + info = FleetInfo( + ship_levels={0: 120, 1: 98, 2: None, 3: 50}, + ship_damage={ + 0: ShipDamageState.NORMAL, + 1: ShipDamageState.MODERATE, + 2: ShipDamageState.NO_SHIP, + 3: ShipDamageState.NORMAL, + 4: ShipDamageState.NO_SHIP, + 5: ShipDamageState.NO_SHIP, + }, + ) + names = ['ship_a', None, None, 'ship_d'] + ships = info.to_ships(names) + + assert len(ships) == 3 + assert ships[0] == Ship( + name='ship_a', + level=120, + damage_state=ShipDamageState.NORMAL, + ) + assert ships[1] == Ship( + name='', + level=98, + damage_state=ShipDamageState.MODERATE, + ) + assert ships[2] == Ship( + name='ship_d', + level=50, + damage_state=ShipDamageState.NORMAL, + ) + + def test_to_ships_with_none_names(self) -> None: + info = FleetInfo( + ship_levels={0: 10}, + ship_damage={ + 0: ShipDamageState.NORMAL, + 1: ShipDamageState.NO_SHIP, + 2: ShipDamageState.NO_SHIP, + 3: ShipDamageState.NO_SHIP, + 4: ShipDamageState.NO_SHIP, + 5: ShipDamageState.NO_SHIP, + }, + ) + ships = info.to_ships(None) + + assert len(ships) == 1 + assert ships[0] == Ship( + name='', + level=10, + damage_state=ShipDamageState.NORMAL, + ) + + def test_to_ships_uses_ship_levels(self) -> None: + info = FleetInfo( + ship_levels={0: 77, 1: None}, + ship_damage={ + 0: ShipDamageState.NORMAL, + 1: ShipDamageState.SEVERE, + 2: ShipDamageState.NO_SHIP, + 3: ShipDamageState.NO_SHIP, + 4: ShipDamageState.NO_SHIP, + 5: ShipDamageState.NO_SHIP, + }, + ) + ships = info.to_ships() + + assert len(ships) == 2 + assert ships[0].level == 77 + assert ships[1].level == 0 + + +class TestParseLevel: + @pytest.mark.parametrize( + ('text', 'expected'), + [ + ('Lv.120', 120), + ('lv 98', 98), + ('0.106', 106), + ('1V.31', 31), + ('497', 97), + ('abc', None), + ('', None), + ], + ) + def test_cases(self, text: str, expected: int | None) -> None: + assert DetectionMixin._parse_level(text) == expected + + +class TestBestLevelFromResults: + def _make_result(self, text: str) -> SimpleNamespace: + return SimpleNamespace(text=text) + + def test_prefers_v_results(self) -> None: + results = [ + self._make_result('abc'), + self._make_result('120'), + self._make_result('Lv.99'), + ] + assert DetectionMixin._best_level_from_results(results) == 99 + + def test_falls_back_to_pure_digits(self) -> None: + results = [ + self._make_result('abc'), + self._make_result('120'), + self._make_result('xyz'), + ] + assert DetectionMixin._best_level_from_results(results) == 120 + + def test_returns_none_when_no_candidates(self) -> None: + results = [ + self._make_result('abc'), + self._make_result('xyz'), + ] + assert DetectionMixin._best_level_from_results(results) is None diff --git a/tests/unit/ui/battle/test_fleet_change.py b/tests/unit/ui/battle/test_fleet_change.py new file mode 100644 index 00000000..ddbbefda --- /dev/null +++ b/tests/unit/ui/battle/test_fleet_change.py @@ -0,0 +1,10 @@ +"""测试 autowsgr.ui.battle.fleet_change.""" + +from __future__ import annotations + + +def test_module_importable() -> None: + """验证模块可被导入。""" + import autowsgr.ui.battle.fleet_change as _mod + + assert _mod is not None diff --git a/tests/unit/ui/battle/test_preparation.py b/tests/unit/ui/battle/test_preparation.py new file mode 100644 index 00000000..4fbfb1e8 --- /dev/null +++ b/tests/unit/ui/battle/test_preparation.py @@ -0,0 +1,10 @@ +"""测试 autowsgr.ui.battle.preparation.""" + +from __future__ import annotations + + +def test_module_importable() -> None: + """验证模块可被导入。""" + import autowsgr.ui.battle.preparation as _mod + + assert _mod is not None diff --git a/tests/unit/ui/battle/test_repair.py b/tests/unit/ui/battle/test_repair.py new file mode 100644 index 00000000..f905ccef --- /dev/null +++ b/tests/unit/ui/battle/test_repair.py @@ -0,0 +1,10 @@ +"""测试 autowsgr.ui.battle.repair.""" + +from __future__ import annotations + + +def test_module_importable() -> None: + """验证模块可被导入。""" + import autowsgr.ui.battle.repair as _mod + + assert _mod is not None diff --git a/tests/unit/ui/battle/test_supply.py b/tests/unit/ui/battle/test_supply.py new file mode 100644 index 00000000..a427ce26 --- /dev/null +++ b/tests/unit/ui/battle/test_supply.py @@ -0,0 +1,10 @@ +"""测试 autowsgr.ui.battle.supply.""" + +from __future__ import annotations + + +def test_module_importable() -> None: + """验证模块可被导入。""" + import autowsgr.ui.battle.supply as _mod + + assert _mod is not None diff --git a/tests/unit/ui/battle_preparation/__init__.py b/tests/unit/ui/battle_preparation/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/testing/ui/battle_preparation/test_unit.py b/tests/unit/ui/battle_preparation/test_unit.py similarity index 100% rename from testing/ui/battle_preparation/test_unit.py rename to tests/unit/ui/battle_preparation/test_unit.py diff --git a/tests/unit/ui/build_page/__init__.py b/tests/unit/ui/build_page/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/ui/canteen_page/__init__.py b/tests/unit/ui/canteen_page/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/ui/decisive/__init__.py b/tests/unit/ui/decisive/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/ui/decisive/test_battle_page.py b/tests/unit/ui/decisive/test_battle_page.py new file mode 100644 index 00000000..7ee14cd0 --- /dev/null +++ b/tests/unit/ui/decisive/test_battle_page.py @@ -0,0 +1,10 @@ +"""测试 autowsgr.ui.decisive.battle_page.""" + +from __future__ import annotations + + +def test_module_importable() -> None: + """验证模块可被导入。""" + import autowsgr.ui.decisive.battle_page as _mod + + assert _mod is not None diff --git a/tests/unit/ui/decisive/test_fleet_ocr.py b/tests/unit/ui/decisive/test_fleet_ocr.py new file mode 100644 index 00000000..a187e97e --- /dev/null +++ b/tests/unit/ui/decisive/test_fleet_ocr.py @@ -0,0 +1,10 @@ +"""测试 autowsgr.ui.decisive.fleet_ocr.""" + +from __future__ import annotations + + +def test_module_importable() -> None: + """验证模块可被导入。""" + import autowsgr.ui.decisive.fleet_ocr as _mod + + assert _mod is not None diff --git a/tests/unit/ui/decisive/test_map_controller.py b/tests/unit/ui/decisive/test_map_controller.py new file mode 100644 index 00000000..648eb61a --- /dev/null +++ b/tests/unit/ui/decisive/test_map_controller.py @@ -0,0 +1,10 @@ +"""测试 autowsgr.ui.decisive.map_controller.""" + +from __future__ import annotations + + +def test_module_importable() -> None: + """验证模块可被导入。""" + import autowsgr.ui.decisive.map_controller as _mod + + assert _mod is not None diff --git a/tests/unit/ui/decisive/test_overlay.py b/tests/unit/ui/decisive/test_overlay.py new file mode 100644 index 00000000..3d1ae679 --- /dev/null +++ b/tests/unit/ui/decisive/test_overlay.py @@ -0,0 +1,10 @@ +"""测试 autowsgr.ui.decisive.overlay.""" + +from __future__ import annotations + + +def test_module_importable() -> None: + """验证模块可被导入。""" + import autowsgr.ui.decisive.overlay as _mod + + assert _mod is not None diff --git a/tests/unit/ui/decisive/test_preparation.py b/tests/unit/ui/decisive/test_preparation.py new file mode 100644 index 00000000..d2497158 --- /dev/null +++ b/tests/unit/ui/decisive/test_preparation.py @@ -0,0 +1,10 @@ +"""测试 autowsgr.ui.decisive.preparation.""" + +from __future__ import annotations + + +def test_module_importable() -> None: + """验证模块可被导入。""" + import autowsgr.ui.decisive.preparation as _mod + + assert _mod is not None diff --git a/tests/unit/ui/decisive_battle_page/__init__.py b/tests/unit/ui/decisive_battle_page/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/ui/event/__init__.py b/tests/unit/ui/event/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/ui/event/test_event_page.py b/tests/unit/ui/event/test_event_page.py new file mode 100644 index 00000000..e7351732 --- /dev/null +++ b/tests/unit/ui/event/test_event_page.py @@ -0,0 +1,10 @@ +"""测试 autowsgr.ui.event.event_page.""" + +from __future__ import annotations + + +def test_module_importable() -> None: + """验证模块可被导入。""" + import autowsgr.ui.event.event_page as _mod + + assert _mod is not None diff --git a/tests/unit/ui/event_page/__init__.py b/tests/unit/ui/event_page/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/ui/friend_page/__init__.py b/tests/unit/ui/friend_page/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/ui/intensify_page/__init__.py b/tests/unit/ui/intensify_page/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/ui/main_page/__init__.py b/tests/unit/ui/main_page/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/ui/main_page/test_constants.py b/tests/unit/ui/main_page/test_constants.py new file mode 100644 index 00000000..433a8e6f --- /dev/null +++ b/tests/unit/ui/main_page/test_constants.py @@ -0,0 +1,103 @@ +"""测试 autowsgr.ui.main_page.constants.""" + +from __future__ import annotations + +from autowsgr.ui.main_page.constants import ( + _SIGNATURES, + _TARGET_PAGES, + DismissCoord, + NavCoord, + OverlayKind, + ProbePoint, + Sig, + Target, + ThemeColor, +) +from autowsgr.vision import Color, PixelSignature + + +def test_target_members_have_expected_values() -> None: + """Target 各成员的值与预期一致。""" + assert Target.SORTIE.value == '出征' + assert Target.TASK.value == '任务' + assert Target.SIDEBAR.value == '侧边栏' + assert Target.HOME.value == '主页' + assert Target.EVENT.value == '活动' + + +def test_every_target_has_page_name() -> None: + """每个 Target 成员都在 _TARGET_PAGES 中有对应映射,且 page_name 返回 str。""" + assert len(_TARGET_PAGES) == len(Target) + for member in Target: + assert member in _TARGET_PAGES + page_name = member.page_name + assert isinstance(page_name, str) + assert page_name == _TARGET_PAGES[member] + + +def test_nav_coord_xy_in_range() -> None: + """NavCoord 各成员的 xy 为两元素 float 元组,取值在 [0, 1] 内。""" + for member in NavCoord: + xy = member.xy + assert isinstance(xy, tuple) + assert len(xy) == 2 + x, y = xy + assert isinstance(x, float) + assert isinstance(y, float) + assert 0.0 <= x <= 1.0 + assert 0.0 <= y <= 1.0 + + +def test_probe_point_xy_in_range() -> None: + """ProbePoint 各成员的 xy 为两元素 float 元组,取值在 [0, 1] 内。""" + for member in ProbePoint: + xy = member.xy + assert isinstance(xy, tuple) + assert len(xy) == 2 + x, y = xy + assert isinstance(x, float) + assert isinstance(y, float) + assert 0.0 <= x <= 1.0 + assert 0.0 <= y <= 1.0 + + +def test_dismiss_coord_xy_in_range() -> None: + """DismissCoord 各成员的 xy 为两元素 float 元组,取值在 [0, 1] 内。""" + for member in DismissCoord: + xy = member.xy + assert isinstance(xy, tuple) + assert len(xy) == 2 + x, y = xy + assert isinstance(x, float) + assert isinstance(y, float) + assert 0.0 <= x <= 1.0 + assert 0.0 <= y <= 1.0 + + +def test_theme_color_properties() -> None: + """ThemeColor 各成员的 color 为 Color 实例,tolerance 为 float。""" + for member in ThemeColor: + assert isinstance(member.color, Color) + assert isinstance(member.tolerance, float) + + +def test_sig_ps_returns_pixel_signature() -> None: + """Sig 各成员的 ps 返回 PixelSignature 实例。""" + for member in Sig: + assert isinstance(member.ps, PixelSignature) + + +def test_signatures_has_entry_for_every_sig() -> None: + """_SIGNATURES 包含所有 Sig 成员。""" + assert len(_SIGNATURES) == len(Sig) + for member in Sig: + assert member in _SIGNATURES + assert isinstance(_SIGNATURES[member], PixelSignature) + + +def test_overlay_kind_members_exist() -> None: + """OverlayKind 包含预期的成员。""" + assert len(OverlayKind) == 3 + assert OverlayKind.NEWS.value == '新闻公告' + assert OverlayKind.SIGN.value == '每日签到' + assert OverlayKind.BOOKING.value == '活动预约' diff --git a/tests/unit/ui/main_page/test_controller.py b/tests/unit/ui/main_page/test_controller.py new file mode 100644 index 00000000..84bbf86b --- /dev/null +++ b/tests/unit/ui/main_page/test_controller.py @@ -0,0 +1,10 @@ +"""测试 autowsgr.ui.main_page.controller.""" + +from __future__ import annotations + + +def test_module_importable() -> None: + """验证模块可被导入。""" + import autowsgr.ui.main_page.controller as _mod + + assert _mod is not None diff --git a/tests/unit/ui/main_page/test_event_nav.py b/tests/unit/ui/main_page/test_event_nav.py new file mode 100644 index 00000000..ef5d2d52 --- /dev/null +++ b/tests/unit/ui/main_page/test_event_nav.py @@ -0,0 +1,10 @@ +"""测试 autowsgr.ui.main_page.event_nav.""" + +from __future__ import annotations + + +def test_module_importable() -> None: + """验证模块可被导入。""" + import autowsgr.ui.main_page.event_nav as _mod + + assert _mod is not None diff --git a/tests/unit/ui/main_page/test_overlays.py b/tests/unit/ui/main_page/test_overlays.py new file mode 100644 index 00000000..84264959 --- /dev/null +++ b/tests/unit/ui/main_page/test_overlays.py @@ -0,0 +1,10 @@ +"""测试 autowsgr.ui.main_page.overlays.""" + +from __future__ import annotations + + +def test_module_importable() -> None: + """验证模块可被导入。""" + import autowsgr.ui.main_page.overlays as _mod + + assert _mod is not None diff --git a/tests/unit/ui/map/__init__.py b/tests/unit/ui/map/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/ui/map/panels/__init__.py b/tests/unit/ui/map/panels/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/ui/map/panels/test_campaign.py b/tests/unit/ui/map/panels/test_campaign.py new file mode 100644 index 00000000..acc62f5a --- /dev/null +++ b/tests/unit/ui/map/panels/test_campaign.py @@ -0,0 +1,10 @@ +"""测试 autowsgr.ui.map.panels.campaign.""" + +from __future__ import annotations + + +def test_module_importable() -> None: + """验证模块可被导入。""" + import autowsgr.ui.map.panels.campaign as _mod + + assert _mod is not None diff --git a/tests/unit/ui/map/panels/test_decisive.py b/tests/unit/ui/map/panels/test_decisive.py new file mode 100644 index 00000000..9f17c18b --- /dev/null +++ b/tests/unit/ui/map/panels/test_decisive.py @@ -0,0 +1,10 @@ +"""测试 autowsgr.ui.map.panels.decisive.""" + +from __future__ import annotations + + +def test_module_importable() -> None: + """验证模块可被导入。""" + import autowsgr.ui.map.panels.decisive as _mod + + assert _mod is not None diff --git a/tests/unit/ui/map/panels/test_exercise.py b/tests/unit/ui/map/panels/test_exercise.py new file mode 100644 index 00000000..dba0c844 --- /dev/null +++ b/tests/unit/ui/map/panels/test_exercise.py @@ -0,0 +1,10 @@ +"""测试 autowsgr.ui.map.panels.exercise.""" + +from __future__ import annotations + + +def test_module_importable() -> None: + """验证模块可被导入。""" + import autowsgr.ui.map.panels.exercise as _mod + + assert _mod is not None diff --git a/tests/unit/ui/map/panels/test_expedition.py b/tests/unit/ui/map/panels/test_expedition.py new file mode 100644 index 00000000..b23d915b --- /dev/null +++ b/tests/unit/ui/map/panels/test_expedition.py @@ -0,0 +1,10 @@ +"""测试 autowsgr.ui.map.panels.expedition.""" + +from __future__ import annotations + + +def test_module_importable() -> None: + """验证模块可被导入。""" + import autowsgr.ui.map.panels.expedition as _mod + + assert _mod is not None diff --git a/tests/unit/ui/map/panels/test_sortie.py b/tests/unit/ui/map/panels/test_sortie.py new file mode 100644 index 00000000..7b3ed4ce --- /dev/null +++ b/tests/unit/ui/map/panels/test_sortie.py @@ -0,0 +1,10 @@ +"""测试 autowsgr.ui.map.panels.sortie.""" + +from __future__ import annotations + + +def test_module_importable() -> None: + """验证模块可被导入。""" + import autowsgr.ui.map.panels.sortie as _mod + + assert _mod is not None diff --git a/tests/unit/ui/map/test_base.py b/tests/unit/ui/map/test_base.py new file mode 100644 index 00000000..cfb32912 --- /dev/null +++ b/tests/unit/ui/map/test_base.py @@ -0,0 +1,10 @@ +"""测试 autowsgr.ui.map.base.""" + +from __future__ import annotations + + +def test_module_importable() -> None: + """验证模块可被导入。""" + import autowsgr.ui.map.base as _mod + + assert _mod is not None diff --git a/tests/unit/ui/map/test_data.py b/tests/unit/ui/map/test_data.py new file mode 100644 index 00000000..e384d43a --- /dev/null +++ b/tests/unit/ui/map/test_data.py @@ -0,0 +1,127 @@ +"""测试 autowsgr.ui.map.data.""" + +from __future__ import annotations + +import pytest + +from autowsgr.ui.map.data import ( + CHAPTER_MAP_COUNTS, + CLICK_PANEL, + PANEL_LIST, + PANEL_TO_INDEX, + TOTAL_CHAPTERS, + MapIdentity, + MapPanel, + parse_map_title, +) + + +# ── MapIdentity ── + + +def test_map_identity_construction() -> None: + """MapIdentity 应正确保存字段值。""" + identity = MapIdentity(chapter=9, map_num=5, name='南大洋群岛', raw_text='9-5南大洋群岛') + assert identity.chapter == 9 + assert identity.map_num == 5 + assert identity.name == '南大洋群岛' + assert identity.raw_text == '9-5南大洋群岛' + + +# ── CHAPTER_MAP_COUNTS / TOTAL_CHAPTERS ── + + +def test_chapter_map_counts_derived() -> None: + """CHAPTER_MAP_COUNTS 应从 MAP_DATABASE 正确推算。""" + assert CHAPTER_MAP_COUNTS[1] == 5 + assert CHAPTER_MAP_COUNTS[2] == 6 + assert CHAPTER_MAP_COUNTS[9] == 5 + assert CHAPTER_MAP_COUNTS[10] == 1 + + +def test_total_chapters_matches_counts() -> None: + """TOTAL_CHAPTERS 应等于 CHAPTER_MAP_COUNTS 的长度。""" + assert len(CHAPTER_MAP_COUNTS) == TOTAL_CHAPTERS + + +# ── parse_map_title ── + + +def test_parse_map_title_basic() -> None: + """解析常规格式 ``章节-关卡名称``。""" + result = parse_map_title('9-5南大洋群岛') + assert result is not None + assert result.chapter == 9 + assert result.map_num == 5 + assert result.name == '南大洋群岛' + + +def test_parse_map_title_with_slash() -> None: + """解析带 ``/`` 分隔符的格式。""" + result = parse_map_title('9-5/南大洋群岛') + assert result is not None + assert result.chapter == 9 + assert result.map_num == 5 + assert result.name == '南大洋群岛' + + +def test_parse_map_title_with_spaces() -> None: + """解析带空格的格式。""" + result = parse_map_title('9 - 5 南大洋群岛') + assert result is not None + assert result.chapter == 9 + assert result.map_num == 5 + assert result.name == '南大洋群岛' + + +def test_parse_map_title_chapter_ten() -> None: + """解析两位数章节号。""" + result = parse_map_title('10-1极地海峡') + assert result is not None + assert result.chapter == 10 + assert result.map_num == 1 + assert result.name == '极地海峡' + + +@pytest.mark.parametrize( + 'text', + [ + '', + 'abc', + '无数字', + '--', + ], +) +def test_parse_map_title_invalid(text: str) -> None: + """无效文本应返回 None。""" + assert parse_map_title(text) is None + + +# ── MapPanel enum ── + + +def test_map_panel_members_exist() -> None: + """MapPanel 应包含预期成员。""" + assert MapPanel.SORTIE.value == '出征' + assert MapPanel.EXERCISE.value == '演习' + assert MapPanel.EXPEDITION.value == '远征' + assert MapPanel.BATTLE.value == '战役' + assert MapPanel.DECISIVE.value == '决战' + + +def test_panel_list_length() -> None: + """PANEL_LIST 长度应与 MapPanel 成员数一致。""" + assert len(PANEL_LIST) == len(MapPanel) + + +def test_panel_to_index_is_bijection() -> None: + """PANEL_TO_INDEX 应为每个 MapPanel 成员分配唯一且连续的索引。""" + assert set(PANEL_TO_INDEX.keys()) == set(MapPanel) + indices = list(PANEL_TO_INDEX.values()) + assert sorted(indices) == list(range(len(MapPanel))) + assert len(set(indices)) == len(indices) + + +def test_click_panel_keys_match_map_panel() -> None: + """CLICK_PANEL 的键应恰好覆盖所有 MapPanel 成员。""" + assert set(CLICK_PANEL.keys()) == set(MapPanel) diff --git a/tests/unit/ui/map_page/__init__.py b/tests/unit/ui/map_page/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/testing/ui/map_page/test_unit.py b/tests/unit/ui/map_page/test_unit.py similarity index 100% rename from testing/ui/map_page/test_unit.py rename to tests/unit/ui/map_page/test_unit.py diff --git a/tests/unit/ui/mission_page/__init__.py b/tests/unit/ui/mission_page/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/ui/mission_page/test_data.py b/tests/unit/ui/mission_page/test_data.py new file mode 100644 index 00000000..e0014c44 --- /dev/null +++ b/tests/unit/ui/mission_page/test_data.py @@ -0,0 +1,10 @@ +"""测试 autowsgr.ui.mission_page.data.""" + +from __future__ import annotations + + +def test_module_importable() -> None: + """验证模块可被导入。""" + import autowsgr.ui.mission_page.data as _mod + + assert _mod is not None diff --git a/tests/unit/ui/mission_page/test_recognition.py b/tests/unit/ui/mission_page/test_recognition.py new file mode 100644 index 00000000..03f08aa0 --- /dev/null +++ b/tests/unit/ui/mission_page/test_recognition.py @@ -0,0 +1,10 @@ +"""测试 autowsgr.ui.mission_page.recognition.""" + +from __future__ import annotations + + +def test_module_importable() -> None: + """验证模块可被导入。""" + import autowsgr.ui.mission_page.recognition as _mod + + assert _mod is not None diff --git a/tests/unit/ui/page/__init__.py b/tests/unit/ui/page/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/testing/ui/page/test_unit.py b/tests/unit/ui/page/test_unit.py similarity index 96% rename from testing/ui/page/test_unit.py rename to tests/unit/ui/page/test_unit.py index 83485a93..a517d599 100644 --- a/testing/ui/page/test_unit.py +++ b/tests/unit/ui/page/test_unit.py @@ -129,7 +129,10 @@ def advancing_time() -> float: mock_time.monotonic.side_effect = advancing_time mock_time.sleep = MagicMock() - with pytest.raises(NavigationError, match='超时'): + with ( + patch('autowsgr.infra.logger.save_image'), + pytest.raises(NavigationError, match='超时'), + ): wait_for_page( ctrl, lambda _s: False, diff --git a/testing/ui/run_all_e2e.ps1 b/tests/unit/ui/run_all_e2e.ps1 similarity index 100% rename from testing/ui/run_all_e2e.ps1 rename to tests/unit/ui/run_all_e2e.ps1 diff --git a/tests/unit/ui/sidebar_page/__init__.py b/tests/unit/ui/sidebar_page/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/ui/test_backyard_page.py b/tests/unit/ui/test_backyard_page.py new file mode 100644 index 00000000..043c77e7 --- /dev/null +++ b/tests/unit/ui/test_backyard_page.py @@ -0,0 +1,10 @@ +"""测试 autowsgr.ui.backyard_page.""" + +from __future__ import annotations + + +def test_module_importable() -> None: + """验证模块可被导入。""" + import autowsgr.ui.backyard_page as _mod + + assert _mod is not None diff --git a/tests/unit/ui/test_build_page.py b/tests/unit/ui/test_build_page.py new file mode 100644 index 00000000..de31cf94 --- /dev/null +++ b/tests/unit/ui/test_build_page.py @@ -0,0 +1,10 @@ +"""测试 autowsgr.ui.build_page.""" + +from __future__ import annotations + + +def test_module_importable() -> None: + """验证模块可被导入。""" + import autowsgr.ui.build_page as _mod + + assert _mod is not None diff --git a/tests/unit/ui/test_canteen_page.py b/tests/unit/ui/test_canteen_page.py new file mode 100644 index 00000000..6fab2a8a --- /dev/null +++ b/tests/unit/ui/test_canteen_page.py @@ -0,0 +1,10 @@ +"""测试 autowsgr.ui.canteen_page.""" + +from __future__ import annotations + + +def test_module_importable() -> None: + """验证模块可被导入。""" + import autowsgr.ui.canteen_page as _mod + + assert _mod is not None diff --git a/tests/unit/ui/test_choose_ship_page.py b/tests/unit/ui/test_choose_ship_page.py new file mode 100644 index 00000000..3121f7f7 --- /dev/null +++ b/tests/unit/ui/test_choose_ship_page.py @@ -0,0 +1,10 @@ +"""测试 autowsgr.ui.choose_ship_page.""" + +from __future__ import annotations + + +def test_module_importable() -> None: + """验证模块可被导入。""" + import autowsgr.ui.choose_ship_page as _mod + + assert _mod is not None diff --git a/tests/unit/ui/test_friend_page.py b/tests/unit/ui/test_friend_page.py new file mode 100644 index 00000000..25eb9bc1 --- /dev/null +++ b/tests/unit/ui/test_friend_page.py @@ -0,0 +1,10 @@ +"""测试 autowsgr.ui.friend_page.""" + +from __future__ import annotations + + +def test_module_importable() -> None: + """验证模块可被导入。""" + import autowsgr.ui.friend_page as _mod + + assert _mod is not None diff --git a/tests/unit/ui/test_intensify_page.py b/tests/unit/ui/test_intensify_page.py new file mode 100644 index 00000000..7f98e818 --- /dev/null +++ b/tests/unit/ui/test_intensify_page.py @@ -0,0 +1,10 @@ +"""测试 autowsgr.ui.intensify_page.""" + +from __future__ import annotations + + +def test_module_importable() -> None: + """验证模块可被导入。""" + import autowsgr.ui.intensify_page as _mod + + assert _mod is not None diff --git a/tests/unit/ui/test_navigation.py b/tests/unit/ui/test_navigation.py new file mode 100644 index 00000000..4920ec9f --- /dev/null +++ b/tests/unit/ui/test_navigation.py @@ -0,0 +1,104 @@ +"""Tests for autowsgr.ui.navigation.""" + +from __future__ import annotations + +import dataclasses + +import pytest + +from autowsgr.types import PageName +from autowsgr.ui.navigation import NAV_GRAPH, NavEdge, find_path + + +def test_nav_edge_is_frozen() -> None: + """NavEdge instances should be frozen dataclass objects.""" + edge = NavEdge( + PageName.MAIN, + PageName.MAP, + lambda _ctx: None, + 'test', + ) + assert edge.__dataclass_params__.frozen is True + with pytest.raises(dataclasses.FrozenInstanceError): + edge.source = PageName.MAP # ty: ignore[invalid-assignment] + + +def test_find_path_same_page_returns_empty_list() -> None: + """find_path from a page to itself should return an empty list.""" + path = find_path(PageName.MAIN, PageName.MAIN) + assert path == [] + + +def test_find_path_main_to_map() -> None: + """MAIN to MAP should be a single edge.""" + path = find_path(PageName.MAIN, PageName.MAP) + assert path is not None + assert len(path) == 1 + assert path[0].source == PageName.MAIN + assert path[0].target == PageName.MAP + + +def test_find_path_map_to_main() -> None: + """MAP to MAIN should be a single edge.""" + path = find_path(PageName.MAP, PageName.MAIN) + assert path is not None + assert len(path) == 1 + assert path[0].source == PageName.MAP + assert path[0].target == PageName.MAIN + + +def test_find_path_main_to_bath() -> None: + """MAIN to BATH should be MAIN -> BACKYARD -> BATH (2 edges).""" + path = find_path(PageName.MAIN, PageName.BATH) + assert path is not None + assert len(path) == 2 + assert path[0].source == PageName.MAIN + assert path[0].target == PageName.BACKYARD + assert path[1].source == PageName.BACKYARD + assert path[1].target == PageName.BATH + + +def test_find_path_main_to_build() -> None: + """MAIN to BUILD should be MAIN -> SIDEBAR -> BUILD (2 edges).""" + path = find_path(PageName.MAIN, PageName.BUILD) + assert path is not None + assert len(path) == 2 + assert path[0].source == PageName.MAIN + assert path[0].target == PageName.SIDEBAR + assert path[1].source == PageName.SIDEBAR + assert path[1].target == PageName.BUILD + + +def test_find_path_build_to_bath() -> None: + """BUILD to BATH should return a valid multi-hop path.""" + path = find_path(PageName.BUILD, PageName.BATH) + assert path is not None + assert len(path) > 1 + assert path[0].source == PageName.BUILD + assert path[-1].target == PageName.BATH + for i in range(len(path) - 1): + assert path[i].target == path[i + 1].source + + +def test_find_path_unknown_page_raises() -> None: + """find_path with an unknown page name should raise ValueError.""" + with pytest.raises(ValueError, match='不是合法的 PageName 取值'): + find_path('unknown_page', PageName.MAIN) + with pytest.raises(ValueError, match='不是合法的 PageName 取值'): + find_path(PageName.MAIN, 'unknown_page') + + +def test_nav_graph_no_duplicate_edges() -> None: + """NAV_GRAPH should not contain duplicate edges in the same direction.""" + seen: set[tuple[PageName, PageName]] = set() + for edge in NAV_GRAPH: + key = (edge.source, edge.target) + assert key not in seen, f'Duplicate edge: {edge.source.value} -> {edge.target.value}' + seen.add(key) + + +def test_nav_graph_edges_use_valid_page_names() -> None: + """Every edge's source and target should be a valid PageName value.""" + for edge in NAV_GRAPH: + assert isinstance(edge.source, PageName) + assert isinstance(edge.target, PageName) diff --git a/tests/unit/ui/test_sidebar_page.py b/tests/unit/ui/test_sidebar_page.py new file mode 100644 index 00000000..b9d67c9e --- /dev/null +++ b/tests/unit/ui/test_sidebar_page.py @@ -0,0 +1,10 @@ +"""测试 autowsgr.ui.sidebar_page.""" + +from __future__ import annotations + + +def test_module_importable() -> None: + """验证模块可被导入。""" + import autowsgr.ui.sidebar_page as _mod + + assert _mod is not None diff --git a/tests/unit/ui/test_start_screen_page.py b/tests/unit/ui/test_start_screen_page.py new file mode 100644 index 00000000..f51c7fd4 --- /dev/null +++ b/tests/unit/ui/test_start_screen_page.py @@ -0,0 +1,10 @@ +"""测试 autowsgr.ui.start_screen_page.""" + +from __future__ import annotations + + +def test_module_importable() -> None: + """验证模块可被导入。""" + import autowsgr.ui.start_screen_page as _mod + + assert _mod is not None diff --git a/tests/unit/ui/test_tabbed_page.py b/tests/unit/ui/test_tabbed_page.py new file mode 100644 index 00000000..44a6b9b7 --- /dev/null +++ b/tests/unit/ui/test_tabbed_page.py @@ -0,0 +1,10 @@ +"""测试 autowsgr.ui.tabbed_page.""" + +from __future__ import annotations + + +def test_module_importable() -> None: + """验证模块可被导入。""" + import autowsgr.ui.tabbed_page as _mod + + assert _mod is not None diff --git a/tests/unit/ui/utils/__init__.py b/tests/unit/ui/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/ui/utils/test_navigation.py b/tests/unit/ui/utils/test_navigation.py new file mode 100644 index 00000000..430c1eac --- /dev/null +++ b/tests/unit/ui/utils/test_navigation.py @@ -0,0 +1,73 @@ +"""Tests for autowsgr.ui.utils.navigation.""" + +from __future__ import annotations + +import pytest + +from autowsgr.ui.utils.navigation import ( + DEFAULT_NAV_CONFIG, + NavConfig, + NavigationError, +) + + +def test_navigation_error_raise_and_catch() -> None: + """NavigationError can be raised and caught; str(err) returns message.""" + with pytest.raises(NavigationError, match='page not found'): + raise NavigationError('page not found') + + +def test_navigation_error_without_screen() -> None: + """NavigationError without screen does not crash.""" + err = NavigationError('missing screen') + assert str(err) == 'missing screen' + + +def test_nav_config_defaults() -> None: + """NavConfig has expected default values.""" + config = NavConfig() + assert config.max_retries == 2 + assert config.retry_delay == 1.0 + assert config.timeout == 5.0 + assert config.interval == 0.5 + assert config.handle_overlays is True + + +def test_nav_config_is_frozen() -> None: + """NavConfig is frozen and raises AttributeError on mutation.""" + config = NavConfig() + with pytest.raises(AttributeError): + config.max_retries = 10 # ty: ignore[invalid-assignment] + + +def test_nav_config_has_slots() -> None: + """NavConfig uses __slots__ and does not allow arbitrary attributes.""" + config = NavConfig() + with pytest.raises(AttributeError): + object.__setattr__(config, 'new_attr', 'value') + + +def test_default_nav_config_instance() -> None: + """DEFAULT_NAV_CONFIG is a NavConfig instance with default values.""" + assert isinstance(DEFAULT_NAV_CONFIG, NavConfig) + assert DEFAULT_NAV_CONFIG.max_retries == 2 + assert DEFAULT_NAV_CONFIG.retry_delay == 1.0 + assert DEFAULT_NAV_CONFIG.timeout == 5.0 + assert DEFAULT_NAV_CONFIG.interval == 0.5 + assert DEFAULT_NAV_CONFIG.handle_overlays is True + + +def test_nav_config_custom_values() -> None: + """NavConfig accepts custom values.""" + config = NavConfig( + max_retries=5, + retry_delay=2.0, + timeout=10.0, + interval=1.0, + handle_overlays=False, + ) + assert config.max_retries == 5 + assert config.retry_delay == 2.0 + assert config.timeout == 10.0 + assert config.interval == 1.0 + assert config.handle_overlays is False diff --git a/tests/unit/ui/utils/test_ship_list.py b/tests/unit/ui/utils/test_ship_list.py new file mode 100644 index 00000000..e241f06a --- /dev/null +++ b/tests/unit/ui/utils/test_ship_list.py @@ -0,0 +1,76 @@ +"""Tests for autowsgr.ui.utils.ship_list.""" + +from __future__ import annotations + +from autowsgr.ui.utils.ship_list import ( + LevelOCRRetryNeededError, + _center_x, + _coerce_level_digits, + _noise_char_count, + _parse_level, + _parse_level_with_status, +) + + +class TestNoiseCharCount: + def test_all_noise_letters(self) -> None: + # I, L, l, O, o are noise; 1 and 0 are valid digits and not counted. + assert _noise_char_count('ILl1Oo0') == 5 + + def test_no_noise(self) -> None: + assert _noise_char_count('abc123') == 0 + + def test_empty(self) -> None: + assert _noise_char_count('') == 0 + + +class TestCoerceLevelDigits: + def test_simple_three_digit(self) -> None: + assert _coerce_level_digits('120') == 120 + + def test_truncates_to_first_three(self) -> None: + assert _coerce_level_digits('1046') == 104 + + def test_leading_zero(self) -> None: + assert _coerce_level_digits('051') == 51 + + def test_only_zeros_after_translation(self) -> None: + assert _coerce_level_digits('O0') is None + + def test_letter_to_digit_translation(self) -> None: + assert _coerce_level_digits('IL') == 11 + + def test_no_digits(self) -> None: + assert _coerce_level_digits('abc') is None + + +class TestParseLevel: + def test_standard_level(self) -> None: + assert _parse_level('Lv.120') == 120 + + def test_no_level(self) -> None: + assert _parse_level('abc') is None + + +class TestParseLevelWithStatus: + def test_standard_level(self) -> None: + assert _parse_level_with_status('Lv.120') == (120, False) + + def test_noisy_excessive_noise(self) -> None: + assert _parse_level_with_status('Lv.IL') == (None, True) + + def test_noisy_pattern_valid(self) -> None: + assert _parse_level_with_status('lV.12') == (12, False) + + +class TestCenterX: + def test_none_bbox(self) -> None: + assert _center_x(None, 100) == 50.0 + + def test_valid_bbox(self) -> None: + assert _center_x((10, 0, 30, 0), 100) == 20.0 + + +class TestLevelOCRRetryNeededError: + def test_is_runtime_error_subclass(self) -> None: + assert issubclass(LevelOCRRetryNeededError, RuntimeError) diff --git a/tests/unit/vision/__init__.py b/tests/unit/vision/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/testing/vision/_helpers.py b/tests/unit/vision/_helpers.py similarity index 100% rename from testing/vision/_helpers.py rename to tests/unit/vision/_helpers.py diff --git a/tests/unit/vision/test_api_dll.py b/tests/unit/vision/test_api_dll.py new file mode 100644 index 00000000..b09810c4 --- /dev/null +++ b/tests/unit/vision/test_api_dll.py @@ -0,0 +1,106 @@ +"""Tests for autowsgr.vision.api_dll.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +import cv2 +import numpy as np +import pytest + +from autowsgr.vision.api_dll import ApiDll, get_api_dll + + +if TYPE_CHECKING: + from collections.abc import Generator + + +@pytest.fixture(autouse=True) +def mock_native() -> Generator[MagicMock, None, None]: + """Patch autowsgr_native in api_dll and clear the singleton cache.""" + get_api_dll.cache_clear() + with patch('autowsgr.vision.api_dll.autowsgr_native', new_callable=MagicMock) as m: + yield m + + +@pytest.fixture +def mock_cv2_resize() -> Generator[MagicMock, None, None]: + """Patch cv2.resize inside api_dll.""" + with patch('autowsgr.vision.api_dll.cv2.resize') as m: + yield m + + +def test_get_api_dll_singleton() -> None: + """Two calls to get_api_dll return the same object.""" + api1 = get_api_dll() + api2 = get_api_dll() + assert api1 is api2 + + +def test_api_dll_instantiation() -> None: + """ApiDll can be instantiated without crashing.""" + dll = ApiDll() + assert isinstance(dll, ApiDll) + + +def test_recognize_map_tall_image( + mock_native: MagicMock, + mock_cv2_resize: MagicMock, +) -> None: + """recognize_map resizes tall images before delegating.""" + image = np.zeros((1080, 1920, 3), dtype=np.uint8) + resized = np.zeros((720, 1280, 3), dtype=np.uint8) + mock_cv2_resize.return_value = resized + mock_native.recognize_map.return_value = 'map_result' + + dll = ApiDll() + result = dll.recognize_map(image) + + mock_cv2_resize.assert_called_once_with( + image, + (1280, 720), + interpolation=cv2.INTER_AREA, + ) + mock_native.recognize_map.assert_called_once_with(resized) + assert result == 'map_result' + + +def test_recognize_map_short_image( + mock_native: MagicMock, + mock_cv2_resize: MagicMock, +) -> None: + """recognize_map does not resize short images.""" + image = np.zeros((720, 1280, 3), dtype=np.uint8) + mock_native.recognize_map.return_value = 'map_result' + + dll = ApiDll() + result = dll.recognize_map(image) + + mock_cv2_resize.assert_not_called() + mock_native.recognize_map.assert_called_once_with(image) + assert result == 'map_result' + + +def test_locate_delegates(mock_native: MagicMock) -> None: + """locate delegates to autowsgr_native.locate.""" + image = np.zeros((100, 100, 3), dtype=np.uint8) + mock_native.locate.return_value = [(1.0, 2.0)] + + dll = ApiDll() + result = dll.locate(image) + + mock_native.locate.assert_called_once_with(image) + assert result == [(1.0, 2.0)] + + +def test_recognize_enemy_delegates(mock_native: MagicMock) -> None: + """recognize_enemy delegates to autowsgr_native.recognize_enemy.""" + images = [np.zeros((100, 100, 3), dtype=np.uint8)] + mock_native.recognize_enemy.return_value = 'enemy_name' + + dll = ApiDll() + result = dll.recognize_enemy(images) + + mock_native.recognize_enemy.assert_called_once_with(images) + assert result == 'enemy_name' diff --git a/testing/vision/test_image_checker.py b/tests/unit/vision/test_image_checker.py similarity index 100% rename from testing/vision/test_image_checker.py rename to tests/unit/vision/test_image_checker.py diff --git a/tests/unit/vision/test_image_matcher.py b/tests/unit/vision/test_image_matcher.py new file mode 100644 index 00000000..32583784 --- /dev/null +++ b/tests/unit/vision/test_image_matcher.py @@ -0,0 +1,494 @@ +"""Tests for autowsgr.vision.image_matcher — ImageChecker engine.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from autowsgr.vision import ( + ROI, + TEMPLATE_SOURCE_RESOLUTION, + ImageChecker, + ImageRule, + ImageSignature, + ImageTemplate, + MatchStrategy, +) + + +# ───────────────────────────────────────────── +# Helpers +# ───────────────────────────────────────────── + + +def _solid_screen(r: int, g: int, b: int, h: int = 100, w: int = 100) -> np.ndarray: + """Create a solid-color screenshot.""" + screen = np.zeros((h, w, 3), dtype=np.uint8) + screen[:, :] = [r, g, b] + return screen + + +def _make_template( + name: str = 'tmpl', + size: int = 20, + resolution: tuple[int, int] = (100, 100), +) -> ImageTemplate: + """Create a random template whose pattern depends on *name*.""" + rng = np.random.RandomState(hash(name) % (2**31)) + img = rng.randint(0, 256, (size, size, 3), dtype=np.uint8) + return ImageTemplate( + name=name, + image=img, + source='test', + source_resolution=resolution, + ) + + +def _embed(screen: np.ndarray, img: np.ndarray, x: int, y: int) -> np.ndarray: + """Embed an image into a screen at absolute pixel coordinates.""" + s = screen.copy() + h, w = img.shape[:2] + s[y : y + h, x : x + w] = img + return s + + +# ───────────────────────────────────────────── +# _scale_template_if_needed +# ───────────────────────────────────────────── + + +class TestScaleTemplate: + def test_same_resolution_returns_original(self) -> None: + img = np.zeros((30, 40, 3), dtype=np.uint8) + result = ImageChecker._scale_template_if_needed( + img, + 960, + 540, + source_resolution=(960, 540), + ) + assert result is img + + def test_different_resolution_returns_new_array(self) -> None: + img = np.zeros((60, 80, 3), dtype=np.uint8) + result = ImageChecker._scale_template_if_needed( + img, + 960, + 540, + source_resolution=(1920, 1080), + ) + assert result is not img + assert result.shape == (30, 40, 3) + + def test_global_fallback_resolution(self) -> None: + img = np.zeros((30, 40, 3), dtype=np.uint8) + result = ImageChecker._scale_template_if_needed(img, 960, 540) + assert result is img + assert TEMPLATE_SOURCE_RESOLUTION == (960, 540) + + +# ───────────────────────────────────────────── +# crop +# ───────────────────────────────────────────── + + +class TestCrop: + def test_crop_returns_correct_subarray(self) -> None: + screen = _solid_screen(100, 150, 200) + roi = ROI(0.0, 0.0, 0.5, 0.5) + cropped = ImageChecker.crop(screen, roi) + assert cropped.shape == (50, 50, 3) + assert cropped[0, 0, 0] == 100 + + def test_crop_returns_copy(self) -> None: + screen = _solid_screen(100, 150, 200) + roi = ROI(0.0, 0.0, 0.5, 0.5) + cropped = ImageChecker.crop(screen, roi) + cropped[0, 0] = [255, 0, 0] + assert screen[0, 0, 0] == 100 + + +# ───────────────────────────────────────────── +# find_template +# ───────────────────────────────────────────── + + +class TestFindTemplate: + def test_perfect_match(self) -> None: + screen = _solid_screen(0, 0, 0) + tmpl = _make_template(name='exact') + screen = _embed(screen, tmpl.image, 10, 10) + detail = ImageChecker.find_template(screen, tmpl, confidence=0.9) + assert detail is not None + assert detail.confidence > 0.99 + assert detail.template_name == 'exact' + cx, cy = detail.center + assert cx == pytest.approx(0.2, abs=0.01) + assert cy == pytest.approx(0.2, abs=0.01) + + def test_no_match(self) -> None: + screen = _solid_screen(0, 0, 0) + tmpl = _make_template(name='absent') + detail = ImageChecker.find_template(screen, tmpl, confidence=0.9) + assert detail is None + + +# ───────────────────────────────────────────── +# find_any +# ───────────────────────────────────────────── + + +class TestFindAny: + def test_first_matching_template(self) -> None: + screen = _solid_screen(0, 0, 0) + t1 = _make_template(name='first') + t2 = _make_template(name='second') + screen = _embed(screen, t1.image, 10, 10) + detail = ImageChecker.find_any(screen, [t1, t2], confidence=0.9) + assert detail is not None + assert detail.template_name == 'first' + + def test_second_matches_when_first_absent(self) -> None: + screen = _solid_screen(0, 0, 0) + t1 = _make_template(name='first') + t2 = _make_template(name='second') + screen = _embed(screen, t2.image, 10, 10) + detail = ImageChecker.find_any(screen, [t1, t2], confidence=0.9) + assert detail is not None + assert detail.template_name == 'second' + + def test_none_match(self) -> None: + screen = _solid_screen(0, 0, 0) + t1 = _make_template(name='absent') + detail = ImageChecker.find_any(screen, [t1], confidence=0.9) + assert detail is None + + +# ───────────────────────────────────────────── +# find_best +# ───────────────────────────────────────────── + + +class TestFindBest: + def test_highest_confidence_returned(self) -> None: + screen = _solid_screen(0, 0, 0) + exact = _make_template(name='exact') + # Create a noisy variant by altering a small central block. + noisy_img = exact.image.copy() + noisy_img[8:12, 8:12] = [0, 0, 0] + noisy = ImageTemplate( + name='noisy', + image=noisy_img, + source='test', + source_resolution=exact.source_resolution, + ) + screen = _embed(screen, exact.image, 10, 10) + + # Verify exact template matches with higher confidence. + exact_detail = ImageChecker._match_single_template( + screen, + exact, + confidence=0.5, + ) + noisy_detail = ImageChecker._match_single_template( + screen, + noisy, + confidence=0.5, + ) + assert exact_detail is not None + assert noisy_detail is not None + assert exact_detail.confidence > noisy_detail.confidence + + # Pass noisy first; find_best should still return exact. + best = ImageChecker.find_best(screen, [noisy, exact], confidence=0.5) + assert best is not None + assert best.template_name == 'exact' + + def test_none_match(self) -> None: + screen = _solid_screen(0, 0, 0) + t1 = _make_template(name='absent') + best = ImageChecker.find_best(screen, [t1], confidence=0.9) + assert best is None + + +# ───────────────────────────────────────────── +# find_all +# ───────────────────────────────────────────── + + +class TestFindAll: + def test_returns_all_matching(self) -> None: + screen = _solid_screen(0, 0, 0) + t1 = _make_template(name='a') + t2 = _make_template(name='b') + screen = _embed(screen, t1.image, 10, 10) + screen = _embed(screen, t2.image, 60, 60) + results = ImageChecker.find_all(screen, [t1, t2], confidence=0.9) + assert len(results) == 2 + names = {r.template_name for r in results} + assert names == {'a', 'b'} + + def test_skips_non_matching(self) -> None: + screen = _solid_screen(0, 0, 0) + t1 = _make_template(name='a') + t2 = _make_template(name='absent') + screen = _embed(screen, t1.image, 10, 10) + results = ImageChecker.find_all(screen, [t1, t2], confidence=0.9) + assert len(results) == 1 + assert results[0].template_name == 'a' + + +# ───────────────────────────────────────────── +# template_exists +# ───────────────────────────────────────────── + + +class TestTemplateExists: + def test_single_template_true(self) -> None: + screen = _solid_screen(0, 0, 0) + tmpl = _make_template(name='btn') + screen = _embed(screen, tmpl.image, 10, 10) + assert ImageChecker.template_exists(screen, tmpl, confidence=0.9) is True + + def test_single_template_false(self) -> None: + screen = _solid_screen(0, 0, 0) + tmpl = _make_template(name='btn') + assert ImageChecker.template_exists(screen, tmpl, confidence=0.9) is False + + def test_list_with_match(self) -> None: + screen = _solid_screen(0, 0, 0) + t1 = _make_template(name='a') + t2 = _make_template(name='b') + screen = _embed(screen, t2.image, 10, 10) + assert ImageChecker.template_exists(screen, [t1, t2], confidence=0.9) is True + + def test_list_without_match(self) -> None: + screen = _solid_screen(0, 0, 0) + t1 = _make_template(name='a') + t2 = _make_template(name='b') + assert ImageChecker.template_exists(screen, [t1, t2], confidence=0.9) is False + + +# ───────────────────────────────────────────── +# identify +# ───────────────────────────────────────────── + + +class TestIdentify: + def test_first_matching_signature(self) -> None: + screen = _solid_screen(0, 0, 0) + t1 = _make_template(name='a') + screen = _embed(screen, t1.image, 10, 10) + sig1 = ImageSignature( + name='page_a', + rules=[ImageRule(name='r1', templates=[t1], confidence=0.9)], + ) + sig2 = ImageSignature( + name='page_b', + rules=[ + ImageRule( + name='r2', + templates=[_make_template(name='absent')], + confidence=0.9, + ), + ], + ) + result = ImageChecker.identify(screen, [sig1, sig2]) + assert result is not None + assert result.rule_name == 'page_a' + + def test_none_match(self) -> None: + screen = _solid_screen(0, 0, 0) + sig = ImageSignature( + name='page_x', + rules=[ + ImageRule( + name='r', + templates=[_make_template(name='absent')], + confidence=0.9, + ), + ], + ) + assert ImageChecker.identify(screen, [sig]) is None + + +# ───────────────────────────────────────────── +# find_all_occurrences +# ───────────────────────────────────────────── + + +class TestFindAllOccurrences: + def test_multiple_occurrences(self) -> None: + screen = _solid_screen(0, 0, 0) + tmpl = _make_template(name='icon') + screen = _embed(screen, tmpl.image, 10, 10) + screen = _embed(screen, tmpl.image, 60, 60) + results = ImageChecker.find_all_occurrences( + screen, + tmpl, + confidence=0.9, + min_distance=10, + ) + assert len(results) == 2 + + def test_nms_deduplication(self) -> None: + screen = _solid_screen(0, 0, 0) + tmpl = _make_template(name='icon') + screen = _embed(screen, tmpl.image, 10, 10) + screen = _embed(screen, tmpl.image, 12, 12) + results = ImageChecker.find_all_occurrences( + screen, + tmpl, + confidence=0.9, + min_distance=10, + ) + assert len(results) == 1 + + +# ───────────────────────────────────────────── +# match_rule +# ───────────────────────────────────────────── + + +class TestMatchRule: + def test_or_semantics_one_matches(self) -> None: + screen = _solid_screen(0, 0, 0) + t1 = _make_template(name='v1') + t2 = _make_template(name='v2') + screen = _embed(screen, t2.image, 10, 10) + rule = ImageRule(name='confirm', templates=[t1, t2], confidence=0.9) + result = ImageChecker.match_rule(screen, rule) + assert result.matched is True + assert result.best is not None + assert result.best.template_name == 'v2' + + def test_or_semantics_none_match(self) -> None: + screen = _solid_screen(0, 0, 0) + t1 = _make_template(name='v1') + t2 = _make_template(name='v2') + rule = ImageRule(name='confirm', templates=[t1, t2], confidence=0.9) + result = ImageChecker.match_rule(screen, rule) + assert result.matched is False + assert result.best is None + + +# ───────────────────────────────────────────── +# check_signature +# ───────────────────────────────────────────── + + +class TestCheckSignature: + def test_all_strategy_all_match(self) -> None: + screen = _solid_screen(0, 0, 0) + t1 = _make_template(name='a') + t2 = _make_template(name='b') + screen = _embed(screen, t1.image, 10, 10) + screen = _embed(screen, t2.image, 60, 60) + sig = ImageSignature( + name='page', + rules=[ + ImageRule(name='r1', templates=[t1], confidence=0.9), + ImageRule(name='r2', templates=[t2], confidence=0.9), + ], + strategy=MatchStrategy.ALL, + ) + result = ImageChecker.check_signature(screen, sig) + assert result.matched is True + + def test_all_strategy_short_circuit_fail(self) -> None: + screen = _solid_screen(0, 0, 0) + t1 = _make_template(name='a') + screen = _embed(screen, t1.image, 10, 10) + sig = ImageSignature( + name='page', + rules=[ + ImageRule(name='r1', templates=[t1], confidence=0.9), + ImageRule( + name='r2', + templates=[_make_template(name='absent')], + confidence=0.9, + ), + ], + strategy=MatchStrategy.ALL, + ) + result = ImageChecker.check_signature(screen, sig) + assert result.matched is False + + def test_any_strategy_short_circuit_success(self) -> None: + screen = _solid_screen(0, 0, 0) + t1 = _make_template(name='a') + screen = _embed(screen, t1.image, 10, 10) + sig = ImageSignature( + name='page', + rules=[ + ImageRule(name='r1', templates=[t1], confidence=0.9), + ImageRule( + name='r2', + templates=[_make_template(name='absent')], + confidence=0.9, + ), + ], + strategy=MatchStrategy.ANY, + ) + result = ImageChecker.check_signature(screen, sig) + assert result.matched is True + + def test_any_strategy_none_match(self) -> None: + screen = _solid_screen(0, 0, 0) + sig = ImageSignature( + name='page', + rules=[ + ImageRule(name='r1', templates=[_make_template(name='a')], confidence=0.9), + ImageRule(name='r2', templates=[_make_template(name='b')], confidence=0.9), + ], + strategy=MatchStrategy.ANY, + ) + result = ImageChecker.check_signature(screen, sig) + assert result.matched is False + + def test_count_strategy_meets_threshold(self) -> None: + screen = _solid_screen(0, 0, 0) + t1 = _make_template(name='a') + t2 = _make_template(name='b') + screen = _embed(screen, t1.image, 10, 10) + screen = _embed(screen, t2.image, 60, 60) + sig = ImageSignature( + name='page', + rules=[ + ImageRule(name='r1', templates=[t1], confidence=0.9), + ImageRule(name='r2', templates=[t2], confidence=0.9), + ImageRule( + name='r3', + templates=[_make_template(name='absent')], + confidence=0.9, + ), + ], + strategy=MatchStrategy.COUNT, + threshold=2, + ) + result = ImageChecker.check_signature(screen, sig) + assert result.matched is True + + def test_count_strategy_below_threshold(self) -> None: + screen = _solid_screen(0, 0, 0) + t1 = _make_template(name='a') + screen = _embed(screen, t1.image, 10, 10) + sig = ImageSignature( + name='page', + rules=[ + ImageRule(name='r1', templates=[t1], confidence=0.9), + ImageRule( + name='r2', + templates=[_make_template(name='absent')], + confidence=0.9, + ), + ImageRule( + name='r3', + templates=[_make_template(name='absent2')], + confidence=0.9, + ), + ], + strategy=MatchStrategy.COUNT, + threshold=2, + ) + result = ImageChecker.check_signature(screen, sig) + assert result.matched is False diff --git a/testing/vision/test_image_template.py b/tests/unit/vision/test_image_template.py similarity index 100% rename from testing/vision/test_image_template.py rename to tests/unit/vision/test_image_template.py diff --git a/testing/vision/test_matcher.py b/tests/unit/vision/test_matcher.py similarity index 99% rename from testing/vision/test_matcher.py rename to tests/unit/vision/test_matcher.py index 51991a85..d864238b 100644 --- a/testing/vision/test_matcher.py +++ b/tests/unit/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 # noqa: PGH003 + c.r = 99 # ty: ignore[invalid-assignment] # ───────────────────────────────────────────── @@ -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 # noqa: PGH003 + r.x = 99 # ty: ignore[invalid-assignment] # ───────────────────────────────────────────── diff --git a/testing/vision/test_ocr.py b/tests/unit/vision/test_ocr.py similarity index 99% rename from testing/vision/test_ocr.py rename to tests/unit/vision/test_ocr.py index 53725780..8a1e8188 100644 --- a/testing/vision/test_ocr.py +++ b/tests/unit/vision/test_ocr.py @@ -46,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 # noqa: PGH003 + r.text = 'y' # ty: ignore[invalid-assignment] # ───────────────────────────────────────────── diff --git a/tests/unit/vision/test_pixel.py b/tests/unit/vision/test_pixel.py new file mode 100644 index 00000000..940a7c15 --- /dev/null +++ b/tests/unit/vision/test_pixel.py @@ -0,0 +1,372 @@ +"""测试 autowsgr.vision.pixel.""" + +from __future__ import annotations + +import pytest + +from autowsgr.vision.pixel import ( + Color, + CompositePixelSignature, + MatchStrategy, + PixelDetail, + PixelMatchResult, + PixelRule, + PixelSignature, +) + + +class TestColor: + """Color 构造、转换与距离计算。""" + + def test_constructor(self) -> None: + c = Color(r=10, g=20, b=30) + assert c.r == 10 + assert c.g == 20 + assert c.b == 30 + + def test_of(self) -> None: + c = Color.of(1, 2, 3) + assert c == Color(1, 2, 3) + + def test_from_rgb(self) -> None: + c = Color.from_rgb(4, 5, 6) + assert c == Color(4, 5, 6) + + def test_from_bgr(self) -> None: + c = Color.from_bgr(30, 20, 10) + assert c == Color(10, 20, 30) + + def test_from_rgb_tuple(self) -> None: + c = Color.from_rgb_tuple((10, 20, 30)) + assert c == Color(10, 20, 30) + + def test_from_bgr_tuple(self) -> None: + c = Color.from_bgr_tuple((30, 20, 10)) + assert c == Color(10, 20, 30) + + def test_as_rgb_tuple(self) -> None: + c = Color(1, 2, 3) + assert c.as_rgb_tuple() == (1, 2, 3) + + def test_as_bgr_tuple(self) -> None: + c = Color(1, 2, 3) + assert c.as_bgr_tuple() == (3, 2, 1) + + def test_frozen(self) -> None: + c = Color(1, 2, 3) + with pytest.raises(AttributeError): + c.r = 10 # ty: ignore[invalid-assignment] + + def test_repr(self) -> None: + assert repr(Color(1, 2, 3)) == 'Color(r=1, g=2, b=3)' + + def test_distance_zero(self) -> None: + c = Color(100, 100, 100) + assert c.distance(c) == 0.0 + + def test_distance_nonzero(self) -> None: + a = Color(0, 0, 0) + b = Color(3, 4, 0) + assert a.distance(b) == 5.0 + + def test_distance_3d(self) -> None: + a = Color(1, 2, 2) + b = Color(4, 6, 7) + expected = ((3) ** 2 + (4) ** 2 + (5) ** 2) ** 0.5 + assert a.distance(b) == pytest.approx(expected) + + def test_near_exact(self) -> None: + a = Color(100, 100, 100) + assert a.near(a) is True + + def test_near_within_tolerance(self) -> None: + a = Color(0, 0, 0) + b = Color(3, 4, 0) + assert a.near(b, tolerance=5.0) is True + + def test_near_at_boundary(self) -> None: + a = Color(0, 0, 0) + b = Color(3, 4, 0) + assert a.near(b, tolerance=5.0) is True + + def test_near_outside_tolerance(self) -> None: + a = Color(0, 0, 0) + b = Color(3, 4, 0) + assert a.near(b, tolerance=4.9) is False + + def test_near_default_tolerance(self) -> None: + a = Color(0, 0, 0) + b = Color(20, 20, 0) + dist = a.distance(b) + assert a.near(b) is (dist <= 30.0) + + +class TestPixelRule: + """PixelRule 构造与字典序列化。""" + + def test_constructor_defaults(self) -> None: + rule = PixelRule(x=0.5, y=0.5, color=Color(1, 2, 3)) + assert rule.x == 0.5 + assert rule.y == 0.5 + assert rule.color == Color(1, 2, 3) + assert rule.tolerance == 30.0 + + def test_of(self) -> None: + rule = PixelRule.of(0.1, 0.2, (10, 20, 30), tolerance=15.0) + assert rule.x == 0.1 + assert rule.y == 0.2 + assert rule.color == Color(10, 20, 30) + assert rule.tolerance == 15.0 + + def test_to_dict_round_trip(self) -> None: + rule = PixelRule(x=0.5, y=0.85, color=Color(201, 129, 54), tolerance=40.0) + d = rule.to_dict() + restored = PixelRule.from_dict(d) + assert restored == rule + + def test_from_dict_with_list_color(self) -> None: + d = {'x': 0.5, 'y': 0.85, 'color': [201, 129, 54]} + rule = PixelRule.from_dict(d) + assert rule.color == Color(201, 129, 54) + assert rule.tolerance == 30.0 + + def test_from_dict_with_tuple_color(self) -> None: + d = {'x': 0.5, 'y': 0.85, 'color': (201, 129, 54), 'tolerance': 25.0} + rule = PixelRule.from_dict(d) + assert rule.color == Color(201, 129, 54) + assert rule.tolerance == 25.0 + + def test_from_dict_with_dict_color(self) -> None: + d = {'x': 0.1, 'y': 0.2, 'color': {'r': 1, 'g': 2, 'b': 3}, 'tolerance': 10.0} + rule = PixelRule.from_dict(d) + assert rule.color == Color(1, 2, 3) + assert rule.tolerance == 10.0 + + def test_from_dict_invalid_color_type(self) -> None: + d = {'x': 0.0, 'y': 0.0, 'color': 'not_a_color'} + with pytest.raises(TypeError): + PixelRule.from_dict(d) + + def test_frozen(self) -> None: + rule = PixelRule(x=0.0, y=0.0, color=Color(0, 0, 0)) + with pytest.raises(AttributeError): + rule.x = 1.0 # ty: ignore[invalid-assignment] + + +class TestMatchStrategy: + """MatchStrategy 枚举值。""" + + def test_all(self) -> None: + assert MatchStrategy.ALL.value == 'all' + + def test_any(self) -> None: + assert MatchStrategy.ANY.value == 'any' + + def test_count(self) -> None: + assert MatchStrategy.COUNT.value == 'count' + + def test_membership(self) -> None: + assert set(MatchStrategy) == {MatchStrategy.ALL, MatchStrategy.ANY, MatchStrategy.COUNT} + + +class TestPixelSignature: + """PixelSignature 构造、转换与长度。""" + + def test_constructor_defaults(self) -> None: + sig = PixelSignature(name='test', rules=()) + assert sig.name == 'test' + assert sig.rules == () + assert sig.strategy == MatchStrategy.ALL + assert sig.threshold == 0 + + def test_list_to_tuple_conversion(self) -> None: + rules = [PixelRule.of(0.1, 0.2, (1, 2, 3))] + sig = PixelSignature(name='test', rules=rules) + assert isinstance(sig.rules, tuple) + assert sig.rules == tuple(rules) + + def test_len(self) -> None: + sig = PixelSignature( + name='test', + rules=[ + PixelRule.of(0.1, 0.2, (1, 2, 3)), + PixelRule.of(0.3, 0.4, (4, 5, 6)), + ], + ) + assert len(sig) == 2 + + def test_len_empty(self) -> None: + sig = PixelSignature(name='empty', rules=()) + assert len(sig) == 0 + + def test_from_dict_to_dict_round_trip(self) -> None: + sig = PixelSignature( + name='main_page', + rules=[ + PixelRule.of(0.1, 0.2, (1, 2, 3), tolerance=25.0), + PixelRule.of(0.3, 0.4, (4, 5, 6)), + ], + strategy=MatchStrategy.COUNT, + threshold=1, + ) + d = sig.to_dict() + restored = PixelSignature.from_dict(d) + assert restored == sig + + def test_from_dict_default_strategy_and_threshold(self) -> None: + d = { + 'name': 'default_sig', + 'rules': [{'x': 0.5, 'y': 0.5, 'color': [10, 20, 30]}], + } + sig = PixelSignature.from_dict(d) + assert sig.strategy == MatchStrategy.ALL + assert sig.threshold == 0 + assert len(sig) == 1 + + def test_frozen(self) -> None: + sig = PixelSignature(name='test', rules=()) + with pytest.raises(AttributeError): + sig.name = 'changed' # ty: ignore[invalid-assignment] + + +class TestCompositePixelSignature: + """CompositePixelSignature 构造、转换与长度。""" + + def test_constructor_list_to_tuple(self) -> None: + sigs = [PixelSignature(name='a', rules=[PixelRule.of(0.1, 0.2, (1, 2, 3))])] + comp = CompositePixelSignature(name='comp', signatures=sigs) + assert isinstance(comp.signatures, tuple) + assert comp.signatures == tuple(sigs) + + def test_len(self) -> None: + comp = CompositePixelSignature( + name='comp', + signatures=[ + PixelSignature(name='a', rules=[PixelRule.of(0.1, 0.2, (1, 2, 3))]), + PixelSignature( + name='b', + rules=[ + PixelRule.of(0.3, 0.4, (4, 5, 6)), + PixelRule.of(0.5, 0.6, (7, 8, 9)), + ], + ), + ], + ) + assert len(comp) == 3 + + def test_len_empty(self) -> None: + comp = CompositePixelSignature(name='empty', signatures=()) + assert len(comp) == 0 + + def test_any_of(self) -> None: + a = PixelSignature(name='a', rules=[PixelRule.of(0.1, 0.2, (1, 2, 3))]) + b = PixelSignature(name='b', rules=[PixelRule.of(0.3, 0.4, (4, 5, 6))]) + comp = CompositePixelSignature.any_of('either', a, b) + assert comp.name == 'either' + assert comp.signatures == (a, b) + assert len(comp) == 2 + + def test_frozen(self) -> None: + comp = CompositePixelSignature(name='test', signatures=()) + with pytest.raises(AttributeError): + comp.name = 'changed' # ty: ignore[invalid-assignment] + + +class TestPixelDetail: + """PixelDetail 纯数据类。""" + + def test_attributes(self) -> None: + rule = PixelRule.of(0.1, 0.2, (1, 2, 3)) + actual = Color(4, 5, 6) + detail = PixelDetail(rule=rule, actual=actual, distance=5.0, matched=True) + assert detail.rule == rule + assert detail.actual == actual + assert detail.distance == 5.0 + assert detail.matched is True + + def test_frozen(self) -> None: + detail = PixelDetail( + rule=PixelRule.of(0.0, 0.0, (0, 0, 0)), + actual=Color(0, 0, 0), + distance=0.0, + matched=False, + ) + with pytest.raises(AttributeError): + detail.matched = True # ty: ignore[invalid-assignment] + + +class TestPixelMatchResult: + """PixelMatchResult 布尔值与比例计算。""" + + def test_bool_true(self) -> None: + result = PixelMatchResult( + matched=True, + signature_name='sig', + matched_count=2, + total_count=2, + ) + assert bool(result) is True + + def test_bool_false(self) -> None: + result = PixelMatchResult( + matched=False, + signature_name='sig', + matched_count=0, + total_count=2, + ) + assert bool(result) is False + + def test_ratio_full_match(self) -> None: + result = PixelMatchResult( + matched=True, + signature_name='sig', + matched_count=4, + total_count=4, + ) + assert result.ratio == 1.0 + + def test_ratio_partial_match(self) -> None: + result = PixelMatchResult( + matched=True, + signature_name='sig', + matched_count=1, + total_count=4, + ) + assert result.ratio == 0.25 + + def test_ratio_zero_total(self) -> None: + result = PixelMatchResult( + matched=False, + signature_name='sig', + matched_count=0, + total_count=0, + ) + assert result.ratio == 0.0 + + def test_ratio_zero_matched(self) -> None: + result = PixelMatchResult( + matched=False, + signature_name='sig', + matched_count=0, + total_count=5, + ) + assert result.ratio == 0.0 + + def test_details_default(self) -> None: + result = PixelMatchResult( + matched=True, + signature_name='sig', + matched_count=1, + total_count=1, + ) + assert result.details == () + + def test_frozen(self) -> None: + result = PixelMatchResult( + matched=True, + signature_name='sig', + matched_count=1, + total_count=1, + ) + with pytest.raises(AttributeError): + result.matched = False # ty: ignore[invalid-assignment] diff --git a/testing/vision/test_roi.py b/tests/unit/vision/test_roi.py similarity index 100% rename from testing/vision/test_roi.py rename to tests/unit/vision/test_roi.py diff --git a/uv.lock b/uv.lock index fc19f844..9f2e06d5 100644 --- a/uv.lock +++ b/uv.lock @@ -72,6 +72,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "beautifulsoup4" }, + { name = "httpx" }, { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-cov" }, @@ -98,6 +99,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "beautifulsoup4" }, + { name = "httpx", specifier = ">=0.28.0" }, { name = "pre-commit" }, { name = "pytest", specifier = ">=8.0" }, { name = "pytest-cov" }, @@ -464,6 +466,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + [[package]] name = "httptools" version = "0.7.1" @@ -486,6 +501,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, ] +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "identify" version = "2.6.19"