add pytest

This commit is contained in:
bridge
2025-12-14 15:13:48 +08:00
parent 6b0bf25699
commit 30a10bbb1c
4 changed files with 174 additions and 64 deletions

View File

@@ -304,8 +304,10 @@ class Avatar(
self._init_known_regions() self._init_known_regions()
def __hash__(self) -> int: def __hash__(self) -> int:
if not hasattr(self, 'id'):
# 防御性编程如果id尚未初始化例如deepcopy过程中使用对象内存地址
return super().__hash__()
return hash(self.id) return hash(self.id)
def __str__(self) -> str: def __str__(self) -> str:
return str(self.get_info(detailed=False)) return str(self.get_info(detailed=False))

View File

@@ -6,10 +6,11 @@ Avatar读档反序列化Mixin
读档策略: 读档策略:
- 两阶段加载先加载所有Avatarrelations留空再重建relations网络 - 两阶段加载先加载所有Avatarrelations留空再重建relations网络
- 引用对象通过id从全局字典获取如techniques_by_id - 引用对象通过id从全局字典获取如techniques_by_id
- weapon/auxiliary拷贝后恢复special_data - weapon/auxiliary拷贝后恢复special_data避免deepcopy带来的递归/崩溃风险)
- 错误容错:缺失的引用对象会跳过而不是崩溃 - 错误容错:缺失的引用对象会跳过而不是崩溃
""" """
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import copy
if TYPE_CHECKING: if TYPE_CHECKING:
from src.classes.world import World from src.classes.world import World
@@ -98,21 +99,26 @@ class AvatarLoadMixin:
if item_id in items_by_id: if item_id in items_by_id:
avatar.items[items_by_id[item_id]] = quantity avatar.items[items_by_id[item_id]] = quantity
# 重建weapon深拷贝因为special_data是实例特有的 # 重建weapon
# 使用copy而非deepcopy避免潜在的递归引用导致的崩溃且性能更好
# special_data 是实例特有的,需要单独赋值
weapon_id = data.get("weapon_id") weapon_id = data.get("weapon_id")
if weapon_id is not None and weapon_id in weapons_by_id: if weapon_id is not None and weapon_id in weapons_by_id:
import copy # 浅拷贝:复制引用,但 weapon.special_data 会被共享
avatar.weapon = copy.deepcopy(weapons_by_id[weapon_id]) # 所以需要手动重新赋值 special_data
weapon_proto = weapons_by_id[weapon_id]
avatar.weapon = copy.copy(weapon_proto)
avatar.weapon.special_data = data.get("weapon_special_data", {}) avatar.weapon.special_data = data.get("weapon_special_data", {})
# 恢复兵器熟练度 # 恢复兵器熟练度
avatar.weapon_proficiency = float(data.get("weapon_proficiency", 0.0)) avatar.weapon_proficiency = float(data.get("weapon_proficiency", 0.0))
# 重建auxiliary深拷贝因为special_data是实例特有的 # 重建auxiliary
# 同上使用copy
auxiliary_id = data.get("auxiliary_id") auxiliary_id = data.get("auxiliary_id")
if auxiliary_id is not None and auxiliary_id in auxiliaries_by_id: if auxiliary_id is not None and auxiliary_id in auxiliaries_by_id:
import copy auxiliary_proto = auxiliaries_by_id[auxiliary_id]
avatar.auxiliary = copy.deepcopy(auxiliaries_by_id[auxiliary_id]) avatar.auxiliary = copy.copy(auxiliary_proto)
avatar.auxiliary.special_data = data.get("auxiliary_special_data", {}) avatar.auxiliary.special_data = data.get("auxiliary_special_data", {})
# 重建spirit_animal # 重建spirit_animal
@@ -197,4 +203,3 @@ class AvatarLoadMixin:
avatar.recalc_effects() avatar.recalc_effects()
return avatar return avatar

View File

