Merge branch 'main' into xzhseh/sqlite-event-manager

This commit is contained in:
Zihao Xu
2026-01-07 20:05:02 -08:00
committed by GitHub
74 changed files with 1754 additions and 806 deletions

97
tests/README.md Normal file
View 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 了相关模块。

View File

@@ -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
}

View File

@@ -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)

View 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

View File

@@ -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

View File

@@ -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}

View File

@@ -109,3 +109,4 @@ def test_cp_serialization():
assert cp_new.realm == Realm.Qi_Refinement

View File

@@ -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

View File

@@ -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
View 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"
)

View File

@@ -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__ 判断,
# 纯单元测试建议主要覆盖逻辑分支。集成测试覆盖实际类。

View File

@@ -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)

View File

@@ -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
View 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()

View File

@@ -117,3 +117,4 @@ def test_df_get_list_int():
assert get_list_int(row, "c", separator="|") == [1, 3]