diff --git a/src/server/main.py b/src/server/main.py index 3c48284..ec572d9 100644 --- a/src/server/main.py +++ b/src/server/main.py @@ -7,6 +7,7 @@ import time import threading import signal import random +import re from omegaconf import OmegaConf from contextlib import asynccontextmanager @@ -1684,8 +1685,17 @@ async def save_llm_config(req: LLMConfigDTO): # --- 存档系统 API --- +def validate_save_name(name: str) -> bool: + """验证存档名称是否合法。""" + if not name or len(name) > 50: + return False + # 只允许中文、字母、数字和下划线。 + pattern = r'^[\w\u4e00-\u9fff]+$' + return bool(re.match(pattern, name)) + + class SaveGameRequest(BaseModel): - filename: Optional[str] = None + custom_name: Optional[str] = None # 自定义存档名称 class LoadGameRequest(BaseModel): filename: str @@ -1694,14 +1704,22 @@ class LoadGameRequest(BaseModel): def get_saves(): """获取存档列表""" saves_list = list_saves() - # 转换 Path 为 str,并整理格式 + # 转换 Path 为 str,并整理格式。 result = [] for path, meta in saves_list: result.append({ "filename": path.name, "save_time": meta.get("save_time", ""), "game_time": meta.get("game_time", ""), - "version": meta.get("version", "") + "version": meta.get("version", ""), + # 新增字段。 + "language": meta.get("language", ""), + "avatar_count": meta.get("avatar_count", 0), + "alive_count": meta.get("alive_count", 0), + "dead_count": meta.get("dead_count", 0), + "protagonist_name": meta.get("protagonist_name"), + "custom_name": meta.get("custom_name"), + "event_count": meta.get("event_count", 0), }) return {"saves": result} @@ -1713,15 +1731,22 @@ def api_save_game(req: SaveGameRequest): if not world or not sim: raise HTTPException(status_code=503, detail="Game not initialized") - # 尝试从 world 属性获取(如果以后添加了) + # 尝试从 world 属性获取(如果以后添加了)。 existed_sects = getattr(world, "existed_sects", []) if not existed_sects: - # fallback: 所有 sects + # fallback: 所有 sects. existed_sects = list(sects_by_id.values()) - # 使用当前存档路径(保持 SQLite 数据库关联) - current_save_path = game_instance.get("current_save_path") - success, filename = save_game(world, sim, existed_sects, save_path=current_save_path) + # 名称验证。 + custom_name = req.custom_name + if custom_name and not validate_save_name(custom_name): + raise HTTPException( + status_code=400, + detail="Invalid save name" + ) + + # 新存档(不使用 current_save_path,每次创建新文件)。 + success, filename = save_game(world, sim, existed_sects, custom_name=custom_name) if success: return {"status": "ok", "filename": filename} else: diff --git a/src/sim/save/save_game.py b/src/sim/save/save_game.py index e2fe0fc..9148495 100644 --- a/src/sim/save/save_game.py +++ b/src/sim/save/save_game.py @@ -25,6 +25,7 @@ - 事件实时写入SQLite,JSON中的events字段仅用于旧存档迁移 """ import json +import re from pathlib import Path from datetime import datetime from typing import List, Optional, TYPE_CHECKING @@ -38,22 +39,45 @@ from src.utils.config import CONFIG from src.classes.language import language_manager from src.sim.load.load_game import get_events_db_path +# 主角特质 ID: 穿越者=30, 气运之子=31. +PROTAGONIST_PERSONA_IDS = {30, 31} + + +def sanitize_save_name(name: str) -> str: + """清理存档名称,只保留安全字符。""" + # 移除文件系统不允许的字符。 + safe_name = re.sub(r'[\\/:*?"<>|]', '', name) + # 只保留中文、字母、数字和下划线。 + safe_name = re.sub(r'[^\w\u4e00-\u9fff]', '_', safe_name) + return safe_name[:50] if safe_name else "save" + + +def find_protagonist_name(world: "World") -> Optional[str]: + """查找主角名字(具有气运之子或穿越者特质的存活角色)。""" + for avatar in world.avatar_manager.avatars.values(): + persona_ids = [p.id for p in avatar.personas] if avatar.personas else [] + if any(pid in PROTAGONIST_PERSONA_IDS for pid in persona_ids): + return avatar.name + return None + def save_game( world: "World", simulator: "Simulator", existed_sects: List["Sect"], - save_path: Optional[Path] = None + save_path: Optional[Path] = None, + custom_name: Optional[str] = None ) -> tuple[bool, Optional[str]]: """ 保存游戏状态到文件 - + Args: world: 世界对象 simulator: 模拟器对象 existed_sects: 本局启用的宗门列表 save_path: 保存路径,默认为saves/时间戳_游戏时间.json - + custom_name: 用户自定义的存档名称 + Returns: (保存是否成功, 保存的文件名) """ @@ -62,15 +86,21 @@ def save_game( if save_path is None: saves_dir = CONFIG.paths.saves saves_dir.mkdir(parents=True, exist_ok=True) - - # 生成友好的文件名:20251111_193000_Y100M1.json + + # 生成友好的文件名。 now = datetime.now() time_str = now.strftime("%Y%m%d_%H%M%S") year = world.month_stamp.get_year() month = world.month_stamp.get_month().value game_time_str = f"Y{year}M{month}" - - filename = f"{time_str}_{game_time_str}.json" + + # 处理自定义名称。 + if custom_name: + safe_name = sanitize_save_name(custom_name) + filename = f"{safe_name}_{time_str}.json" + else: + filename = f"{time_str}_{game_time_str}.json" + save_path = saves_dir / filename else: save_path = Path(save_path) @@ -95,6 +125,12 @@ def save_game( else: print(f"警告: 当前事件数据库不存在: {current_db_path}") + # 计算角色统计。 + alive_count = len(world.avatar_manager.avatars) + dead_count = len(world.avatar_manager.dead_avatars) + total_count = alive_count + dead_count + protagonist_name = find_protagonist_name(world) + # 构建元信息 meta = { "version": CONFIG.meta.version, @@ -104,6 +140,12 @@ def save_game( # SQLite 事件数据库信息。 "events_db": str(events_db_path.name), "event_count": world.event_manager.count(), + # 新增元数据。 + "avatar_count": total_count, + "alive_count": alive_count, + "dead_count": dead_count, + "protagonist_name": protagonist_name, + "custom_name": custom_name, } # 构建世界数据 diff --git a/tests/test_save_custom_name.py b/tests/test_save_custom_name.py new file mode 100644 index 0000000..a60f0ca --- /dev/null +++ b/tests/test_save_custom_name.py @@ -0,0 +1,680 @@ +""" +Tests for custom save name and enhanced metadata features (Issue #95). +""" +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.classes.persona import personas_by_id +from src.sim.simulator import Simulator +from src.sim.save.save_game import ( + save_game, + sanitize_save_name, + find_protagonist_name, + PROTAGONIST_PERSONA_IDS, +) +from src.sim.load.load_game import load_game, get_events_db_path +from src.utils.id_generator import get_avatar_id + + +def create_test_map(): + """Create a simple test map.""" + m = Map(width=10, height=10) + for x in range(10): + for y in range(10): + 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 + + +# ============================================================================= +# Tests for sanitize_save_name function +# ============================================================================= + + +class TestSanitizeSaveName: + """Tests for the sanitize_save_name helper function.""" + + def test_chinese_characters_allowed(self): + """Test that Chinese characters are preserved.""" + result = sanitize_save_name("我的存档") + assert result == "我的存档" + + def test_english_characters_allowed(self): + """Test that English characters are preserved.""" + result = sanitize_save_name("MyFirstSave") + assert result == "MyFirstSave" + + def test_numbers_allowed(self): + """Test that numbers are preserved.""" + result = sanitize_save_name("Save123") + assert result == "Save123" + + def test_underscores_allowed(self): + """Test that underscores are preserved.""" + result = sanitize_save_name("my_save_file") + assert result == "my_save_file" + + def test_mixed_content(self): + """Test mixed Chinese, English, and numbers.""" + result = sanitize_save_name("我的Save存档_123") + assert result == "我的Save存档_123" + + def test_special_characters_replaced(self): + """Test that special characters are replaced with underscores.""" + result = sanitize_save_name("Save!@#$%^&*()") + assert "!" not in result + assert "@" not in result + assert result.replace("_", "").isalnum() or result == "Save__________" + + def test_path_separators_removed(self): + """Test that path separators are removed.""" + result = sanitize_save_name("path/to\\save") + assert "/" not in result + assert "\\" not in result + + def test_dangerous_chars_removed(self): + """Test that dangerous filesystem characters are removed.""" + result = sanitize_save_name('save:*?"<>|name') + assert ":" not in result + assert "*" not in result + assert "?" not in result + assert '"' not in result + assert "<" not in result + assert ">" not in result + assert "|" not in result + + def test_length_limit(self): + """Test that names are truncated to 50 characters.""" + long_name = "a" * 100 + result = sanitize_save_name(long_name) + assert len(result) <= 50 + + def test_empty_string_returns_default(self): + """Test that empty string returns 'save'.""" + result = sanitize_save_name("") + assert result == "save" + + def test_only_special_chars_returns_default(self): + """Test that a name with only special chars returns 'save'.""" + # After replacing all special chars with underscores, if nothing left, return 'save'. + # But underscores are kept, so "!!!" becomes "___" which is not empty. + result = sanitize_save_name("!!!") + # Should be "___" or similar, not "save". + assert len(result) > 0 + + def test_spaces_replaced(self): + """Test that spaces are replaced with underscores.""" + result = sanitize_save_name("my save file") + assert " " not in result + assert "_" in result + + +# ============================================================================= +# Tests for find_protagonist_name function +# ============================================================================= + + +class TestFindProtagonistName: + """Tests for the find_protagonist_name helper function.""" + + def test_no_protagonists(self, temp_save_dir): + """Test with no protagonist avatars.""" + game_map = create_test_map() + world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY)) + + # Add regular avatar without protagonist traits. + avatar = Avatar( + world=world, + name="RegularAvatar", + id=get_avatar_id(), + birth_month_stamp=create_month_stamp(Year(80), Month.JANUARY), + age=Age(20, Realm.Qi_Refinement), + gender=Gender.MALE, + ) + avatar.personas = [] + world.avatar_manager.avatars[avatar.id] = avatar + + result = find_protagonist_name(world) + assert result is None + + def test_finds_protagonist_with_trait_30(self, temp_save_dir): + """Test finding protagonist with trait ID 30 (穿越者).""" + game_map = create_test_map() + world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY)) + + # Add protagonist avatar with trait 30. + avatar = Avatar( + world=world, + name="穿越者主角", + id=get_avatar_id(), + birth_month_stamp=create_month_stamp(Year(80), Month.JANUARY), + age=Age(20, Realm.Qi_Refinement), + gender=Gender.MALE, + ) + # Mock persona with ID 30. + mock_persona = MagicMock() + mock_persona.id = 30 + avatar.personas = [mock_persona] + world.avatar_manager.avatars[avatar.id] = avatar + + result = find_protagonist_name(world) + assert result == "穿越者主角" + + def test_finds_protagonist_with_trait_31(self, temp_save_dir): + """Test finding protagonist with trait ID 31 (气运之子).""" + game_map = create_test_map() + world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY)) + + # Add protagonist avatar with trait 31. + avatar = Avatar( + world=world, + name="气运之子", + id=get_avatar_id(), + birth_month_stamp=create_month_stamp(Year(80), Month.JANUARY), + age=Age(20, Realm.Qi_Refinement), + gender=Gender.MALE, + ) + mock_persona = MagicMock() + mock_persona.id = 31 + avatar.personas = [mock_persona] + world.avatar_manager.avatars[avatar.id] = avatar + + result = find_protagonist_name(world) + assert result == "气运之子" + + def test_finds_first_protagonist_when_multiple(self, temp_save_dir): + """Test that it returns one protagonist when multiple exist.""" + game_map = create_test_map() + world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY)) + + # Add two protagonist avatars. + for i, name in enumerate(["主角一", "主角二"]): + avatar = Avatar( + world=world, + name=name, + id=get_avatar_id(), + birth_month_stamp=create_month_stamp(Year(80), Month.JANUARY), + age=Age(20, Realm.Qi_Refinement), + gender=Gender.MALE, + ) + mock_persona = MagicMock() + mock_persona.id = 30 + avatar.personas = [mock_persona] + world.avatar_manager.avatars[avatar.id] = avatar + + result = find_protagonist_name(world) + assert result in ["主角一", "主角二"] + + +# ============================================================================= +# Tests for save_game with custom name +# ============================================================================= + + +class TestSaveGameWithCustomName: + """Tests for save_game function with custom_name parameter.""" + + def test_save_with_custom_name(self, temp_save_dir): + """Test that custom name is used in filename.""" + game_map = create_test_map() + world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY)) + sim = Simulator(world) + + success, filename = save_game( + world, sim, [], + save_path=None, + custom_name="我的测试存档" + ) + + # Check save succeeded. + assert success + + # Filename should start with the sanitized custom name. + assert filename.startswith("我的测试存档_") + assert filename.endswith(".json") + + def test_save_without_custom_name(self, temp_save_dir): + """Test that default naming is used when no custom name provided.""" + game_map = create_test_map() + world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY)) + sim = Simulator(world) + + # Patch CONFIG.paths.saves to use temp dir. + with patch.object(__import__('src.utils.config', fromlist=['CONFIG']).CONFIG.paths, 'saves', temp_save_dir): + success, filename = save_game( + world, sim, [], + save_path=None, + custom_name=None + ) + + assert success + # Default filename format: YYYYMMDD_HHMMSS_Y{year}M{month}.json. + assert "_Y100M1.json" in filename or filename.endswith(".json") + + def test_custom_name_stored_in_meta(self, temp_save_dir): + """Test that custom_name is stored in save metadata.""" + game_map = create_test_map() + world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY)) + sim = Simulator(world) + + save_path = temp_save_dir / "test_custom_meta.json" + success, _ = save_game( + world, sim, [], + save_path=save_path, + custom_name="我的存档" + ) + + assert success + + # Read and verify meta. + with open(save_path, "r", encoding="utf-8") as f: + data = json.load(f) + + assert data["meta"]["custom_name"] == "我的存档" + + def test_null_custom_name_in_meta(self, temp_save_dir): + """Test that null custom_name is stored when not provided.""" + game_map = create_test_map() + world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY)) + sim = Simulator(world) + + save_path = temp_save_dir / "test_null_meta.json" + success, _ = save_game( + world, sim, [], + save_path=save_path, + custom_name=None + ) + + assert success + + with open(save_path, "r", encoding="utf-8") as f: + data = json.load(f) + + assert data["meta"]["custom_name"] is None + + +# ============================================================================= +# Tests for enhanced metadata +# ============================================================================= + + +class TestEnhancedMetadata: + """Tests for enhanced save metadata (avatar counts, protagonist).""" + + def test_avatar_counts_in_meta(self, temp_save_dir): + """Test that avatar counts are correctly stored.""" + game_map = create_test_map() + world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY)) + + # Add living avatars. + for i in range(5): + avatar = Avatar( + world=world, + name=f"Avatar{i}", + id=get_avatar_id(), + birth_month_stamp=create_month_stamp(Year(80), Month.JANUARY), + age=Age(20, Realm.Qi_Refinement), + gender=Gender.MALE, + ) + world.avatar_manager.avatars[avatar.id] = avatar + + # Add dead avatars. + for i in range(3): + avatar = Avatar( + world=world, + name=f"DeadAvatar{i}", + id=get_avatar_id(), + birth_month_stamp=create_month_stamp(Year(80), Month.JANUARY), + age=Age(20, Realm.Qi_Refinement), + gender=Gender.MALE, + ) + world.avatar_manager.dead_avatars[avatar.id] = avatar + + sim = Simulator(world) + save_path = temp_save_dir / "test_counts.json" + success, _ = save_game(world, sim, [], save_path) + + assert success + + with open(save_path, "r", encoding="utf-8") as f: + data = json.load(f) + + meta = data["meta"] + assert meta["alive_count"] == 5 + assert meta["dead_count"] == 3 + assert meta["avatar_count"] == 8 # total + + def test_protagonist_name_in_meta(self, temp_save_dir): + """Test that protagonist name is stored in metadata.""" + game_map = create_test_map() + world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY)) + + # Add protagonist avatar. + avatar = Avatar( + world=world, + name="林动", + id=get_avatar_id(), + birth_month_stamp=create_month_stamp(Year(80), Month.JANUARY), + age=Age(20, Realm.Qi_Refinement), + gender=Gender.MALE, + ) + mock_persona = MagicMock() + mock_persona.id = 31 # 气运之子 + avatar.personas = [mock_persona] + world.avatar_manager.avatars[avatar.id] = avatar + + sim = Simulator(world) + save_path = temp_save_dir / "test_protagonist.json" + success, _ = save_game(world, sim, [], save_path) + + assert success + + with open(save_path, "r", encoding="utf-8") as f: + data = json.load(f) + + assert data["meta"]["protagonist_name"] == "林动" + + def test_no_protagonist_in_meta(self, temp_save_dir): + """Test that protagonist_name is None when no protagonist exists.""" + game_map = create_test_map() + world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY)) + + # Add regular avatar. + avatar = Avatar( + world=world, + name="普通人", + id=get_avatar_id(), + birth_month_stamp=create_month_stamp(Year(80), Month.JANUARY), + age=Age(20, Realm.Qi_Refinement), + gender=Gender.MALE, + ) + avatar.personas = [] + world.avatar_manager.avatars[avatar.id] = avatar + + sim = Simulator(world) + save_path = temp_save_dir / "test_no_prot.json" + success, _ = save_game(world, sim, [], save_path) + + assert success + + with open(save_path, "r", encoding="utf-8") as f: + data = json.load(f) + + assert data["meta"]["protagonist_name"] is None + + +# ============================================================================= +# Tests for API endpoints +# ============================================================================= + + +class TestSaveApiWithCustomName: + """Tests for /api/game/save endpoint with custom name.""" + + def test_api_save_with_custom_name(self, temp_save_dir): + """Test API save endpoint with custom name.""" + from fastapi.testclient import TestClient + from src.server import main + from src.utils.config import CONFIG + + # Setup game instance. + game_map = create_test_map() + world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY)) + sim = Simulator(world) + + original_state = main.game_instance.copy() + main.game_instance["world"] = world + main.game_instance["sim"] = sim + + with patch.object(CONFIG.paths, "saves", temp_save_dir): + client = TestClient(main.app) + response = client.post( + "/api/game/save", + json={"custom_name": "我的API存档"} + ) + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "ok" + assert "我的API存档" in data["filename"] + + # Cleanup. + main.game_instance.update(original_state) + + def test_api_save_without_custom_name(self, temp_save_dir): + """Test API save endpoint without custom name.""" + from fastapi.testclient import TestClient + from src.server import main + from src.utils.config import CONFIG + + game_map = create_test_map() + world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY)) + sim = Simulator(world) + + original_state = main.game_instance.copy() + main.game_instance["world"] = world + main.game_instance["sim"] = sim + + with patch.object(CONFIG.paths, "saves", temp_save_dir): + client = TestClient(main.app) + response = client.post( + "/api/game/save", + json={} + ) + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "ok" + assert data["filename"].endswith(".json") + + main.game_instance.update(original_state) + + def test_api_save_invalid_name_rejected(self, temp_save_dir): + """Test that invalid save names are rejected.""" + from fastapi.testclient import TestClient + from src.server import main + from src.utils.config import CONFIG + + game_map = create_test_map() + world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY)) + sim = Simulator(world) + + original_state = main.game_instance.copy() + main.game_instance["world"] = world + main.game_instance["sim"] = sim + + with patch.object(CONFIG.paths, "saves", temp_save_dir): + client = TestClient(main.app) + # Name with only special characters - should be rejected. + response = client.post( + "/api/game/save", + json={"custom_name": "!!!@@@###"} + ) + + # Should be rejected with 400. + assert response.status_code == 400 + + main.game_instance.update(original_state) + + def test_api_save_name_too_long_rejected(self, temp_save_dir): + """Test that names over 50 chars are rejected.""" + from fastapi.testclient import TestClient + from src.server import main + from src.utils.config import CONFIG + + game_map = create_test_map() + world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY)) + sim = Simulator(world) + + original_state = main.game_instance.copy() + main.game_instance["world"] = world + main.game_instance["sim"] = sim + + with patch.object(CONFIG.paths, "saves", temp_save_dir): + client = TestClient(main.app) + long_name = "a" * 51 + response = client.post( + "/api/game/save", + json={"custom_name": long_name} + ) + + assert response.status_code == 400 + + main.game_instance.update(original_state) + + +class TestSavesListApiWithMetadata: + """Tests for /api/saves endpoint returning enhanced metadata.""" + + def test_api_saves_returns_new_fields(self, temp_save_dir): + """Test that /api/saves returns new metadata fields.""" + from fastapi.testclient import TestClient + from src.server import main + from src.utils.config import CONFIG + + # Create a save file with metadata. + game_map = create_test_map() + world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY)) + + # Add avatars. + for i in range(3): + avatar = Avatar( + world=world, + name=f"Avatar{i}", + id=get_avatar_id(), + birth_month_stamp=create_month_stamp(Year(80), Month.JANUARY), + age=Age(20, Realm.Qi_Refinement), + gender=Gender.MALE, + ) + world.avatar_manager.avatars[avatar.id] = avatar + + sim = Simulator(world) + save_path = temp_save_dir / "test_list.json" + save_game(world, sim, [], save_path, custom_name="列表测试") + + with patch.object(CONFIG.paths, "saves", temp_save_dir): + client = TestClient(main.app) + response = client.get("/api/saves") + + assert response.status_code == 200 + data = response.json() + + assert len(data["saves"]) >= 1 + save_item = data["saves"][0] + + # Verify new fields are present. + assert "avatar_count" in save_item + assert "alive_count" in save_item + assert "dead_count" in save_item + assert "protagonist_name" in save_item + assert "custom_name" in save_item + assert "event_count" in save_item + assert "language" in save_item + + # Verify values. + assert save_item["custom_name"] == "列表测试" + assert save_item["alive_count"] == 3 + assert save_item["avatar_count"] == 3 + + def test_api_saves_old_save_compatibility(self, temp_save_dir): + """Test that old saves without new fields return defaults.""" + from fastapi.testclient import TestClient + from src.server import main + from src.utils.config import CONFIG + + # Create a "legacy" save file without new metadata fields. + legacy_save = { + "meta": { + "version": "1.0", + "save_time": "2026-01-01T12:00:00", + "game_time": "100年1月", + }, + "world": {"month_stamp": 1200}, + "avatars": [], + "events": [], + "simulator": {}, + } + + save_path = temp_save_dir / "legacy.json" + with open(save_path, "w", encoding="utf-8") as f: + json.dump(legacy_save, f) + + with patch.object(CONFIG.paths, "saves", temp_save_dir): + client = TestClient(main.app) + response = client.get("/api/saves") + + assert response.status_code == 200 + data = response.json() + + # Find our legacy save. + legacy_item = None + for save in data["saves"]: + if save["filename"] == "legacy.json": + legacy_item = save + break + + assert legacy_item is not None + + # New fields should have default values. + assert legacy_item["avatar_count"] == 0 + assert legacy_item["alive_count"] == 0 + assert legacy_item["dead_count"] == 0 + assert legacy_item["protagonist_name"] is None + assert legacy_item["custom_name"] is None + assert legacy_item["event_count"] == 0 + + +# ============================================================================= +# Tests for validate_save_name function +# ============================================================================= + + +class TestValidateSaveName: + """Tests for the validate_save_name function in main.py.""" + + def test_valid_chinese_name(self): + from src.server.main import validate_save_name + assert validate_save_name("我的存档") is True + + def test_valid_english_name(self): + from src.server.main import validate_save_name + assert validate_save_name("MySave") is True + + def test_valid_mixed_name(self): + from src.server.main import validate_save_name + assert validate_save_name("我的Save_123") is True + + def test_empty_name_invalid(self): + from src.server.main import validate_save_name + assert validate_save_name("") is False + + def test_too_long_name_invalid(self): + from src.server.main import validate_save_name + assert validate_save_name("a" * 51) is False + + def test_special_chars_invalid(self): + from src.server.main import validate_save_name + assert validate_save_name("save!@#") is False + + def test_space_invalid(self): + from src.server.main import validate_save_name + assert validate_save_name("my save") is False + + def test_exactly_50_chars_valid(self): + from src.server.main import validate_save_name + assert validate_save_name("a" * 50) is True diff --git a/web/src/__tests__/components/panels/SaveLoadPanel.test.ts b/web/src/__tests__/components/panels/SaveLoadPanel.test.ts new file mode 100644 index 0000000..2acc5c3 --- /dev/null +++ b/web/src/__tests__/components/panels/SaveLoadPanel.test.ts @@ -0,0 +1,494 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mount } from '@vue/test-utils' +import { createI18n } from 'vue-i18n' +import SaveLoadPanel from '@/components/game/panels/system/SaveLoadPanel.vue' +import type { SaveFileDTO } from '@/types/api' + +// Use real timers for this test file since we need async operations. +beforeEach(() => { + vi.useRealTimers() +}) + +afterEach(() => { + vi.useFakeTimers() +}) + +// Mock naive-ui components. +vi.mock('naive-ui', () => ({ + NModal: { + name: 'NModal', + template: '
', + props: ['show', 'title', 'preset', 'maskClosable', 'closable'], + }, + NInput: { + name: 'NInput', + template: '', + props: ['value', 'placeholder', 'status', 'disabled'], + emits: ['update:value'], + }, + NButton: { + name: 'NButton', + template: '', + props: ['type', 'loading', 'disabled'], + emits: ['click'], + }, + NSpin: { + name: 'NSpin', + template: '
', + props: ['size'], + }, + NTooltip: { + name: 'NTooltip', + template: '
', + props: ['trigger'], + }, + useMessage: () => ({ + success: vi.fn(), + error: vi.fn(), + }), +})) + +// Mock stores. +vi.mock('@/stores/world', () => ({ + useWorldStore: () => ({ + reset: vi.fn(), + initialize: vi.fn().mockResolvedValue(undefined), + }), +})) + +vi.mock('@/stores/ui', () => ({ + useUiStore: () => ({ + clearSelection: vi.fn(), + }), +})) + +// Mock API. +vi.mock('@/api', () => ({ + systemApi: { + fetchSaves: vi.fn(), + saveGame: vi.fn(), + loadGame: vi.fn(), + }, +})) + +import { systemApi } from '@/api' + +// Create i18n instance for tests. +const i18n = createI18n({ + legacy: false, + locale: 'en-US', + messages: { + 'en-US': { + save_load: { + loading: 'Loading...', + new_save: 'New Save', + new_save_desc: 'Save with custom name', + quick_save: 'Quick Save', + quick_save_desc: 'Use auto-generated name', + empty: 'No saves found', + game_time: 'Game Time: {time}', + avatar_count: 'Characters: {alive}/{total}', + event_count: '{count} events', + protagonist_tooltip: 'Protagonist', + load: 'Load', + save_success: 'Saved: {filename}', + save_failed: 'Save failed', + load_confirm: 'Load {filename}?', + load_success: 'Loaded', + load_failed: 'Load failed', + fetch_failed: 'Fetch failed', + save_modal_title: 'Save Game', + save_confirm: 'Save', + name_hint: 'Enter save name (optional)', + name_placeholder: 'Enter name...', + name_tip: 'Leave empty for auto name', + name_too_long: 'Name too long', + name_invalid_chars: 'Invalid characters', + }, + common: { + cancel: 'Cancel', + }, + }, + }, +}) + +const createMockSave = (overrides: Partial = {}): SaveFileDTO => ({ + filename: 'test_save.json', + save_time: '2026-01-01T12:00:00', + game_time: '100年1月', + version: '1.0.0', + language: 'zh-CN', + avatar_count: 10, + alive_count: 8, + dead_count: 2, + protagonist_name: null, + custom_name: null, + event_count: 50, + ...overrides, +}) + +// Helper to wait for promises. +const flushPromises = () => new Promise(resolve => setTimeout(resolve, 0)) + +describe('SaveLoadPanel', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(window, 'confirm').mockReturnValue(true) + }) + + describe('Save Mode', () => { + it('should render save actions in save mode', async () => { + vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: [] }) + + const wrapper = mount(SaveLoadPanel, { + props: { mode: 'save' }, + global: { plugins: [i18n] }, + }) + + await flushPromises() + + expect(wrapper.find('.new-save-card').exists()).toBe(true) + expect(wrapper.find('.quick-save-card').exists()).toBe(true) + expect(wrapper.text()).toContain('New Save') + expect(wrapper.text()).toContain('Quick Save') + }) + + it('should open save modal when clicking new save', async () => { + vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: [] }) + + const wrapper = mount(SaveLoadPanel, { + props: { mode: 'save' }, + global: { plugins: [i18n] }, + }) + + await flushPromises() + await wrapper.find('.new-save-card').trigger('click') + await flushPromises() + + expect(wrapper.find('.n-modal').exists()).toBe(true) + }) + + it('should call saveGame without name on quick save', async () => { + vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: [] }) + vi.mocked(systemApi.saveGame).mockResolvedValue({ status: 'ok', filename: 'auto_save.json' }) + + const wrapper = mount(SaveLoadPanel, { + props: { mode: 'save' }, + global: { plugins: [i18n] }, + }) + + await flushPromises() + await wrapper.find('.quick-save-card').trigger('click') + await flushPromises() + + expect(systemApi.saveGame).toHaveBeenCalled() + expect(systemApi.saveGame).toHaveBeenCalledWith() + }) + }) + + describe('Load Mode', () => { + it('should render save list in load mode', async () => { + const mockSaves = [ + createMockSave({ filename: 'save1.json', custom_name: '我的存档' }), + createMockSave({ filename: 'save2.json', game_time: '200年6月' }), + ] + vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: mockSaves }) + + const wrapper = mount(SaveLoadPanel, { + props: { mode: 'load' }, + global: { plugins: [i18n] }, + }) + + await flushPromises() + + expect(wrapper.findAll('.save-item')).toHaveLength(2) + expect(wrapper.text()).toContain('我的存档') + expect(wrapper.text()).toContain('Load') + }) + + it('should not render save actions in load mode', async () => { + vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: [] }) + + const wrapper = mount(SaveLoadPanel, { + props: { mode: 'load' }, + global: { plugins: [i18n] }, + }) + + await flushPromises() + + expect(wrapper.find('.new-save-card').exists()).toBe(false) + expect(wrapper.find('.quick-save-card').exists()).toBe(false) + }) + + it('should call loadGame when clicking save item', async () => { + const mockSaves = [createMockSave({ filename: 'test.json' })] + vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: mockSaves }) + vi.mocked(systemApi.loadGame).mockResolvedValue({ status: 'ok', message: 'loaded' }) + + const wrapper = mount(SaveLoadPanel, { + props: { mode: 'load' }, + global: { plugins: [i18n] }, + }) + + await flushPromises() + await wrapper.find('.save-item').trigger('click') + await flushPromises() + + expect(systemApi.loadGame).toHaveBeenCalledWith('test.json') + }) + + it('should not load if user cancels confirm', async () => { + vi.spyOn(window, 'confirm').mockReturnValue(false) + const mockSaves = [createMockSave({ filename: 'test.json' })] + vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: mockSaves }) + + const wrapper = mount(SaveLoadPanel, { + props: { mode: 'load' }, + global: { plugins: [i18n] }, + }) + + await flushPromises() + await wrapper.find('.save-item').trigger('click') + await flushPromises() + + expect(systemApi.loadGame).not.toHaveBeenCalled() + }) + }) + + describe('Save Display', () => { + it('should display custom name when available', async () => { + const mockSaves = [createMockSave({ custom_name: '自定义名称', filename: 'test.json' })] + vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: mockSaves }) + + const wrapper = mount(SaveLoadPanel, { + props: { mode: 'load' }, + global: { plugins: [i18n] }, + }) + + await flushPromises() + + expect(wrapper.find('.save-name').text()).toBe('自定义名称') + }) + + it('should display filename when no custom name', async () => { + const mockSaves = [createMockSave({ custom_name: null, filename: '20260101_120000.json' })] + vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: mockSaves }) + + const wrapper = mount(SaveLoadPanel, { + props: { mode: 'load' }, + global: { plugins: [i18n] }, + }) + + await flushPromises() + + expect(wrapper.find('.save-name').text()).toBe('20260101_120000') + }) + + it('should display protagonist badge when protagonist exists', async () => { + const mockSaves = [createMockSave({ protagonist_name: '林动' })] + vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: mockSaves }) + + const wrapper = mount(SaveLoadPanel, { + props: { mode: 'load' }, + global: { plugins: [i18n] }, + }) + + await flushPromises() + + expect(wrapper.find('.protagonist-badge').exists()).toBe(true) + expect(wrapper.text()).toContain('林动') + }) + + it('should not display protagonist badge when no protagonist', async () => { + const mockSaves = [createMockSave({ protagonist_name: null })] + vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: mockSaves }) + + const wrapper = mount(SaveLoadPanel, { + props: { mode: 'load' }, + global: { plugins: [i18n] }, + }) + + await flushPromises() + + expect(wrapper.find('.protagonist-badge').exists()).toBe(false) + }) + + it('should display avatar counts', async () => { + const mockSaves = [createMockSave({ alive_count: 15, avatar_count: 20 })] + vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: mockSaves }) + + const wrapper = mount(SaveLoadPanel, { + props: { mode: 'load' }, + global: { plugins: [i18n] }, + }) + + await flushPromises() + + expect(wrapper.find('.avatar-count').text()).toContain('15') + expect(wrapper.find('.avatar-count').text()).toContain('20') + }) + + it('should display event count', async () => { + const mockSaves = [createMockSave({ event_count: 100 })] + vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: mockSaves }) + + const wrapper = mount(SaveLoadPanel, { + props: { mode: 'load' }, + global: { plugins: [i18n] }, + }) + + await flushPromises() + + expect(wrapper.find('.event-count').text()).toContain('100') + }) + }) + + describe('Name Validation', () => { + it('should show error for name over 50 chars', async () => { + vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: [] }) + + const wrapper = mount(SaveLoadPanel, { + props: { mode: 'save' }, + global: { plugins: [i18n] }, + }) + + await flushPromises() + await wrapper.find('.new-save-card').trigger('click') + await flushPromises() + + const input = wrapper.find('.n-input') + await input.setValue('a'.repeat(51)) + await flushPromises() + + expect(wrapper.find('.error-text').exists()).toBe(true) + expect(wrapper.text()).toContain('Name too long') + }) + + it('should show error for invalid characters', async () => { + vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: [] }) + + const wrapper = mount(SaveLoadPanel, { + props: { mode: 'save' }, + global: { plugins: [i18n] }, + }) + + await flushPromises() + await wrapper.find('.new-save-card').trigger('click') + await flushPromises() + + const input = wrapper.find('.n-input') + await input.setValue('name!@#$') + await flushPromises() + + expect(wrapper.find('.error-text').exists()).toBe(true) + expect(wrapper.text()).toContain('Invalid characters') + }) + + it('should allow valid Chinese name', async () => { + vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: [] }) + + const wrapper = mount(SaveLoadPanel, { + props: { mode: 'save' }, + global: { plugins: [i18n] }, + }) + + await flushPromises() + await wrapper.find('.new-save-card').trigger('click') + await flushPromises() + + const input = wrapper.find('.n-input') + await input.setValue('我的存档') + await flushPromises() + + expect(wrapper.find('.error-text').exists()).toBe(false) + }) + + it('should allow empty name', async () => { + vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: [] }) + + const wrapper = mount(SaveLoadPanel, { + props: { mode: 'save' }, + global: { plugins: [i18n] }, + }) + + await flushPromises() + await wrapper.find('.new-save-card').trigger('click') + await flushPromises() + + const input = wrapper.find('.n-input') + await input.setValue('') + await flushPromises() + + expect(wrapper.find('.error-text').exists()).toBe(false) + expect(wrapper.find('.tip-text').exists()).toBe(true) + }) + }) + + describe('Empty State', () => { + it('should show empty message when no saves', async () => { + vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: [] }) + + const wrapper = mount(SaveLoadPanel, { + props: { mode: 'load' }, + global: { plugins: [i18n] }, + }) + + await flushPromises() + + expect(wrapper.find('.empty').exists()).toBe(true) + expect(wrapper.text()).toContain('No saves found') + }) + }) + + describe('Error Handling', () => { + it('should handle fetchSaves error gracefully', async () => { + vi.mocked(systemApi.fetchSaves).mockRejectedValue(new Error('Network error')) + + const wrapper = mount(SaveLoadPanel, { + props: { mode: 'load' }, + global: { plugins: [i18n] }, + }) + + await flushPromises() + + // Should not crash, saves should be empty. + expect(wrapper.findAll('.save-item')).toHaveLength(0) + }) + + it('should handle saveGame error gracefully', async () => { + vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: [] }) + vi.mocked(systemApi.saveGame).mockRejectedValue(new Error('Save error')) + + const wrapper = mount(SaveLoadPanel, { + props: { mode: 'save' }, + global: { plugins: [i18n] }, + }) + + await flushPromises() + await wrapper.find('.quick-save-card').trigger('click') + await flushPromises() + + // Should not crash. + expect(wrapper.exists()).toBe(true) + }) + }) + + describe('Mode Switching', () => { + it('should refetch saves when mode changes', async () => { + vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: [] }) + + const wrapper = mount(SaveLoadPanel, { + props: { mode: 'save' }, + global: { plugins: [i18n] }, + }) + + await flushPromises() + expect(systemApi.fetchSaves).toHaveBeenCalledTimes(1) + + await wrapper.setProps({ mode: 'load' }) + await flushPromises() + + expect(systemApi.fetchSaves).toHaveBeenCalledTimes(2) + }) + }) +}) diff --git a/web/src/api/modules/system.ts b/web/src/api/modules/system.ts index 2882731..222fcd5 100644 --- a/web/src/api/modules/system.ts +++ b/web/src/api/modules/system.ts @@ -19,8 +19,11 @@ export const systemApi = { return httpClient.get<{ saves: SaveFileDTO[] }>('/api/saves'); }, - saveGame(filename?: string) { - return httpClient.post<{ status: string; filename: string }>('/api/game/save', { filename }); + saveGame(customName?: string) { + return httpClient.post<{ status: string; filename: string }>( + '/api/game/save', + { custom_name: customName } + ); }, loadGame(filename: string) { diff --git a/web/src/components/game/panels/system/SaveLoadPanel.vue b/web/src/components/game/panels/system/SaveLoadPanel.vue index 36c5603..a3c615b 100644 --- a/web/src/components/game/panels/system/SaveLoadPanel.vue +++ b/web/src/components/game/panels/system/SaveLoadPanel.vue @@ -1,5 +1,6 @@