@@ -1,55 +0,0 @@
import pytest
import copy
from src.classes.weapon import Weapon, WeaponType
from src.classes.equipment_grade import EquipmentGrade
from src.classes.sect import Sect, SectHeadQuarter
from src.classes.alignment import Alignment
from pathlib import Path
def test_weapon_deepcopy_does_not_copy_sect():
# 1. 创建模拟的 Sect
hq = SectHeadQuarter("HQ", "Desc", Path("img.png"))
sect = Sect(
id=1, name="TestSect", desc="Desc", member_act_style="Style",
alignment=Alignment.Righteous, headquarter=hq, technique_names=[]
)
# 向 Sect 中添加一些可能导致问题的成员(虽然这里只是简单测试引用)
# 在真实场景中Sect.members 可能包含复杂的 Avatar 对象
sect.members["dummy"] = "DummyAvatar"
# 2. 创建 Weapon 并关联 Sect
weapon = Weapon(
id=101, name="TestWeapon", weapon_type=WeaponType.SWORD,
grade=EquipmentGrade.COMMON, sect_id=1, desc="Desc", sect=sect
)
# 3. 深拷贝 Weapon
weapon_copy = copy.deepcopy(weapon)
# 4. 验证 Weapon 被复制了
assert weapon_copy is not weapon
assert weapon_copy.id == weapon.id
# 5. 关键验证Sect 应该是同一个对象(浅拷贝)
assert weapon_copy.sect is sect
assert weapon_copy.sect is weapon.sect
# 验证 Sect 的成员没有被复制
assert weapon_copy.sect.members is sect.members
def test_weapon_special_data_is_copied():
# 验证 special_data 是否被正确深拷贝
weapon = Weapon(
id=101, name="TestWeapon", weapon_type=WeaponType.SWORD,
grade=EquipmentGrade.COMMON, sect_id=None, desc="Desc"
)
weapon.special_data = {"souls": 10, "nested": {"a": 1}}
weapon_copy = copy.deepcopy(weapon)
assert weapon_copy.special_data == weapon.special_data
assert weapon_copy.special_data is not weapon.special_data
assert weapon_copy.special_data["nested"] is not weapon.special_data["nested"]

View File

