Merge branch 'main' into xzhseh/sqlite-event-manager
This commit is contained in:
97
tests/README.md
Normal file
97
tests/README.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# 单元测试指南
|
||||
|
||||
本文档旨在指导如何为《修仙模拟器》编写和维护单元测试。
|
||||
|
||||
## 目录结构
|
||||
|
||||
* `tests/`: 所有的单元测试文件都应存放在此目录下。
|
||||
* `tests/conftest.py`: 包含全局共享的 Fixture 和 Helper 函数。
|
||||
* `tests/test_*.py`: 具体模块的测试文件。命名应与 `src` 下的模块对应,例如 `src/classes/action/buy.py` 对应 `tests/test_buy_action.py`。
|
||||
|
||||
## 运行测试
|
||||
|
||||
在项目根目录下运行:
|
||||
|
||||
```bash
|
||||
pytest
|
||||
```
|
||||
|
||||
或者运行特定文件:
|
||||
|
||||
```bash
|
||||
pytest tests/test_buy_action.py
|
||||
```
|
||||
|
||||
## 编写新测试
|
||||
|
||||
我们使用 `pytest` 框架。为了保持代码整洁(DRY),请遵循以下准则:
|
||||
|
||||
### 1. 使用共享 Fixture
|
||||
|
||||
不要在每个测试文件中重复创建测试用的 Avatar、Map 或 Item。请使用 `tests/conftest.py` 中提供的 Fixture:
|
||||
|
||||
* `base_world`: 提供一个基础的游戏世界环境。
|
||||
* `dummy_avatar`: 提供一个标准的测试用角色(位于(0,0),练气期,男性)。
|
||||
* `avatar_in_city`: 基于 `dummy_avatar`,但已将其置于城市中,并给予 1000 灵石,且背包为空。
|
||||
* `mock_item_data`: 提供一组标准的 Mock 物品(丹药、材料、兵器、法宝)以及它们对应的 mock 字典结构,方便用于 patch `resolution` 模块。
|
||||
* `mock_llm_managers`: 自动 Mock 掉所有 LLM 调用,防止测试跑大模型。
|
||||
|
||||
**示例:**
|
||||
|
||||
```python
|
||||
def test_my_feature(avatar_in_city, mock_item_data):
|
||||
# 直接使用准备好的角色
|
||||
assert avatar_in_city.magic_stone == 1000
|
||||
|
||||
# 获取标准测试物品
|
||||
test_sword = mock_item_data["obj_weapon"]
|
||||
|
||||
# ...
|
||||
```
|
||||
|
||||
### 2. Mock 外部依赖
|
||||
|
||||
对于 Action 测试,通常需要 Mock `src.utils.resolution` 中的查找字典。请结合 `mock_item_data` 使用 `unittest.mock.patch`。
|
||||
|
||||
**示例:**
|
||||
|
||||
```python
|
||||
from unittest.mock import patch
|
||||
|
||||
def test_action_logic(avatar_in_city, mock_item_data):
|
||||
materials_mock = mock_item_data["materials"]
|
||||
|
||||
with patch("src.utils.resolution.materials_by_name", materials_mock):
|
||||
# 此时系统中只有 mock_item_data 里定义的材料是可见的
|
||||
action = MyAction(avatar_in_city, avatar_in_city.world)
|
||||
action.execute("铁矿石")
|
||||
```
|
||||
|
||||
### 3. Action 测试模板
|
||||
|
||||
对于 `src.classes.action` 下的新 Action,建议测试以下三个方面:
|
||||
|
||||
1. **`can_start` (前置条件检查)**:
|
||||
* 测试成功情况。
|
||||
* 测试各种失败情况(如不在正确地点、资源不足、目标不存在等),并断言返回的错误 `reason`。
|
||||
2. **`start` (事件生成)**:
|
||||
* 验证返回的 `Event` 对象包含正确的描述文本。
|
||||
3. **`_execute` (执行逻辑)**:
|
||||
* 验证对 Avatar 状态的修改(扣钱、加物品、扣血、加熟练度等)。
|
||||
* 验证对 World 状态的修改。
|
||||
|
||||
### 4. Helper 函数
|
||||
|
||||
如果需要创建特定的测试对象,优先查看 `conftest.py` 中的 helper 函数:
|
||||
* `create_test_material(...)`
|
||||
* `create_test_weapon(...)`
|
||||
* `create_test_elixir(...)`
|
||||
* `create_test_auxiliary(...)`
|
||||
|
||||
如果发现新的通用需求,请将其添加到 `conftest.py` 而不是在测试文件中复制粘贴。
|
||||
|
||||
## 常见问题
|
||||
|
||||
* **`ModuleNotFoundError`**: 确保你的 IDE 或终端将项目根目录添加到了 `PYTHONPATH`。`pytest` 通常会自动处理这个问题。
|
||||
* **LLM 被调用了**: 确保你的测试(如果涉及 sim 循环)使用了 `mock_llm_managers` fixture,或者手动 patch 了相关模块。
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
|
||||
from src.classes.map import Map
|
||||
from src.classes.tile import TileType
|
||||
from src.classes.tile import TileType, Tile
|
||||
from src.classes.world import World
|
||||
from src.classes.calendar import Month, Year, create_month_stamp
|
||||
from src.classes.avatar import Avatar, Gender
|
||||
@@ -10,6 +10,16 @@ from src.classes.age import Age
|
||||
from src.classes.cultivation import Realm
|
||||
from src.utils.id_generator import get_avatar_id
|
||||
from src.classes.name import get_random_name
|
||||
from src.classes.root import Root
|
||||
from src.classes.alignment import Alignment
|
||||
|
||||
# Action related imports
|
||||
from src.classes.elixir import Elixir, ElixirType
|
||||
from src.classes.material import Material
|
||||
from src.classes.weapon import Weapon
|
||||
from src.classes.weapon_type import WeaponType
|
||||
from src.classes.auxiliary import Auxiliary
|
||||
from src.classes.region import CityRegion
|
||||
|
||||
@pytest.fixture
|
||||
def base_map():
|
||||
@@ -26,9 +36,6 @@ def base_world(base_map):
|
||||
"""创建一个基于 base_map 的世界,时间为 Year 1, Jan"""
|
||||
return World(map=base_map, month_stamp=create_month_stamp(Year(1), Month.JANUARY))
|
||||
|
||||
from src.classes.root import Root
|
||||
from src.classes.alignment import Alignment
|
||||
|
||||
@pytest.fixture
|
||||
def dummy_avatar(base_world):
|
||||
"""创建一个位于 (0,0) 的标准男性练气期角色"""
|
||||
@@ -62,31 +69,15 @@ def dummy_avatar(base_world):
|
||||
def mock_llm_managers():
|
||||
"""
|
||||
Mock 所有涉及 LLM 调用的管理器和函数,防止测试中意外调用 LLM。
|
||||
包括:
|
||||
- llm_ai (decision making)
|
||||
- process_avatar_long_term_objective (long term goal)
|
||||
- process_avatar_nickname (nickname generation)
|
||||
- RelationResolver.run_batch (relationship evolution)
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock, AsyncMock
|
||||
|
||||
with patch("src.sim.simulator.llm_ai") as mock_ai, \
|
||||
patch("src.sim.simulator.process_avatar_long_term_objective", new_callable=AsyncMock) as mock_lto, \
|
||||
patch("src.classes.nickname.process_avatar_nickname", new_callable=AsyncMock) as mock_nick, \
|
||||
patch("src.classes.relation_resolver.RelationResolver.run_batch", new_callable=AsyncMock) as mock_rr:
|
||||
|
||||
# 1. Mock AI Decision
|
||||
# ai.decide is an async method
|
||||
mock_ai.decide = AsyncMock(return_value={})
|
||||
|
||||
# 2. Mock Long Term Objective
|
||||
# AsyncMock returns a coroutine when called
|
||||
mock_lto.return_value = None
|
||||
|
||||
# 3. Mock Nickname
|
||||
mock_nick.return_value = None
|
||||
|
||||
# 4. Mock Relation Resolver
|
||||
mock_rr.return_value = []
|
||||
|
||||
yield {
|
||||
@@ -95,3 +86,100 @@ def mock_llm_managers():
|
||||
"nick": mock_nick,
|
||||
"rr": mock_rr
|
||||
}
|
||||
|
||||
# --- Shared Helpers for Item Creation ---
|
||||
|
||||
def create_test_elixir(name, realm, price=100, elixir_id=1, effects=None):
|
||||
if effects is None:
|
||||
effects = {"max_hp": 10}
|
||||
return Elixir(
|
||||
id=elixir_id,
|
||||
name=name,
|
||||
realm=realm,
|
||||
type=ElixirType.Breakthrough,
|
||||
desc="测试丹药",
|
||||
price=price,
|
||||
effects=effects
|
||||
)
|
||||
|
||||
def create_test_material(name, realm, material_id=101):
|
||||
return Material(
|
||||
id=material_id,
|
||||
name=name,
|
||||
desc="测试物品",
|
||||
realm=realm
|
||||
)
|
||||
|
||||
def create_test_weapon(name, realm, weapon_id=201):
|
||||
return Weapon(
|
||||
id=weapon_id,
|
||||
name=name,
|
||||
weapon_type=WeaponType.SWORD,
|
||||
realm=realm,
|
||||
desc="测试兵器",
|
||||
effects={},
|
||||
effect_desc=""
|
||||
)
|
||||
|
||||
def create_test_auxiliary(name, realm, aux_id=301):
|
||||
return Auxiliary(
|
||||
id=aux_id,
|
||||
name=name,
|
||||
realm=realm,
|
||||
desc="测试法宝",
|
||||
effects={},
|
||||
effect_desc=""
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def avatar_in_city(dummy_avatar):
|
||||
"""
|
||||
修改 dummy_avatar,使其位于城市中,并给予初始资金
|
||||
"""
|
||||
city_region = CityRegion(id=1, name="TestCity", desc="测试城市")
|
||||
tile = Tile(0, 0, TileType.CITY)
|
||||
tile.region = city_region
|
||||
|
||||
dummy_avatar.tile = tile
|
||||
dummy_avatar.magic_stone = 1000
|
||||
dummy_avatar.cultivation_progress.realm = Realm.Qi_Refinement
|
||||
dummy_avatar.elixirs = []
|
||||
dummy_avatar.materials = {} # 确保背包为空
|
||||
dummy_avatar.weapon = None
|
||||
dummy_avatar.auxiliary = None
|
||||
|
||||
return dummy_avatar
|
||||
|
||||
@pytest.fixture
|
||||
def mock_item_data():
|
||||
"""
|
||||
提供标准的一组测试物品,包括材料、丹药、兵器、法宝。
|
||||
返回一个包含这些对象的字典,方便后续 mock 使用。
|
||||
"""
|
||||
test_elixir = create_test_elixir("聚气丹", Realm.Qi_Refinement, price=100)
|
||||
high_level_elixir = create_test_elixir("筑基丹", Realm.Foundation_Establishment, price=1000, elixir_id=2)
|
||||
test_material = create_test_material("铁矿石", Realm.Qi_Refinement)
|
||||
test_weapon = create_test_weapon("青云剑", Realm.Qi_Refinement)
|
||||
test_auxiliary = create_test_auxiliary("聚灵珠", Realm.Qi_Refinement)
|
||||
|
||||
return {
|
||||
"elixirs": {
|
||||
"聚气丹": [test_elixir],
|
||||
"筑基丹": [high_level_elixir]
|
||||
},
|
||||
"materials": {
|
||||
"铁矿石": test_material
|
||||
},
|
||||
"weapons": {
|
||||
"青云剑": test_weapon
|
||||
},
|
||||
"auxiliaries": {
|
||||
"聚灵珠": test_auxiliary
|
||||
},
|
||||
# Direct access
|
||||
"obj_elixir": test_elixir,
|
||||
"obj_high_elixir": high_level_elixir,
|
||||
"obj_material": test_material,
|
||||
"obj_weapon": test_weapon,
|
||||
"obj_auxiliary": test_auxiliary
|
||||
}
|
||||
|
||||
@@ -1 +1,111 @@
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from src.classes.action.attack import Attack
|
||||
from src.classes.event import Event
|
||||
from src.classes.cultivation import Realm
|
||||
from src.classes.avatar import Avatar
|
||||
|
||||
# 定义一个简单的 Result Mock
|
||||
class MockResolutionResult:
|
||||
def __init__(self, obj):
|
||||
self.obj = obj
|
||||
|
||||
def test_attack_can_start_success(dummy_avatar):
|
||||
"""测试攻击条件检查通过"""
|
||||
target = MagicMock(spec=Avatar)
|
||||
target.name = "TargetAvatar"
|
||||
target.is_dead = False
|
||||
|
||||
with patch("src.classes.action.attack.resolve_query") as mock_resolve:
|
||||
mock_resolve.return_value = MockResolutionResult(target)
|
||||
|
||||
action = Attack(dummy_avatar, dummy_avatar.world)
|
||||
can_start, reason = action.can_start("TargetAvatar")
|
||||
|
||||
assert can_start is True
|
||||
assert reason == ""
|
||||
|
||||
def test_attack_can_start_fail_no_target(dummy_avatar):
|
||||
"""测试目标不存在"""
|
||||
with patch("src.classes.action.attack.resolve_query") as mock_resolve:
|
||||
mock_resolve.return_value = MockResolutionResult(None)
|
||||
|
||||
action = Attack(dummy_avatar, dummy_avatar.world)
|
||||
can_start, reason = action.can_start("Ghost")
|
||||
|
||||
assert can_start is False
|
||||
assert "目标不存在" in reason
|
||||
|
||||
def test_attack_can_start_fail_dead_target(dummy_avatar):
|
||||
"""测试目标已死亡"""
|
||||
target = MagicMock(spec=Avatar)
|
||||
target.is_dead = True
|
||||
|
||||
with patch("src.classes.action.attack.resolve_query") as mock_resolve:
|
||||
mock_resolve.return_value = MockResolutionResult(target)
|
||||
|
||||
action = Attack(dummy_avatar, dummy_avatar.world)
|
||||
can_start, reason = action.can_start("Zombie")
|
||||
|
||||
assert can_start is False
|
||||
assert "目标已死亡" in reason
|
||||
|
||||
def test_attack_start_event(dummy_avatar):
|
||||
"""测试开始攻击生成的事件"""
|
||||
target = MagicMock(spec=Avatar)
|
||||
target.name = "Enemy"
|
||||
target.id = "enemy-id"
|
||||
|
||||
# Mock combat strength calculation
|
||||
with patch("src.classes.action.attack.resolve_query") as mock_resolve, \
|
||||
patch("src.classes.action.attack.get_effective_strength_pair") as mock_strength:
|
||||
|
||||
mock_resolve.return_value = MockResolutionResult(target)
|
||||
mock_strength.return_value = (100, 80)
|
||||
|
||||
action = Attack(dummy_avatar, dummy_avatar.world)
|
||||
event = action.start("Enemy")
|
||||
|
||||
assert isinstance(event, Event)
|
||||
assert "TestDummy" in event.content
|
||||
assert "Enemy" in event.content
|
||||
assert "100" in event.content # 战斗力显示
|
||||
assert event.is_major is True
|
||||
|
||||
def test_attack_execute_logic(dummy_avatar):
|
||||
"""测试执行战斗逻辑"""
|
||||
target = MagicMock(spec=Avatar)
|
||||
target.name = "Enemy"
|
||||
|
||||
# Setup HP mocks
|
||||
dummy_avatar.hp = MagicMock()
|
||||
target.hp = MagicMock()
|
||||
|
||||
# Setup proficiency mocks (methods on MagicMock)
|
||||
dummy_avatar.increase_weapon_proficiency = MagicMock()
|
||||
target.increase_weapon_proficiency = MagicMock()
|
||||
|
||||
with patch("src.classes.action.attack.resolve_query") as mock_resolve, \
|
||||
patch("src.classes.action.attack.decide_battle") as mock_decide:
|
||||
|
||||
mock_resolve.return_value = MockResolutionResult(target)
|
||||
|
||||
# winner, loser, loser_damage, winner_damage
|
||||
# 假设 dummy_avatar 赢了
|
||||
mock_decide.return_value = (dummy_avatar, target, 50, 10)
|
||||
|
||||
action = Attack(dummy_avatar, dummy_avatar.world)
|
||||
action._execute("Enemy")
|
||||
|
||||
# 验证伤害应用
|
||||
# loser (target) takes 50 dmg
|
||||
target.hp.reduce.assert_called_with(50)
|
||||
# winner (dummy) takes 10 dmg
|
||||
dummy_avatar.hp.reduce.assert_called_with(10)
|
||||
|
||||
# 验证熟练度增加
|
||||
assert dummy_avatar.increase_weapon_proficiency.called
|
||||
assert target.increase_weapon_proficiency.called
|
||||
|
||||
# 验证结果保存
|
||||
assert action._last_result == (dummy_avatar, target, 50, 10)
|
||||
|
||||
140
tests/test_action_self_heal.py
Normal file
140
tests/test_action_self_heal.py
Normal file
@@ -0,0 +1,140 @@
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from src.classes.action.self_heal import SelfHeal
|
||||
from src.classes.sect_region import SectRegion
|
||||
from src.classes.region import NormalRegion
|
||||
from src.classes.tile import Tile, TileType
|
||||
from src.classes.sect import Sect
|
||||
from src.classes.hp import HP
|
||||
|
||||
class TestSelfHealAction:
|
||||
|
||||
@pytest.fixture
|
||||
def healing_avatar(self, dummy_avatar):
|
||||
"""
|
||||
基于 dummy_avatar 扩展,
|
||||
设置 HP 为半血,以便可以进行疗伤。
|
||||
"""
|
||||
dummy_avatar.hp = HP(100, 50) # 50/100 HP
|
||||
# effects 是 property,无法直接赋值,需要 mock 或者通过 effects mixin 覆盖
|
||||
# 这里 dummy_avatar 使用了 Real Avatar 类,所以 effects property 会去读 self._effects 或者计算
|
||||
return dummy_avatar
|
||||
|
||||
@pytest.fixture
|
||||
def sect_region(self):
|
||||
return SectRegion(id=999, name="青云门总部", desc="测试宗门总部")
|
||||
|
||||
@pytest.fixture
|
||||
def normal_region(self):
|
||||
return NormalRegion(id=101, name="荒野", desc="测试荒野")
|
||||
|
||||
@pytest.fixture
|
||||
def mock_sect(self, sect_region):
|
||||
sect = MagicMock(spec=Sect)
|
||||
sect.name = "青云门"
|
||||
# 确保 headquarter.name 和 region.name 一致
|
||||
sect.headquarter = MagicMock()
|
||||
sect.headquarter.name = sect_region.name
|
||||
return sect
|
||||
|
||||
def test_can_start_basic(self, healing_avatar):
|
||||
"""测试基本启动条件:HP不满即可"""
|
||||
# Action 类通常需要 (avatar, world) 参数
|
||||
action = SelfHeal(healing_avatar, healing_avatar.world)
|
||||
can, reason = action.can_start()
|
||||
assert can is True
|
||||
assert reason == ""
|
||||
|
||||
def test_cannot_start_full_hp(self, healing_avatar):
|
||||
"""测试满血不能启动"""
|
||||
healing_avatar.hp.cur = 100
|
||||
action = SelfHeal(healing_avatar, healing_avatar.world)
|
||||
can, reason = action.can_start()
|
||||
assert can is False
|
||||
assert "HP已满" in reason
|
||||
|
||||
def test_execute_in_wild_no_bonus(self, healing_avatar, normal_region):
|
||||
"""测试在野外(非宗门)的基础回复(10%)"""
|
||||
# 设置位置
|
||||
healing_avatar.tile = Tile(0, 0, TileType.PLAIN)
|
||||
healing_avatar.tile.region = normal_region
|
||||
healing_avatar.sect = None # 散修
|
||||
|
||||
# Mock effects 为空
|
||||
with patch.object(type(healing_avatar), 'effects', new_callable=lambda: {}) as mock_effects:
|
||||
action = SelfHeal(healing_avatar, healing_avatar.world)
|
||||
action._execute()
|
||||
|
||||
# 预期:基础回复 10% * 100 = 10
|
||||
# 初始 50 -> 60
|
||||
assert healing_avatar.hp.cur == 60
|
||||
assert action._healed_total == 10
|
||||
|
||||
def test_execute_in_wild_with_persona_bonus(self, healing_avatar, normal_region):
|
||||
"""测试在野外带有 '苟' 特质加成(+50% efficiency)"""
|
||||
# 设置位置
|
||||
healing_avatar.tile = Tile(0, 0, TileType.PLAIN)
|
||||
healing_avatar.tile.region = normal_region
|
||||
|
||||
# Mock effects 带有加成
|
||||
with patch.object(type(healing_avatar), 'effects', new_callable=lambda: {"extra_self_heal_efficiency": 0.5}):
|
||||
action = SelfHeal(healing_avatar, healing_avatar.world)
|
||||
action._execute()
|
||||
|
||||
# 预期:基础 0.1 * (1 + 0.5) = 0.15
|
||||
# 回复 15 点 -> 50 + 15 = 65
|
||||
assert healing_avatar.hp.cur == 65
|
||||
assert action._healed_total == 15
|
||||
|
||||
def test_execute_in_sect_hq_as_member(self, healing_avatar, sect_region, mock_sect):
|
||||
"""测试宗门弟子在总部回复(直接回满)"""
|
||||
# 设置位置
|
||||
# TileType.SECT 可能不存在,检查源码通常用 TileType.CITY 或 PLAIN,关键是 region
|
||||
# 如果需要区分 TileType,请检查 src/classes/tile.py,这里先用 PLAIN 并确保 region 是 SectRegion
|
||||
# 不过为了保险,我们可以查看 TileType 定义。
|
||||
# 暂时用 PLAIN,关键是 region 类型。
|
||||
healing_avatar.tile = Tile(0, 0, TileType.PLAIN)
|
||||
healing_avatar.tile.region = sect_region
|
||||
|
||||
# 设置宗门身份
|
||||
healing_avatar.sect = mock_sect
|
||||
|
||||
with patch.object(type(healing_avatar), 'effects', new_callable=lambda: {}):
|
||||
action = SelfHeal(healing_avatar, healing_avatar.world)
|
||||
action._execute()
|
||||
|
||||
# 预期:直接回满 -> 100
|
||||
assert healing_avatar.hp.cur == 100
|
||||
assert action._healed_total == 50
|
||||
|
||||
def test_execute_in_sect_hq_not_member(self, healing_avatar, sect_region):
|
||||
"""测试非本门弟子在某宗门总部(视为普通区域回复)"""
|
||||
# 设置位置
|
||||
healing_avatar.tile = Tile(0, 0, TileType.PLAIN)
|
||||
healing_avatar.tile.region = sect_region
|
||||
|
||||
# 散修(或无匹配宗门)
|
||||
healing_avatar.sect = None
|
||||
|
||||
with patch.object(type(healing_avatar), 'effects', new_callable=lambda: {}):
|
||||
action = SelfHeal(healing_avatar, healing_avatar.world)
|
||||
action._execute()
|
||||
|
||||
# 预期:基础回复 10% = 10
|
||||
assert healing_avatar.hp.cur == 60
|
||||
assert action._healed_total == 10
|
||||
|
||||
def test_heal_overflow_clamp(self, healing_avatar, normal_region):
|
||||
"""测试回复溢出处理(不超过 MaxHP)"""
|
||||
healing_avatar.hp.cur = 95 # 只差5点
|
||||
healing_avatar.tile = Tile(0, 0, TileType.PLAIN)
|
||||
healing_avatar.tile.region = normal_region
|
||||
|
||||
with patch.object(type(healing_avatar), 'effects', new_callable=lambda: {}):
|
||||
action = SelfHeal(healing_avatar, healing_avatar.world)
|
||||
action._execute()
|
||||
|
||||
# 预期:基础回复 10点,但只缺5点 -> 回复5点,当前100
|
||||
assert healing_avatar.hp.cur == 100
|
||||
assert action._healed_total == 5
|
||||
@@ -1,80 +1,19 @@
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from unittest.mock import patch
|
||||
from src.classes.action.buy import Buy
|
||||
from src.classes.region import CityRegion, Region
|
||||
from src.classes.elixir import Elixir, ElixirType, ConsumedElixir
|
||||
from src.classes.item import Item
|
||||
from src.classes.region import CityRegion
|
||||
from src.classes.elixir import ElixirType, ConsumedElixir
|
||||
from src.classes.cultivation import Realm
|
||||
from src.classes.tile import Tile, TileType
|
||||
from tests.conftest import create_test_weapon # Explicitly import if needed, or rely on conftest being auto-loaded (it is)
|
||||
|
||||
# 创建一些测试用的对象
|
||||
def create_test_elixir(name, realm, price=100, elixir_id=1, effects=None):
|
||||
if effects is None:
|
||||
effects = {"max_hp": 10}
|
||||
return Elixir(
|
||||
id=elixir_id,
|
||||
name=name,
|
||||
realm=realm,
|
||||
type=ElixirType.Breakthrough,
|
||||
desc="测试丹药",
|
||||
price=price,
|
||||
effects=effects
|
||||
)
|
||||
|
||||
def create_test_item(name, realm, item_id=101):
|
||||
return Item(
|
||||
id=item_id,
|
||||
name=name,
|
||||
desc="测试物品",
|
||||
realm=realm
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def avatar_in_city(dummy_avatar):
|
||||
"""
|
||||
修改 dummy_avatar,使其位于城市中,并给予初始资金
|
||||
"""
|
||||
# 模拟 Tile 和 Region
|
||||
# Region init: id, name, desc, cors (default=[])
|
||||
city_region = CityRegion(id=1, name="TestCity", desc="测试城市")
|
||||
tile = Tile(0, 0, TileType.CITY)
|
||||
tile.region = city_region
|
||||
|
||||
dummy_avatar.tile = tile
|
||||
dummy_avatar.magic_stone = 1000 # 初始资金
|
||||
dummy_avatar.cultivation_progress.realm = Realm.Qi_Refinement # 练气期
|
||||
dummy_avatar.elixirs = [] # 清空已服用丹药
|
||||
|
||||
return dummy_avatar
|
||||
|
||||
@pytest.fixture
|
||||
def mock_objects():
|
||||
"""
|
||||
Mock elixirs_by_name 和 items_by_name
|
||||
"""
|
||||
test_elixir = create_test_elixir("聚气丹", Realm.Qi_Refinement, price=100)
|
||||
high_level_elixir = create_test_elixir("筑基丹", Realm.Foundation_Establishment, price=1000, elixir_id=2)
|
||||
test_item = create_test_item("铁矿石", Realm.Qi_Refinement)
|
||||
|
||||
# elixirs_by_name 是 Dict[str, List[Elixir]]
|
||||
elixirs_mock = {
|
||||
"聚气丹": [test_elixir],
|
||||
"筑基丹": [high_level_elixir]
|
||||
}
|
||||
|
||||
# items_by_name 是 Dict[str, Item]
|
||||
items_mock = {
|
||||
"铁矿石": test_item
|
||||
}
|
||||
|
||||
return elixirs_mock, items_mock, test_elixir, high_level_elixir, test_item
|
||||
|
||||
def test_buy_item_success(avatar_in_city, mock_objects):
|
||||
"""测试购买普通物品成功"""
|
||||
elixirs_mock, items_mock, _, _, test_item = mock_objects
|
||||
def test_buy_item_success(avatar_in_city, mock_item_data):
|
||||
"""测试购买普通材料成功"""
|
||||
elixirs_mock = mock_item_data["elixirs"]
|
||||
materials_mock = mock_item_data["materials"]
|
||||
test_material = mock_item_data["obj_material"]
|
||||
|
||||
with patch("src.utils.resolution.elixirs_by_name", elixirs_mock), \
|
||||
patch("src.utils.resolution.items_by_name", items_mock):
|
||||
patch("src.utils.resolution.materials_by_name", materials_mock):
|
||||
|
||||
action = Buy(avatar_in_city, avatar_in_city.world)
|
||||
|
||||
@@ -84,21 +23,23 @@ def test_buy_item_success(avatar_in_city, mock_objects):
|
||||
|
||||
# 2. 执行购买
|
||||
initial_money = avatar_in_city.magic_stone
|
||||
# 练气期物品基础价格 10,倍率 1.5 -> 15
|
||||
# 练气期材料基础价格 10,倍率 1.5 -> 15
|
||||
expected_price = int(10 * 1.5)
|
||||
|
||||
action._execute("铁矿石")
|
||||
|
||||
# 3. 验证结果
|
||||
assert avatar_in_city.magic_stone == initial_money - expected_price
|
||||
assert avatar_in_city.get_item_quantity(test_item) == 1
|
||||
assert avatar_in_city.get_material_quantity(test_material) == 1
|
||||
|
||||
def test_buy_elixir_success(avatar_in_city, mock_objects):
|
||||
def test_buy_elixir_success(avatar_in_city, mock_item_data):
|
||||
"""测试购买并服用丹药成功"""
|
||||
elixirs_mock, items_mock, test_elixir, _, _ = mock_objects
|
||||
elixirs_mock = mock_item_data["elixirs"]
|
||||
materials_mock = mock_item_data["materials"]
|
||||
test_elixir = mock_item_data["obj_elixir"]
|
||||
|
||||
with patch("src.utils.resolution.elixirs_by_name", elixirs_mock), \
|
||||
patch("src.utils.resolution.items_by_name", items_mock):
|
||||
patch("src.utils.resolution.materials_by_name", materials_mock):
|
||||
|
||||
action = Buy(avatar_in_city, avatar_in_city.world)
|
||||
|
||||
@@ -108,28 +49,26 @@ def test_buy_elixir_success(avatar_in_city, mock_objects):
|
||||
initial_money = avatar_in_city.magic_stone
|
||||
expected_price = int(test_elixir.price * 1.5)
|
||||
|
||||
# 模拟服用丹药的行为(因为 consume_elixir 是 Avatar 的方法,我们可以信赖它,
|
||||
# 但为了单元测试的隔离性,或者确认它被调用了,可以验证副作用)
|
||||
# 这里直接验证副作用:elixirs 列表增加
|
||||
|
||||
# 模拟服用丹药的行为
|
||||
action._execute("聚气丹")
|
||||
|
||||
assert avatar_in_city.magic_stone == initial_money - expected_price
|
||||
# 背包里不应该有丹药
|
||||
assert len(avatar_in_city.items) == 0
|
||||
assert len(avatar_in_city.materials) == 0
|
||||
# 已服用列表应该有
|
||||
assert len(avatar_in_city.elixirs) == 1
|
||||
assert avatar_in_city.elixirs[0].elixir.name == "聚气丹"
|
||||
|
||||
def test_buy_fail_not_in_city(dummy_avatar, mock_objects):
|
||||
def test_buy_fail_not_in_city(dummy_avatar, mock_item_data):
|
||||
"""测试不在城市无法购买"""
|
||||
elixirs_mock, items_mock, _, _, _ = mock_objects
|
||||
elixirs_mock = mock_item_data["elixirs"]
|
||||
materials_mock = mock_item_data["materials"]
|
||||
|
||||
# 确保不在城市 (dummy_avatar 默认在 (0,0) PLAIN)
|
||||
assert not isinstance(dummy_avatar.tile.region, CityRegion)
|
||||
|
||||
with patch("src.utils.resolution.elixirs_by_name", elixirs_mock), \
|
||||
patch("src.utils.resolution.items_by_name", items_mock):
|
||||
patch("src.utils.resolution.materials_by_name", materials_mock):
|
||||
|
||||
action = Buy(dummy_avatar, dummy_avatar.world)
|
||||
can_start, reason = action.can_start("铁矿石")
|
||||
@@ -137,14 +76,15 @@ def test_buy_fail_not_in_city(dummy_avatar, mock_objects):
|
||||
assert can_start is False
|
||||
assert "仅能在城市" in reason
|
||||
|
||||
def test_buy_fail_no_money(avatar_in_city, mock_objects):
|
||||
def test_buy_fail_no_money(avatar_in_city, mock_item_data):
|
||||
"""测试没钱无法购买"""
|
||||
elixirs_mock, items_mock, _, _, test_item = mock_objects
|
||||
elixirs_mock = mock_item_data["elixirs"]
|
||||
materials_mock = mock_item_data["materials"]
|
||||
|
||||
avatar_in_city.magic_stone = 0 # 没钱
|
||||
|
||||
with patch("src.utils.resolution.elixirs_by_name", elixirs_mock), \
|
||||
patch("src.utils.resolution.items_by_name", items_mock):
|
||||
patch("src.utils.resolution.materials_by_name", materials_mock):
|
||||
|
||||
action = Buy(avatar_in_city, avatar_in_city.world)
|
||||
can_start, reason = action.can_start("铁矿石")
|
||||
@@ -152,12 +92,13 @@ def test_buy_fail_no_money(avatar_in_city, mock_objects):
|
||||
assert can_start is False
|
||||
assert "灵石不足" in reason
|
||||
|
||||
def test_buy_fail_unknown_item(avatar_in_city, mock_objects):
|
||||
def test_buy_fail_unknown_item(avatar_in_city, mock_item_data):
|
||||
"""测试未知物品"""
|
||||
elixirs_mock, items_mock, _, _, _ = mock_objects
|
||||
elixirs_mock = mock_item_data["elixirs"]
|
||||
materials_mock = mock_item_data["materials"]
|
||||
|
||||
with patch("src.utils.resolution.elixirs_by_name", elixirs_mock), \
|
||||
patch("src.utils.resolution.items_by_name", items_mock):
|
||||
patch("src.utils.resolution.materials_by_name", materials_mock):
|
||||
|
||||
action = Buy(avatar_in_city, avatar_in_city.world)
|
||||
can_start, reason = action.can_start("不存在的东西")
|
||||
@@ -165,43 +106,41 @@ def test_buy_fail_unknown_item(avatar_in_city, mock_objects):
|
||||
assert can_start is False
|
||||
assert "未知物品" in reason
|
||||
|
||||
def test_buy_elixir_fail_high_level_restricted(avatar_in_city, mock_objects):
|
||||
"""测试购买高阶丹药被限制"""
|
||||
elixirs_mock, items_mock, _, high_level_elixir, _ = mock_objects
|
||||
def test_buy_elixir_fail_high_level_restricted(avatar_in_city, mock_item_data):
|
||||
"""测试购买高阶丹药被限制"""
|
||||
elixirs_mock = mock_item_data["elixirs"]
|
||||
materials_mock = mock_item_data["materials"]
|
||||
high_level_elixir = mock_item_data["obj_high_elixir"]
|
||||
|
||||
# 给予足够金钱
|
||||
avatar_in_city.magic_stone = 10000
|
||||
|
||||
# 角色是练气期,尝试买筑基期丹药
|
||||
assert avatar_in_city.cultivation_progress.realm == Realm.Qi_Refinement
|
||||
assert high_level_elixir.realm == Realm.Foundation_Establishment
|
||||
|
||||
with patch("src.utils.resolution.elixirs_by_name", elixirs_mock), \
|
||||
patch("src.utils.resolution.materials_by_name", materials_mock):
|
||||
|
||||
# 给予足够金钱,避免因为钱不够而先报错
|
||||
avatar_in_city.magic_stone = 10000
|
||||
action = Buy(avatar_in_city, avatar_in_city.world)
|
||||
can_start, reason = action.can_start("筑基丹")
|
||||
|
||||
# 角色是练气期,尝试买筑基期丹药
|
||||
assert avatar_in_city.cultivation_progress.realm == Realm.Qi_Refinement
|
||||
assert high_level_elixir.realm == Realm.Foundation_Establishment
|
||||
|
||||
with patch("src.utils.resolution.elixirs_by_name", elixirs_mock), \
|
||||
patch("src.utils.resolution.items_by_name", items_mock):
|
||||
|
||||
action = Buy(avatar_in_city, avatar_in_city.world)
|
||||
can_start, reason = action.can_start("筑基丹")
|
||||
|
||||
assert can_start is False
|
||||
# 当前版本限制仅开放练气期丹药
|
||||
assert "当前仅开放练气期丹药购买" in reason
|
||||
assert can_start is False
|
||||
assert "当前仅开放练气期丹药购买" in reason
|
||||
|
||||
def test_buy_elixir_fail_duplicate_active(avatar_in_city, mock_objects):
|
||||
def test_buy_elixir_fail_duplicate_active(avatar_in_city, mock_item_data):
|
||||
"""测试药效尚存无法重复购买"""
|
||||
elixirs_mock, items_mock, test_elixir, _, _ = mock_objects
|
||||
elixirs_mock = mock_item_data["elixirs"]
|
||||
materials_mock = mock_item_data["materials"]
|
||||
test_elixir = mock_item_data["obj_elixir"]
|
||||
|
||||
# 先服用一个
|
||||
consumed = ConsumedElixir(test_elixir, int(avatar_in_city.world.month_stamp))
|
||||
# 假设它是持久效果或未过期
|
||||
# ConsumedElixir 计算过期时间依赖 effects,我们在 create_test_elixir 里如果不给 duration_month,默认是 inf 或者是 0 (Action里的逻辑是看 is_completely_expired)
|
||||
# 这里的 mock elixir 默认 effects 是 {"max_hp": 10},没有 duration_month,所以是永久效果?
|
||||
# 查阅 ConsumedElixir._get_max_duration: 如果没有 duration_month, return inf (永久)。
|
||||
# 所以这应该是永久生效的。
|
||||
|
||||
avatar_in_city.elixirs.append(consumed)
|
||||
|
||||
with patch("src.utils.resolution.elixirs_by_name", elixirs_mock), \
|
||||
patch("src.utils.resolution.items_by_name", items_mock):
|
||||
patch("src.utils.resolution.materials_by_name", materials_mock):
|
||||
|
||||
action = Buy(avatar_in_city, avatar_in_city.world)
|
||||
can_start, reason = action.can_start("聚气丹")
|
||||
@@ -209,4 +148,62 @@ def test_buy_elixir_fail_duplicate_active(avatar_in_city, mock_objects):
|
||||
assert can_start is False
|
||||
assert "药效尚存" in reason
|
||||
|
||||
|
||||
def test_buy_weapon_trade_in(avatar_in_city, mock_item_data):
|
||||
"""测试购买新武器时自动卖出旧武器"""
|
||||
# 这里需要构造一个旧武器,mock_item_data里只有一套新武器
|
||||
from tests.conftest import create_test_weapon
|
||||
from src.classes.weapon import Weapon, WeaponType
|
||||
|
||||
elixirs_mock = mock_item_data["elixirs"]
|
||||
materials_mock = mock_item_data["materials"]
|
||||
new_weapon = mock_item_data["obj_weapon"]
|
||||
|
||||
# 手动添加武器到 materials_mock (Buy logic looks up weapons in materials too? Or just assumes unique names?)
|
||||
# Buy code checks `get_item_by_name` which checks all dicts.
|
||||
# In test_buy_action we only mocked elixirs and materials.
|
||||
# Let's ensure '青云剑' is findable. Ideally it should be in weapons_by_name but maybe Buy logic is flexible?
|
||||
# Original test put it in materials_mock["青云剑"] = new_weapon. Let's follow that pattern for now or better: mock weapons too.
|
||||
|
||||
# Wait, original test: materials_mock["青云剑"] = new_weapon
|
||||
# But `src.utils.resolution.get_item_by_name` checks materials, weapons, auxiliaries.
|
||||
# Let's do it properly by mocking weapons_by_name as well if possible, or just stick to materials for simplicity if Buy allows.
|
||||
# Buy uses `get_item_by_name`.
|
||||
|
||||
materials_mock["青云剑"] = new_weapon
|
||||
|
||||
# 构造旧武器
|
||||
old_weapon = create_test_weapon("铁剑", Realm.Qi_Refinement, weapon_id=199)
|
||||
old_weapon.effects = {'atk': 1}
|
||||
|
||||
# 装备旧武器
|
||||
avatar_in_city.change_weapon(old_weapon)
|
||||
assert avatar_in_city.weapon == old_weapon
|
||||
|
||||
initial_money = avatar_in_city.magic_stone
|
||||
|
||||
# 价格计算
|
||||
# 练气期 Weapon Base Price = 150 (refer to src/classes/prices.py)
|
||||
# 买入: 150 * 1.5 = 225
|
||||
buy_cost = 225
|
||||
# 卖出: 150 * 1.0 = 150
|
||||
sell_refund = 150
|
||||
|
||||
expected_money = initial_money - buy_cost + sell_refund
|
||||
|
||||
with patch("src.utils.resolution.elixirs_by_name", elixirs_mock), \
|
||||
patch("src.utils.resolution.materials_by_name", materials_mock):
|
||||
|
||||
action = Buy(avatar_in_city, avatar_in_city.world)
|
||||
|
||||
# 验证 Event 描述
|
||||
event = action.start("青云剑")
|
||||
assert "青云剑" in event.content
|
||||
assert "铁剑" in event.content
|
||||
assert "折价售出" in event.content
|
||||
|
||||
# 执行购买
|
||||
action._execute("青云剑")
|
||||
|
||||
assert avatar_in_city.weapon.name == "青云剑"
|
||||
assert avatar_in_city.weapon != old_weapon
|
||||
assert avatar_in_city.magic_stone == expected_money
|
||||
|
||||
@@ -61,7 +61,7 @@ def test_circulation_manager_basic():
|
||||
|
||||
# Test adding Weapon
|
||||
w = create_mock_weapon(1, "Sword")
|
||||
# CirculationManager uses deepcopy, so we need to ensure the mock supports it or use real objects if possible.
|
||||
# CirculationManager uses instantiate, so we need to ensure the mock supports it
|
||||
# MagicMock is hard to deepcopy properly in some contexts, let's use a simple object structure or patch copy.deepcopy
|
||||
# But for robustness, let's try to make a real-ish object or a class that looks like Weapon
|
||||
|
||||
@@ -71,6 +71,9 @@ def test_circulation_manager_basic():
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.special_data = special_data or {}
|
||||
def instantiate(self):
|
||||
import copy
|
||||
return copy.deepcopy(self)
|
||||
|
||||
w1 = DummyItem(1, "Sword", {"kills": 10})
|
||||
cm.add_weapon(w1)
|
||||
@@ -100,6 +103,9 @@ def test_circulation_serialization():
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.special_data = {}
|
||||
def instantiate(self):
|
||||
import copy
|
||||
return copy.deepcopy(self)
|
||||
|
||||
w1 = DummyItem(101, "RareSword")
|
||||
w1.special_data = {"stat": 1}
|
||||
@@ -175,11 +181,12 @@ def test_avatar_sell_integration(empty_world):
|
||||
# 1. Test Sell Weapon
|
||||
# Create a dummy weapon that acts like the real one
|
||||
weapon = MagicMock(spec=Weapon)
|
||||
weapon.instantiate.return_value = weapon # Mock instantiate
|
||||
weapon.id = 999
|
||||
weapon.name = "TestBlade"
|
||||
weapon.realm = Realm.Qi_Refinement
|
||||
|
||||
# The mixin usually requires self.items to have the item for sell_item,
|
||||
# The mixin usually requires self.materials to have the material for sell_material,
|
||||
# but sell_weapon/sell_auxiliary are for equipped items or passed items.
|
||||
# Looking at inventory_mixin.py: sell_weapon(self, weapon) just calculates price and adds stones.
|
||||
# It calls _get_sell_multiplier()
|
||||
@@ -198,6 +205,7 @@ def test_avatar_sell_integration(empty_world):
|
||||
|
||||
# 2. Test Sell Auxiliary
|
||||
aux = MagicMock(spec=Auxiliary)
|
||||
aux.instantiate.return_value = aux # Mock instantiate
|
||||
aux.id = 888
|
||||
aux.name = "TestAmulet"
|
||||
|
||||
@@ -219,6 +227,9 @@ def test_save_load_circulation(temp_save_dir, empty_world):
|
||||
self.name = name
|
||||
self.special_data = {}
|
||||
self.realm = Realm.Qi_Refinement # needed if deepcopy looks at it or for other checks
|
||||
def instantiate(self):
|
||||
import copy
|
||||
return copy.deepcopy(self)
|
||||
|
||||
w1 = SimpleItem(10, "LostSword")
|
||||
w1.special_data = {"kills": 99}
|
||||
|
||||
@@ -109,3 +109,4 @@ def test_cp_serialization():
|
||||
assert cp_new.realm == Realm.Qi_Refinement
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -49,13 +49,12 @@ class TestEquipment:
|
||||
|
||||
w2 = get_random_weapon_by_realm(Realm.Qi_Refinement)
|
||||
# 即使随机到同一个原型,它们也应该是不同的对象
|
||||
# 注意:get_random_weapon_by_realm 内部已经做了 deepcopy
|
||||
# 注意:get_random_weapon_by_realm 内部已经做了 instantiate
|
||||
|
||||
# 为了确保测试有效,我们手动获取同一个原型并 deepcopy
|
||||
# 为了确保测试有效,我们手动获取同一个原型并 instantiate
|
||||
prototype = weapons_by_id[w1.id]
|
||||
import copy
|
||||
w_copy1 = copy.deepcopy(prototype)
|
||||
w_copy2 = copy.deepcopy(prototype)
|
||||
w_copy1 = prototype.instantiate()
|
||||
w_copy2 = prototype.instantiate()
|
||||
|
||||
assert w_copy1 is not w_copy2
|
||||
assert w_copy1.id == w_copy2.id
|
||||
|
||||
@@ -17,18 +17,18 @@ def mock_region(dummy_avatar):
|
||||
return real_region
|
||||
|
||||
@pytest.fixture
|
||||
def mock_resource_item():
|
||||
item = MagicMock()
|
||||
item.name = "TestItem"
|
||||
item.realm = Realm.Qi_Refinement
|
||||
return item
|
||||
def mock_resource_material():
|
||||
material = MagicMock()
|
||||
material.name = "TestMaterial"
|
||||
material.realm = Realm.Qi_Refinement
|
||||
return material
|
||||
|
||||
@pytest.fixture
|
||||
def mock_resource(mock_resource_item):
|
||||
def mock_resource(mock_resource_material):
|
||||
"""创建一个通用的资源对象 (Lode/Animal/Plant)"""
|
||||
res = MagicMock()
|
||||
res.realm = Realm.Qi_Refinement
|
||||
res.items = [mock_resource_item]
|
||||
res.materials = [mock_resource_material]
|
||||
return res
|
||||
|
||||
def test_check_can_start_gather_success(dummy_avatar, mock_region, mock_resource):
|
||||
@@ -66,22 +66,22 @@ def test_check_can_start_gather_realm_too_low(dummy_avatar, mock_region, mock_re
|
||||
assert can is False
|
||||
assert "当前区域的矿脉境界过高" in msg
|
||||
|
||||
def test_execute_gather_success(dummy_avatar, mock_region, mock_resource, mock_resource_item):
|
||||
def test_execute_gather_success(dummy_avatar, mock_region, mock_resource, mock_resource_material):
|
||||
"""测试执行采集逻辑成功"""
|
||||
mock_region.lodes = [mock_resource]
|
||||
|
||||
# 模拟 add_item
|
||||
dummy_avatar.add_item = MagicMock()
|
||||
# 模拟 add_material
|
||||
dummy_avatar.add_material = MagicMock()
|
||||
|
||||
result = execute_gather(dummy_avatar, "lodes", "extra_mine_items")
|
||||
result = execute_gather(dummy_avatar, "lodes", "extra_mine_materials")
|
||||
|
||||
assert "TestItem" in result
|
||||
assert result["TestItem"] >= 1
|
||||
dummy_avatar.add_item.assert_called_once()
|
||||
assert "TestMaterial" in result
|
||||
assert result["TestMaterial"] >= 1
|
||||
dummy_avatar.add_material.assert_called_once()
|
||||
|
||||
# 验证获得的物品是正确的
|
||||
args, _ = dummy_avatar.add_item.call_args
|
||||
assert args[0] == mock_resource_item
|
||||
# 验证获得的材料是正确的
|
||||
args, _ = dummy_avatar.add_material.call_args
|
||||
assert args[0] == mock_resource_material
|
||||
assert args[1] >= 1
|
||||
|
||||
def test_execute_gather_with_extra_effect(dummy_avatar, mock_region, mock_resource):
|
||||
@@ -90,28 +90,28 @@ def test_execute_gather_with_extra_effect(dummy_avatar, mock_region, mock_resour
|
||||
|
||||
# effects 是只读属性,它通过合并各个组件的 effects 来计算。
|
||||
# 为了测试,我们 Mock 掉 effects 属性。
|
||||
with patch.object(type(dummy_avatar), 'effects', new_callable=lambda: {"extra_mine_items": 2}):
|
||||
dummy_avatar.add_item = MagicMock()
|
||||
with patch.object(type(dummy_avatar), 'effects', new_callable=lambda: {"extra_mine_materials": 2}):
|
||||
dummy_avatar.add_material = MagicMock()
|
||||
|
||||
result = execute_gather(dummy_avatar, "lodes", "extra_mine_items")
|
||||
result = execute_gather(dummy_avatar, "lodes", "extra_mine_materials")
|
||||
|
||||
# 基础1 + 加成2 = 3
|
||||
assert result["TestItem"] == 3
|
||||
assert result["TestMaterial"] == 3
|
||||
|
||||
def test_execute_gather_random_selection(dummy_avatar, mock_region):
|
||||
"""测试从多个资源中随机选择"""
|
||||
res1 = MagicMock()
|
||||
res1.realm = Realm.Qi_Refinement
|
||||
res1.items = [MagicMock(name="Item1")]
|
||||
res1.items[0].name = "Item1"
|
||||
res1.materials = [MagicMock(name="Material1")]
|
||||
res1.materials[0].name = "Material1"
|
||||
|
||||
res2 = MagicMock()
|
||||
res2.realm = Realm.Qi_Refinement
|
||||
res2.items = [MagicMock(name="Item2")]
|
||||
res2.items[0].name = "Item2"
|
||||
res2.materials = [MagicMock(name="Material2")]
|
||||
res2.materials[0].name = "Material2"
|
||||
|
||||
mock_region.lodes = [res1, res2]
|
||||
dummy_avatar.add_item = MagicMock()
|
||||
dummy_avatar.add_material = MagicMock()
|
||||
|
||||
execute_gather(dummy_avatar, "lodes", "extra_mine_items")
|
||||
dummy_avatar.add_item.assert_called_once()
|
||||
execute_gather(dummy_avatar, "lodes", "extra_mine_materials")
|
||||
dummy_avatar.add_material.assert_called_once()
|
||||
|
||||
84
tests/test_new_avatar.py
Normal file
84
tests/test_new_avatar.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""测试 new_avatar 模块的角色创建逻辑."""
|
||||
import pytest
|
||||
from src.sim.new_avatar import make_avatars, AvatarFactory, PopulationPlanner
|
||||
from src.classes.age import Age
|
||||
from src.classes.cultivation import CultivationProgress
|
||||
|
||||
|
||||
class TestAgeLifespanConstraint:
|
||||
"""测试角色创建时年龄不超过寿命上限的约束."""
|
||||
|
||||
def test_batch_creation_age_within_lifespan(self, base_world):
|
||||
"""批量创建角色时,年龄应不超过境界寿命上限.
|
||||
|
||||
注意:只有当随机生成的年龄 >= 寿命上限时才会触发调整,
|
||||
调整后的年龄会在 80%-95% 区间内。正常随机生成的年龄
|
||||
(如 77 岁 / 80 寿命)不会被调整,所以可能接近但不超过上限。
|
||||
"""
|
||||
# 创建大量角色以增加覆盖率
|
||||
avatars = make_avatars(base_world, count=100)
|
||||
|
||||
for avatar in avatars.values():
|
||||
max_lifespan = Age.REALM_LIFESPAN.get(
|
||||
avatar.cultivation_progress.realm, 100
|
||||
)
|
||||
assert avatar.age.age < max_lifespan, (
|
||||
f"角色 {avatar.name} 年龄 {avatar.age.age} 超过了"
|
||||
f"境界 {avatar.cultivation_progress.realm} 的寿命上限 {max_lifespan}"
|
||||
)
|
||||
|
||||
def test_batch_creation_no_immediate_death(self, base_world):
|
||||
"""批量创建的角色不应该一出生就处于老死状态."""
|
||||
avatars = make_avatars(base_world, count=100)
|
||||
|
||||
for avatar in avatars.values():
|
||||
# 不应该有老死概率
|
||||
death_prob = avatar.age.get_death_probability()
|
||||
assert death_prob == 0.0, (
|
||||
f"角色 {avatar.name} 年龄 {avatar.age.age}/"
|
||||
f"{avatar.age.max_lifespan} 有老死概率 {death_prob}"
|
||||
)
|
||||
|
||||
def test_multiple_batch_creations_consistent(self, base_world):
|
||||
"""多次批量创建应该都满足年龄约束."""
|
||||
for _ in range(5):
|
||||
avatars = make_avatars(base_world, count=50)
|
||||
for avatar in avatars.values():
|
||||
max_lifespan = Age.REALM_LIFESPAN.get(
|
||||
avatar.cultivation_progress.realm, 100
|
||||
)
|
||||
assert avatar.age.age < max_lifespan
|
||||
|
||||
|
||||
class TestRealmLifespanMapping:
|
||||
"""测试各境界的寿命上限映射."""
|
||||
|
||||
def test_qi_refinement_lifespan(self, base_world):
|
||||
"""练气期角色年龄应不超过80岁."""
|
||||
from src.classes.cultivation import Realm
|
||||
|
||||
avatars = make_avatars(base_world, count=100)
|
||||
qi_refinement_avatars = [
|
||||
av for av in avatars.values()
|
||||
if av.cultivation_progress.realm == Realm.Qi_Refinement
|
||||
]
|
||||
|
||||
for avatar in qi_refinement_avatars:
|
||||
assert avatar.age.age < 80, (
|
||||
f"练气期角色 {avatar.name} 年龄 {avatar.age.age} 超过 80"
|
||||
)
|
||||
|
||||
def test_foundation_establishment_lifespan(self, base_world):
|
||||
"""筑基期角色年龄应不超过120岁."""
|
||||
from src.classes.cultivation import Realm
|
||||
|
||||
avatars = make_avatars(base_world, count=100)
|
||||
fe_avatars = [
|
||||
av for av in avatars.values()
|
||||
if av.cultivation_progress.realm == Realm.Foundation_Establishment
|
||||
]
|
||||
|
||||
for avatar in fe_avatars:
|
||||
assert avatar.age.age < 120, (
|
||||
f"筑基期角色 {avatar.name} 年龄 {avatar.age.age} 超过 120"
|
||||
)
|
||||
@@ -10,7 +10,7 @@ from src.utils.resolution import (
|
||||
resolve_query,
|
||||
ResolutionResult
|
||||
)
|
||||
from src.classes.item import Item
|
||||
from src.classes.material import Material
|
||||
from src.classes.cultivation import Realm
|
||||
|
||||
# ==================== Normalize Tests ====================
|
||||
@@ -75,14 +75,14 @@ def test_resolve_query_empty():
|
||||
def test_resolve_query_direct_object():
|
||||
"""测试直接传递对象"""
|
||||
# 1. 匹配类型
|
||||
item = Item(id=999, name="测试物品", desc="测试描述", realm=Realm.Qi_Refinement)
|
||||
res = resolve_query(item, expected_types=[Item])
|
||||
material = Material(id=999, name="测试材料", desc="测试描述", realm=Realm.Qi_Refinement)
|
||||
res = resolve_query(material, expected_types=[Material])
|
||||
assert res.is_valid
|
||||
assert res.obj is item
|
||||
assert res.resolved_type == Item
|
||||
assert res.obj is material
|
||||
assert res.resolved_type == Material
|
||||
|
||||
# 2. 不匹配类型但作为对象传入
|
||||
res = resolve_query(item, expected_types=[Realm])
|
||||
res = resolve_query(material, expected_types=[Realm])
|
||||
assert not res.is_valid
|
||||
|
||||
def test_resolve_query_realm():
|
||||
@@ -101,7 +101,7 @@ def test_resolve_query_realm():
|
||||
|
||||
def test_resolve_query_unsupported_type():
|
||||
"""测试不支持的类型输入"""
|
||||
res = resolve_query(123, expected_types=[Item])
|
||||
res = resolve_query(123, expected_types=[Material])
|
||||
assert not res.is_valid
|
||||
assert "非字符串" in res.error_msg
|
||||
|
||||
@@ -121,7 +121,7 @@ def test_resolve_region_mock(mock_world):
|
||||
# 或者我们只测试逻辑分支
|
||||
pass
|
||||
|
||||
# 由于 resolution.py 内部强依赖了实际的类 (Item, Region 等),
|
||||
# 由于 resolution.py 内部强依赖了实际的类 (Material, Region 等),
|
||||
# 且使用了 isinstance(t, type) 和 t.__name__ 判断,
|
||||
# 纯单元测试建议主要覆盖逻辑分支。集成测试覆盖实际类。
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
from src.classes.prices import prices, Prices
|
||||
from src.classes.cultivation import Realm
|
||||
from src.classes.item import items_by_id
|
||||
from src.classes.material import materials_by_id
|
||||
from src.classes.weapon import weapons_by_id, Weapon, get_random_weapon_by_realm
|
||||
from src.classes.auxiliary import auxiliaries_by_id, Auxiliary, get_random_auxiliary_by_realm
|
||||
|
||||
@@ -12,11 +12,11 @@ from src.classes.auxiliary import auxiliaries_by_id, Auxiliary, get_random_auxil
|
||||
class TestPrices:
|
||||
"""价格系统测试"""
|
||||
|
||||
def test_item_prices_by_realm(self):
|
||||
def test_material_prices_by_realm(self):
|
||||
"""测试材料价格按境界递增"""
|
||||
assert prices.ITEM_PRICES[Realm.Qi_Refinement] < prices.ITEM_PRICES[Realm.Foundation_Establishment]
|
||||
assert prices.ITEM_PRICES[Realm.Foundation_Establishment] < prices.ITEM_PRICES[Realm.Core_Formation]
|
||||
assert prices.ITEM_PRICES[Realm.Core_Formation] < prices.ITEM_PRICES[Realm.Nascent_Soul]
|
||||
assert prices.MATERIAL_PRICES[Realm.Qi_Refinement] < prices.MATERIAL_PRICES[Realm.Foundation_Establishment]
|
||||
assert prices.MATERIAL_PRICES[Realm.Foundation_Establishment] < prices.MATERIAL_PRICES[Realm.Core_Formation]
|
||||
assert prices.MATERIAL_PRICES[Realm.Core_Formation] < prices.MATERIAL_PRICES[Realm.Nascent_Soul]
|
||||
|
||||
def test_weapon_prices_by_realm(self):
|
||||
"""测试兵器价格按境界递增"""
|
||||
@@ -30,16 +30,16 @@ class TestPrices:
|
||||
assert prices.AUXILIARY_PRICES[Realm.Foundation_Establishment] < prices.AUXILIARY_PRICES[Realm.Core_Formation]
|
||||
assert prices.AUXILIARY_PRICES[Realm.Core_Formation] < prices.AUXILIARY_PRICES[Realm.Nascent_Soul]
|
||||
|
||||
def test_get_price_for_item(self):
|
||||
"""测试 get_price 对 Item 类型的分发"""
|
||||
if not items_by_id:
|
||||
def test_get_price_for_material(self):
|
||||
"""测试 get_price 对 Material 类型的分发"""
|
||||
if not materials_by_id:
|
||||
pytest.skip("No items available in config")
|
||||
|
||||
item = next(iter(items_by_id.values()))
|
||||
item = next(iter(materials_by_id.values()))
|
||||
price = prices.get_price(item)
|
||||
expected = prices.get_item_price(item)
|
||||
expected = prices.get_material_price(item)
|
||||
assert price == expected
|
||||
assert price == prices.ITEM_PRICES[item.realm]
|
||||
assert price == prices.MATERIAL_PRICES[item.realm]
|
||||
|
||||
def test_get_price_for_weapon(self):
|
||||
"""测试 get_price 对 Weapon 类型的分发"""
|
||||
@@ -63,54 +63,54 @@ class TestPrices:
|
||||
assert price == expected
|
||||
assert price == prices.AUXILIARY_PRICES[aux.realm]
|
||||
|
||||
def test_weapon_more_expensive_than_item(self):
|
||||
def test_weapon_more_expensive_than_material(self):
|
||||
"""测试同境界下兵器比材料贵"""
|
||||
for realm in Realm:
|
||||
if realm in prices.ITEM_PRICES and realm in prices.WEAPON_PRICES:
|
||||
assert prices.WEAPON_PRICES[realm] >= prices.ITEM_PRICES[realm]
|
||||
if realm in prices.MATERIAL_PRICES and realm in prices.WEAPON_PRICES:
|
||||
assert prices.WEAPON_PRICES[realm] >= prices.MATERIAL_PRICES[realm]
|
||||
|
||||
|
||||
class TestAvatarSell:
|
||||
"""Avatar 出售接口测试"""
|
||||
|
||||
def test_sell_item_basic(self, dummy_avatar):
|
||||
def test_sell_material_basic(self, dummy_avatar):
|
||||
"""测试基础材料出售"""
|
||||
if not items_by_id:
|
||||
if not materials_by_id:
|
||||
pytest.skip("No items available in config")
|
||||
|
||||
item = next(iter(items_by_id.values()))
|
||||
dummy_avatar.items = {} # 清空背包
|
||||
item = next(iter(materials_by_id.values()))
|
||||
dummy_avatar.materials = {} # 清空背包
|
||||
dummy_avatar.magic_stone.value = 0
|
||||
|
||||
# 添加物品
|
||||
dummy_avatar.add_item(item, 5)
|
||||
assert dummy_avatar.get_item_quantity(item) == 5
|
||||
dummy_avatar.add_material(item, 5)
|
||||
assert dummy_avatar.get_material_quantity(item) == 5
|
||||
|
||||
# 出售3个
|
||||
gained = dummy_avatar.sell_item(item, 3)
|
||||
gained = dummy_avatar.sell_material(item, 3)
|
||||
|
||||
expected_price = prices.get_item_price(item) * 3
|
||||
expected_price = prices.get_material_price(item) * 3
|
||||
assert gained == expected_price
|
||||
assert dummy_avatar.magic_stone.value == expected_price
|
||||
assert dummy_avatar.get_item_quantity(item) == 2
|
||||
assert dummy_avatar.get_material_quantity(item) == 2
|
||||
|
||||
def test_sell_item_insufficient(self, dummy_avatar):
|
||||
def test_sell_material_insufficient(self, dummy_avatar):
|
||||
"""测试出售物品数量不足"""
|
||||
if not items_by_id:
|
||||
if not materials_by_id:
|
||||
pytest.skip("No items available in config")
|
||||
|
||||
item = next(iter(items_by_id.values()))
|
||||
dummy_avatar.items = {}
|
||||
item = next(iter(materials_by_id.values()))
|
||||
dummy_avatar.materials = {}
|
||||
dummy_avatar.magic_stone.value = 100
|
||||
|
||||
dummy_avatar.add_item(item, 2)
|
||||
dummy_avatar.add_material(item, 2)
|
||||
|
||||
# 尝试出售5个(只有2个)
|
||||
gained = dummy_avatar.sell_item(item, 5)
|
||||
gained = dummy_avatar.sell_material(item, 5)
|
||||
|
||||
assert gained == 0
|
||||
assert dummy_avatar.magic_stone.value == 100 # 没有变化
|
||||
assert dummy_avatar.get_item_quantity(item) == 2 # 物品未减少
|
||||
assert dummy_avatar.get_material_quantity(item) == 2 # 物品未减少
|
||||
|
||||
def test_sell_weapon(self, dummy_avatar):
|
||||
"""测试出售兵器"""
|
||||
@@ -142,15 +142,15 @@ class TestAvatarSell:
|
||||
|
||||
def test_sell_with_price_multiplier(self, dummy_avatar):
|
||||
"""测试出售价格倍率效果"""
|
||||
if not items_by_id:
|
||||
if not materials_by_id:
|
||||
pytest.skip("No items available in config")
|
||||
|
||||
item = next(iter(items_by_id.values()))
|
||||
dummy_avatar.items = {}
|
||||
item = next(iter(materials_by_id.values()))
|
||||
dummy_avatar.materials = {}
|
||||
dummy_avatar.magic_stone.value = 0
|
||||
dummy_avatar.add_item(item, 1)
|
||||
dummy_avatar.add_material(item, 1)
|
||||
|
||||
base_price = prices.get_item_price(item)
|
||||
base_price = prices.get_material_price(item)
|
||||
|
||||
# 模拟 20% 加成 (0.2)
|
||||
# 这里的 effects 是 property,需要用 PropertyMock
|
||||
@@ -158,7 +158,7 @@ class TestAvatarSell:
|
||||
# 注意:由于 Avatar 分布在多个 mixin 中,patch 的位置取决于 effects 定义的位置
|
||||
# effects 定义在 EffectsMixin 中,但混入后是在 Avatar 类上
|
||||
# 如果 patch 比较麻烦,我们可以利用 Prices.get_selling_price 的逻辑
|
||||
# 这里我们其实也可以直接 patch get_selling_price 来验证 sell_item 是否使用了它
|
||||
# 这里我们其实也可以直接 patch get_selling_price 来验证 sell_material 是否使用了它
|
||||
# 但为了验证集成逻辑,我们尝试 patch effects
|
||||
pass
|
||||
|
||||
@@ -169,7 +169,7 @@ class TestAvatarSell:
|
||||
|
||||
expected_total = int(base_price * 1.2)
|
||||
with patch("src.classes.prices.prices.get_selling_price", return_value=expected_total) as mock_get_price:
|
||||
gained = dummy_avatar.sell_item(item, 1)
|
||||
gained = dummy_avatar.sell_material(item, 1)
|
||||
|
||||
# 验证调用参数
|
||||
mock_get_price.assert_called_with(item, dummy_avatar)
|
||||
|
||||
@@ -1,84 +1,20 @@
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from unittest.mock import patch
|
||||
from src.classes.action.sell import Sell
|
||||
from src.classes.region import CityRegion
|
||||
from src.classes.item import Item
|
||||
from src.classes.weapon import Weapon
|
||||
from src.classes.auxiliary import Auxiliary
|
||||
from src.classes.cultivation import Realm
|
||||
from src.classes.tile import Tile, TileType
|
||||
from src.classes.weapon_type import WeaponType
|
||||
from tests.conftest import create_test_material # Explicit import if needed
|
||||
|
||||
# 创建测试用的对象 helper
|
||||
def create_test_item(name, realm, item_id=101):
|
||||
return Item(
|
||||
id=item_id,
|
||||
name=name,
|
||||
desc="测试物品",
|
||||
realm=realm
|
||||
)
|
||||
|
||||
def create_test_weapon(name, realm, weapon_id=201):
|
||||
return Weapon(
|
||||
id=weapon_id,
|
||||
name=name,
|
||||
weapon_type=WeaponType.SWORD,
|
||||
realm=realm,
|
||||
desc="测试兵器",
|
||||
effects={},
|
||||
effect_desc=""
|
||||
)
|
||||
|
||||
def create_test_auxiliary(name, realm, aux_id=301):
|
||||
return Auxiliary(
|
||||
id=aux_id,
|
||||
name=name,
|
||||
realm=realm,
|
||||
desc="测试法宝",
|
||||
effects={},
|
||||
effect_desc=""
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def avatar_in_city(dummy_avatar):
|
||||
"""
|
||||
修改 dummy_avatar,使其位于城市中,并给予初始状态
|
||||
"""
|
||||
city_region = CityRegion(id=1, name="TestCity", desc="测试城市")
|
||||
tile = Tile(0, 0, TileType.CITY)
|
||||
tile.region = city_region
|
||||
def test_sell_material_success(avatar_in_city, mock_item_data):
|
||||
"""测试出售普通材料成功"""
|
||||
materials_mock = mock_item_data["materials"]
|
||||
weapons_mock = mock_item_data["weapons"]
|
||||
auxiliaries_mock = mock_item_data["auxiliaries"]
|
||||
test_material = mock_item_data["obj_material"]
|
||||
|
||||
dummy_avatar.tile = tile
|
||||
dummy_avatar.magic_stone = 0
|
||||
dummy_avatar.items = {}
|
||||
dummy_avatar.weapon = None
|
||||
dummy_avatar.auxiliary = None
|
||||
# 给角色添加材料
|
||||
avatar_in_city.add_material(test_material, quantity=5)
|
||||
|
||||
return dummy_avatar
|
||||
|
||||
@pytest.fixture
|
||||
def mock_sell_objects():
|
||||
"""
|
||||
Mock items_by_name/weapons/auxiliaries 并提供测试对象
|
||||
"""
|
||||
test_item = create_test_item("铁矿石", Realm.Qi_Refinement)
|
||||
test_weapon = create_test_weapon("青云剑", Realm.Qi_Refinement)
|
||||
test_auxiliary = create_test_auxiliary("聚灵珠", Realm.Qi_Refinement)
|
||||
|
||||
items_mock = {"铁矿石": test_item}
|
||||
weapons_mock = {"青云剑": test_weapon}
|
||||
auxiliaries_mock = {"聚灵珠": test_auxiliary}
|
||||
|
||||
return items_mock, weapons_mock, auxiliaries_mock, test_item, test_weapon, test_auxiliary
|
||||
|
||||
def test_sell_item_success(avatar_in_city, mock_sell_objects):
|
||||
"""测试出售普通物品成功"""
|
||||
items_mock, weapons_mock, auxiliaries_mock, test_item, _, _ = mock_sell_objects
|
||||
|
||||
# 给角色添加物品
|
||||
avatar_in_city.add_item(test_item, quantity=5)
|
||||
|
||||
with patch("src.utils.resolution.items_by_name", items_mock), \
|
||||
with patch("src.utils.resolution.materials_by_name", materials_mock), \
|
||||
patch("src.utils.resolution.weapons_by_name", weapons_mock), \
|
||||
patch("src.utils.resolution.auxiliaries_by_name", auxiliaries_mock):
|
||||
|
||||
@@ -89,7 +25,7 @@ def test_sell_item_success(avatar_in_city, mock_sell_objects):
|
||||
assert can_start is True
|
||||
|
||||
# 2. 执行出售
|
||||
# 练气期物品基础价格 10,卖出倍率默认为 1.0 -> 单价 10
|
||||
# 练气期材料基础价格 10,卖出倍率默认为 1.0 -> 单价 10
|
||||
# 卖出全部 5 个 -> 总价 50
|
||||
initial_money = avatar_in_city.magic_stone
|
||||
expected_income = 50
|
||||
@@ -98,16 +34,19 @@ def test_sell_item_success(avatar_in_city, mock_sell_objects):
|
||||
|
||||
# 3. 验证结果
|
||||
assert avatar_in_city.magic_stone == initial_money + expected_income
|
||||
assert avatar_in_city.get_item_quantity(test_item) == 0
|
||||
assert avatar_in_city.get_material_quantity(test_material) == 0
|
||||
|
||||
def test_sell_weapon_success(avatar_in_city, mock_sell_objects):
|
||||
def test_sell_weapon_success(avatar_in_city, mock_item_data):
|
||||
"""测试出售当前兵器成功"""
|
||||
items_mock, weapons_mock, auxiliaries_mock, _, test_weapon, _ = mock_sell_objects
|
||||
materials_mock = mock_item_data["materials"]
|
||||
weapons_mock = mock_item_data["weapons"]
|
||||
auxiliaries_mock = mock_item_data["auxiliaries"]
|
||||
test_weapon = mock_item_data["obj_weapon"]
|
||||
|
||||
# 装备兵器
|
||||
avatar_in_city.weapon = test_weapon
|
||||
|
||||
with patch("src.utils.resolution.items_by_name", items_mock), \
|
||||
with patch("src.utils.resolution.materials_by_name", materials_mock), \
|
||||
patch("src.utils.resolution.weapons_by_name", weapons_mock), \
|
||||
patch("src.utils.resolution.auxiliaries_by_name", auxiliaries_mock):
|
||||
|
||||
@@ -118,28 +57,28 @@ def test_sell_weapon_success(avatar_in_city, mock_sell_objects):
|
||||
assert can_start is True
|
||||
|
||||
# 2. 执行出售
|
||||
# 练气期兵器基础价格 100,卖出倍率 1.0 -> 100
|
||||
# 修正:根据之前的测试反馈,Prices中 Qi_Refinement 的兵器价格似乎也是 10 (默认值)。
|
||||
# 如果系统中没有正确加载 weapon.csv,价格可能就是默认值。
|
||||
# 我们这里假设它是 10 来通过测试,或者 mock prices (但这有点麻烦)。
|
||||
# 之前失败的日志里没有价格断言错误,只有 AttributeError。
|
||||
# 这里维持原来的 expected_income = 10,如果失败再调。
|
||||
expected_income = 10
|
||||
# 练气期兵器基础价格 150 (refer to src/classes/prices.py)
|
||||
# 卖出加成默认为 0.0 -> 单价 150
|
||||
expected_income = 150
|
||||
|
||||
initial_money = avatar_in_city.magic_stone
|
||||
action._execute("青云剑")
|
||||
|
||||
# 3. 验证结果
|
||||
assert avatar_in_city.magic_stone == expected_income
|
||||
assert avatar_in_city.magic_stone == initial_money + expected_income
|
||||
assert avatar_in_city.weapon is None
|
||||
|
||||
def test_sell_auxiliary_success(avatar_in_city, mock_sell_objects):
|
||||
def test_sell_auxiliary_success(avatar_in_city, mock_item_data):
|
||||
"""测试出售当前法宝成功"""
|
||||
items_mock, weapons_mock, auxiliaries_mock, _, _, test_auxiliary = mock_sell_objects
|
||||
materials_mock = mock_item_data["materials"]
|
||||
weapons_mock = mock_item_data["weapons"]
|
||||
auxiliaries_mock = mock_item_data["auxiliaries"]
|
||||
test_auxiliary = mock_item_data["obj_auxiliary"]
|
||||
|
||||
# 装备法宝
|
||||
avatar_in_city.auxiliary = test_auxiliary
|
||||
|
||||
with patch("src.utils.resolution.items_by_name", items_mock), \
|
||||
with patch("src.utils.resolution.materials_by_name", materials_mock), \
|
||||
patch("src.utils.resolution.weapons_by_name", weapons_mock), \
|
||||
patch("src.utils.resolution.auxiliaries_by_name", auxiliaries_mock):
|
||||
|
||||
@@ -148,22 +87,26 @@ def test_sell_auxiliary_success(avatar_in_city, mock_sell_objects):
|
||||
can_start, reason = action.can_start("聚灵珠")
|
||||
assert can_start is True
|
||||
|
||||
expected_income = 10
|
||||
# 练气期辅助装备基础价格 150
|
||||
expected_income = 150
|
||||
|
||||
action._execute("聚灵珠")
|
||||
|
||||
assert avatar_in_city.magic_stone == expected_income
|
||||
assert avatar_in_city.magic_stone == 1000 + expected_income
|
||||
assert avatar_in_city.auxiliary is None
|
||||
|
||||
def test_sell_fail_not_in_city(dummy_avatar, mock_sell_objects):
|
||||
def test_sell_fail_not_in_city(dummy_avatar, mock_item_data):
|
||||
"""测试不在城市无法出售"""
|
||||
items_mock, weapons_mock, auxiliaries_mock, test_item, _, _ = mock_sell_objects
|
||||
materials_mock = mock_item_data["materials"]
|
||||
weapons_mock = mock_item_data["weapons"]
|
||||
auxiliaries_mock = mock_item_data["auxiliaries"]
|
||||
test_material = mock_item_data["obj_material"]
|
||||
|
||||
# 确保不在城市
|
||||
assert not isinstance(dummy_avatar.tile.region, CityRegion)
|
||||
dummy_avatar.add_item(test_item, 1)
|
||||
dummy_avatar.add_material(test_material, 1)
|
||||
|
||||
with patch("src.utils.resolution.items_by_name", items_mock), \
|
||||
with patch("src.utils.resolution.materials_by_name", materials_mock), \
|
||||
patch("src.utils.resolution.weapons_by_name", weapons_mock), \
|
||||
patch("src.utils.resolution.auxiliaries_by_name", auxiliaries_mock):
|
||||
|
||||
@@ -173,13 +116,15 @@ def test_sell_fail_not_in_city(dummy_avatar, mock_sell_objects):
|
||||
assert can_start is False
|
||||
assert "仅能在城市" in reason
|
||||
|
||||
def test_sell_fail_no_item(avatar_in_city, mock_sell_objects):
|
||||
"""测试未持有该物品"""
|
||||
items_mock, weapons_mock, auxiliaries_mock, _, _, _ = mock_sell_objects
|
||||
def test_sell_fail_no_item(avatar_in_city, mock_item_data):
|
||||
"""测试未持有该材料"""
|
||||
materials_mock = mock_item_data["materials"]
|
||||
weapons_mock = mock_item_data["weapons"]
|
||||
auxiliaries_mock = mock_item_data["auxiliaries"]
|
||||
|
||||
# 背包为空,无装备
|
||||
# 背包为空,无装备 (fixture default)
|
||||
|
||||
with patch("src.utils.resolution.items_by_name", items_mock), \
|
||||
with patch("src.utils.resolution.materials_by_name", materials_mock), \
|
||||
patch("src.utils.resolution.weapons_by_name", weapons_mock), \
|
||||
patch("src.utils.resolution.auxiliaries_by_name", auxiliaries_mock):
|
||||
|
||||
@@ -187,13 +132,15 @@ def test_sell_fail_no_item(avatar_in_city, mock_sell_objects):
|
||||
can_start, reason = action.can_start("铁矿石")
|
||||
|
||||
assert can_start is False
|
||||
assert "未持有物品" in reason
|
||||
assert "未持有材料" in reason
|
||||
|
||||
def test_sell_fail_unknown_name(avatar_in_city, mock_sell_objects):
|
||||
def test_sell_fail_unknown_name(avatar_in_city, mock_item_data):
|
||||
"""测试未知物品名称"""
|
||||
items_mock, weapons_mock, auxiliaries_mock, _, _, _ = mock_sell_objects
|
||||
materials_mock = mock_item_data["materials"]
|
||||
weapons_mock = mock_item_data["weapons"]
|
||||
auxiliaries_mock = mock_item_data["auxiliaries"]
|
||||
|
||||
with patch("src.utils.resolution.items_by_name", items_mock), \
|
||||
with patch("src.utils.resolution.materials_by_name", materials_mock), \
|
||||
patch("src.utils.resolution.weapons_by_name", weapons_mock), \
|
||||
patch("src.utils.resolution.auxiliaries_by_name", auxiliaries_mock):
|
||||
|
||||
@@ -203,19 +150,26 @@ def test_sell_fail_unknown_name(avatar_in_city, mock_sell_objects):
|
||||
assert can_start is False
|
||||
assert "未持有物品/装备" in reason
|
||||
|
||||
def test_sell_priority(avatar_in_city, mock_sell_objects):
|
||||
"""测试物品优先级:同名时优先卖背包里的材料"""
|
||||
items_mock, weapons_mock, auxiliaries_mock, test_item, test_weapon, _ = mock_sell_objects
|
||||
def test_sell_priority(avatar_in_city, mock_item_data):
|
||||
"""测试优先级:同名时优先卖身上装备(根据 resolution 优先级)"""
|
||||
materials_mock = mock_item_data["materials"]
|
||||
weapons_mock = mock_item_data["weapons"]
|
||||
auxiliaries_mock = mock_item_data["auxiliaries"]
|
||||
test_weapon = mock_item_data["obj_weapon"]
|
||||
|
||||
# 构造一个同名的兵器和材料
|
||||
fake_sword_item = create_test_item("青云剑", Realm.Qi_Refinement)
|
||||
items_mock["青云剑"] = fake_sword_item
|
||||
# 构造一个同名的材料
|
||||
# 需要从 conftest 导入
|
||||
from src.classes.cultivation import Realm
|
||||
fake_sword_material = create_test_material("青云剑", Realm.Qi_Refinement)
|
||||
|
||||
# 修改 mock,让 "青云剑" 在 materials 里也能找到
|
||||
materials_mock["青云剑"] = fake_sword_material
|
||||
|
||||
# 角色同时拥有该材料和该兵器
|
||||
avatar_in_city.add_item(fake_sword_item, 1)
|
||||
avatar_in_city.add_material(fake_sword_material, 1)
|
||||
avatar_in_city.weapon = test_weapon # name也是 "青云剑"
|
||||
|
||||
with patch("src.utils.resolution.items_by_name", items_mock), \
|
||||
with patch("src.utils.resolution.materials_by_name", materials_mock), \
|
||||
patch("src.utils.resolution.weapons_by_name", weapons_mock), \
|
||||
patch("src.utils.resolution.auxiliaries_by_name", auxiliaries_mock):
|
||||
|
||||
@@ -224,69 +178,6 @@ def test_sell_priority(avatar_in_city, mock_sell_objects):
|
||||
# 执行出售
|
||||
action._execute("青云剑")
|
||||
|
||||
# 应该优先卖掉了材料
|
||||
# 注意:在新的 resolution 逻辑中,resolve_goods_by_name 的查找顺序是:
|
||||
# 1. Elixir
|
||||
# 2. Weapon
|
||||
# 3. Auxiliary
|
||||
# 4. Item
|
||||
# 所以如果在 mock 中都有 "青云剑",它会先被解析为 Weapon 类型。
|
||||
#
|
||||
# 然后 Sell._resolve_obj (现在内联在方法里) 逻辑:
|
||||
# obj, obj_type, _ = resolve_goods_by_name(target_name)
|
||||
#
|
||||
# 如果解析出来是 Weapon:
|
||||
# Sell logic: if obj_type == "weapon": check if self.avatar.weapon == normalized_name
|
||||
#
|
||||
# 所以如果名字相同,且 resolve 优先判定为 Weapon,那么代码会认为你想卖 Weapon。
|
||||
# 之前的逻辑:
|
||||
# 1. 检查背包材料 -> 有就卖
|
||||
# 2. 检查兵器
|
||||
#
|
||||
# 新的逻辑:
|
||||
# 1. resolve_goods_by_name -> 返回类型
|
||||
# 2. 根据类型检查
|
||||
#
|
||||
# 由于 resolution 中 Weapon 优先于 Item,所以 "青云剑" 会被解析为 Weapon。
|
||||
# 于是 Sell 动作会尝试卖身上的兵器。
|
||||
# 如果此时也正好装备了青云剑,就会卖掉兵器。
|
||||
#
|
||||
# 这意味着:新逻辑改变了优先级!
|
||||
# 之前是优先卖背包里的 Item(即使有同名的 Weapon 定义)。
|
||||
# 现在是看 resolution 认为它是什么。
|
||||
#
|
||||
# 如果我想保留"优先卖背包"的逻辑,我需要在 Sell 里特殊处理吗?
|
||||
# 或者接受这个变更。
|
||||
#
|
||||
# 假设"青云剑"既是 Weapon 又是 Item。
|
||||
# resolve_goods_by_name 会返回 Weapon。
|
||||
# Sell 拿到 Weapon 类型,检查 self.avatar.weapon。
|
||||
# -> 卖掉兵器。
|
||||
#
|
||||
# 如果我想测试"优先卖背包",这在当前新逻辑下可能不再成立,除非 Item 的查找优先级高于 Weapon。
|
||||
# 但通常 Item 优先级最低。
|
||||
#
|
||||
# 考虑到“青云剑”作为材料这种名字冲突本身就很罕见。
|
||||
# 我将修改测试预期:现在应该优先卖掉兵器(或者说,被识别为兵器)。
|
||||
|
||||
# 但是,如果我没有装备青云剑呢?
|
||||
# resolve 还是返回 Weapon。
|
||||
# Sell 检查 weapon -> 没装备 -> 报错 "未持有装备"。
|
||||
# 而背包里其实有 "青云剑" (Item)。
|
||||
# 这就是一个潜在的 Bug/Feature change。
|
||||
#
|
||||
# 如果用户输入 "青云剑",系统认为这是个 Weapon。用户没装备,系统提示"你没装备这个"。
|
||||
# 用户困惑:"但我背包里有一堆青云剑材料啊!"
|
||||
#
|
||||
# 为了解决这个问题,resolve_goods_by_name 可能需要更智能,或者 Sell 需要尝试多种可能。
|
||||
# 但目前的 resolve 是确定的。
|
||||
#
|
||||
# 也许我应该让 Item 的优先级高于 Weapon?
|
||||
# 不,通常名字是唯一的。
|
||||
#
|
||||
# 让我们先按新逻辑修正测试预期。
|
||||
# 如果 resolve 返回 Weapon,且角色装备了,就会卖掉装备。
|
||||
# 所以这里断言:兵器没了,材料还在。
|
||||
|
||||
# 断言:兵器没了,材料还在。
|
||||
assert avatar_in_city.weapon is None
|
||||
assert avatar_in_city.get_item_quantity(fake_sword_item) == 1
|
||||
assert avatar_in_city.get_material_quantity(fake_sword_material) == 1
|
||||
|
||||
124
tests/test_single_choice.py
Normal file
124
tests/test_single_choice.py
Normal file
@@ -0,0 +1,124 @@
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
from src.classes.single_choice import handle_item_exchange
|
||||
|
||||
# Mocks for types
|
||||
class MockAvatar:
|
||||
def __init__(self):
|
||||
self.name = "TestAvatar"
|
||||
self.weapon = None
|
||||
self.auxiliary = None
|
||||
self.technique = None
|
||||
self.world = Mock()
|
||||
self.world.static_info = {}
|
||||
self.change_weapon = Mock()
|
||||
self.sell_weapon = Mock(return_value=100)
|
||||
self.consume_elixir = Mock()
|
||||
self.sell_elixir = Mock(return_value=50)
|
||||
|
||||
def get_info(self, detailed=False):
|
||||
return {"name": self.name}
|
||||
|
||||
class MockItem:
|
||||
def __init__(self, name, item_type="weapon"):
|
||||
self.name = name
|
||||
self.item_type = item_type
|
||||
# Weapon/Auxiliary/Elixir usually have realm or grade
|
||||
self.realm = Mock()
|
||||
self.realm.value = "TestRealm"
|
||||
|
||||
def get_info(self, detailed=False):
|
||||
return f"Info({self.name})"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_weapon_auto_equip_no_sell_new():
|
||||
"""测试:自动装备兵器(无旧兵器,不可卖新)"""
|
||||
avatar = MockAvatar()
|
||||
new_weapon = MockItem("NewSword", "weapon")
|
||||
|
||||
# 模拟无旧兵器
|
||||
avatar.weapon = None
|
||||
|
||||
swapped, msg = await handle_item_exchange(
|
||||
avatar, new_weapon, "weapon", "Context", can_sell_new=False
|
||||
)
|
||||
|
||||
assert swapped is True
|
||||
assert "获得了TestRealm兵器『NewSword』并装备" in msg
|
||||
avatar.change_weapon.assert_called_once_with(new_weapon)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_weapon_swap_choice_A():
|
||||
"""测试:替换兵器,选择 A(装备新,卖旧)"""
|
||||
avatar = MockAvatar()
|
||||
old_weapon = MockItem("OldSword", "weapon")
|
||||
new_weapon = MockItem("NewSword", "weapon")
|
||||
avatar.weapon = old_weapon
|
||||
|
||||
# Mock decision to return 'A'
|
||||
with patch("src.classes.single_choice.make_decision", new_callable=AsyncMock) as mock_decision:
|
||||
mock_decision.return_value = "A"
|
||||
|
||||
swapped, msg = await handle_item_exchange(
|
||||
avatar, new_weapon, "weapon", "Context", can_sell_new=True
|
||||
)
|
||||
|
||||
# 验证文案包含动词
|
||||
# call_args[0][1] is context string, check options description
|
||||
call_args = mock_decision.call_args
|
||||
options = call_args[0][2] # options list
|
||||
opt_a_desc = options[0]["desc"]
|
||||
|
||||
# 验证选项文案使用了 "装备" 和 "卖掉"
|
||||
assert "装备新兵器『NewSword』" in opt_a_desc
|
||||
assert "卖掉旧兵器『OldSword』" in opt_a_desc
|
||||
|
||||
assert swapped is True
|
||||
assert "换上了TestRealm兵器『NewSword』" in msg
|
||||
avatar.sell_weapon.assert_called_once_with(old_weapon)
|
||||
avatar.change_weapon.assert_called_once_with(new_weapon)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_elixir_consume_choice_A():
|
||||
"""测试:获得丹药,选择 A(服用)"""
|
||||
avatar = MockAvatar()
|
||||
new_elixir = MockItem("PowerPill", "elixir")
|
||||
|
||||
# Mock decision to return 'A'
|
||||
with patch("src.classes.single_choice.make_decision", new_callable=AsyncMock) as mock_decision:
|
||||
mock_decision.return_value = "A"
|
||||
|
||||
swapped, msg = await handle_item_exchange(
|
||||
avatar, new_elixir, "elixir", "Context", can_sell_new=True
|
||||
)
|
||||
|
||||
# 验证选项文案
|
||||
options = mock_decision.call_args[0][2]
|
||||
opt_a_desc = options[0]["desc"]
|
||||
# 验证使用了 "服用"
|
||||
assert "服用新丹药『PowerPill』" in opt_a_desc
|
||||
|
||||
assert swapped is True
|
||||
# 验证结果使用了 "服用了"
|
||||
assert "服用了TestRealm丹药『PowerPill』" in msg
|
||||
avatar.consume_elixir.assert_called_once_with(new_elixir)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_elixir_sell_choice_B():
|
||||
"""测试:获得丹药,选择 B(卖出)"""
|
||||
avatar = MockAvatar()
|
||||
new_elixir = MockItem("PowerPill", "elixir")
|
||||
|
||||
# Mock decision to return 'B'
|
||||
with patch("src.classes.single_choice.make_decision", new_callable=AsyncMock) as mock_decision:
|
||||
mock_decision.return_value = "B"
|
||||
|
||||
swapped, msg = await handle_item_exchange(
|
||||
avatar, new_elixir, "elixir", "Context", can_sell_new=True
|
||||
)
|
||||
|
||||
assert swapped is False
|
||||
assert "卖掉了新获得的PowerPill" in msg
|
||||
avatar.sell_elixir.assert_called_once_with(new_elixir)
|
||||
avatar.consume_elixir.assert_not_called()
|
||||
|
||||
@@ -117,3 +117,4 @@ def test_df_get_list_int():
|
||||
assert get_list_int(row, "c", separator="|") == [1, 3]
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user