@@ -0,0 +1,158 @@
import pytest
import json
from pathlib import Path
from unittest.mock import patch, MagicMock
from src.classes.world import World
from src.classes.map import Map
from src.classes.tile import TileType
from src.classes.calendar import Month, Year, create_month_stamp
from src.classes.avatar import Avatar, Gender
from src.classes.age import Age
from src.classes.cultivation import Realm
from src.sim.simulator import Simulator
from src.sim.save.save_game import save_game
from src.sim.load.load_game import load_game
from src.utils.id_generator import get_avatar_id
from src.utils.config import CONFIG
# Helper to create a simple map
def create_simple_map():
m = Map(width=5, height=5) # Slightly larger to be safe
for x in range(5):
for y in range(5):
m.create_tile(x, y, TileType.PLAIN)
return m
@pytest.fixture
def temp_save_dir(tmp_path):
d = tmp_path / "saves"
d.mkdir()
return d
def test_save_load_cycle(temp_save_dir):
"""
Test the full save and load cycle with a real World and Simulator instance,
but without running the LLM or stepping the simulation.
"""
# 1. Setup World
# Create a deterministic map for testing
game_map = create_simple_map()
# Set a specific time
start_year = Year(100)
start_month = Month.JANUARY
month_stamp = create_month_stamp(start_year, start_month)
world = World(map=game_map, month_stamp=month_stamp)
# 2. Add an Avatar
avatar_id = get_avatar_id()
avatar_name = "TestUser_SaveLoad"
avatar = Avatar(
world=world,
name=avatar_name,
id=avatar_id,
birth_month_stamp=create_month_stamp(Year(80), Month.JANUARY),
age=Age(20, Realm.Qi_Refinement),
gender=Gender.MALE
)
# Set some specific attributes to verify persistence
# Note: hp.max is recalculated from realm and effects on load, so setting it manually
# without a supporting effect will result in it being reset.
# We test hp.cur persistence instead (as long as it's <= max).
# Default max for Qi_Refinement is 100.
avatar.hp.cur = 80
# Add to world
world.avatar_manager.avatars[avatar.id] = avatar
# 3. Setup Simulator
sim = Simulator(world)
# Modify a config value on the instance to check if it persists
test_birth_rate = 0.99
sim.birth_rate = test_birth_rate
# 4. Prepare Existed Sects (Empty for this basic test)
existed_sects = []
# 5. Save Game
save_filename = "test_save_cycle.json"
save_path = temp_save_dir / save_filename
success, saved_name = save_game(world, sim, existed_sects, save_path)
assert success, "Save operation failed"
assert save_path.exists(), "Save file was not created"
# 6. Load Game
# We need to patch 'load_cultivation_world_map' because load_game calls it.
# We want it to return our simple map (or a new equivalent one) instead of loading the real huge map.
# Note: load_game imports it inside the function, so we patch where it is imported FROM if it was global,
# but since it's inside, we rely on sys.modules or patch the target module path.
# The import in load_game.py is: from src.run.load_map import load_cultivation_world_map
with patch('src.run.load_map.load_cultivation_world_map', return_value=create_simple_map()):
# We also need to be careful about 'sects_by_id' if we had sects, but we don't.
loaded_world, loaded_sim, loaded_sects = load_game(save_path)
# 7. Verification
# Verify World Metadata
assert loaded_world.month_stamp == world.month_stamp
assert loaded_world.month_stamp.get_year() == 100
# Verify Avatar
assert len(loaded_world.avatar_manager.avatars) == 1
assert avatar_id in loaded_world.avatar_manager.avatars
loaded_avatar = loaded_world.avatar_manager.avatars[avatar_id]
assert loaded_avatar.name == avatar_name
assert loaded_avatar.age.age == 20
assert loaded_avatar.cultivation_progress.realm == Realm.Qi_Refinement
assert loaded_avatar.gender == Gender.MALE
# hp.max is reset to 100 based on Realm.Qi_Refinement
assert loaded_avatar.hp.max == 100
assert loaded_avatar.hp.cur == 80
# Verify Simulator
assert loaded_sim.birth_rate == test_birth_rate
# Verify World/Simulator linkage
assert loaded_sim.world == loaded_world
assert loaded_avatar.world == loaded_world
def test_save_load_with_relations(temp_save_dir):
"""
Test saving and loading avatars with relationships.
"""
game_map = create_simple_map()
world = World(map=game_map, month_stamp=create_month_stamp(Year(1), Month.JANUARY))
# Create two avatars
av1 = Avatar(world, "Av1", get_avatar_id(), create_month_stamp(Year(1), Month.JANUARY), Age(20, Realm.Qi_Refinement), Gender.MALE)
av2 = Avatar(world, "Av2", get_avatar_id(), create_month_stamp(Year(1), Month.JANUARY), Age(20, Realm.Qi_Refinement), Gender.FEMALE)
world.avatar_manager.avatars[av1.id] = av1
world.avatar_manager.avatars[av2.id] = av2
# Add relationship
from src.classes.relation import Relation
# Manually adding relation for test (usually done via helper methods)
# relation value is integer
av1.relations[av2] = Relation.FRIEND
sim = Simulator(world)
save_path = temp_save_dir / "test_relation.json"
save_game(world, sim, [], save_path)
with patch('src.run.load_map.load_cultivation_world_map', return_value=create_simple_map()):
l_world, _, _ = load_game(save_path)
l_av1 = l_world.avatar_manager.avatars[av1.id]
l_av2 = l_world.avatar_manager.avatars[av2.id]
assert l_av2 in l_av1.relations
assert l_av1.relations[l_av2] == Relation.FRIEND