From 38327c1b104d5bad13b535d2ea42701ca2241e06 Mon Sep 17 00:00:00 2001 From: Zihao Xu Date: Wed, 21 Jan 2026 00:03:42 -0800 Subject: [PATCH] test: add game initialization integration tests (#82) Add 17 tests covering the complete init_game_async flow: - Phase progress updates (4 tests) - Full initialization success (2 tests) - History processing (2 tests) - LLM check failure handling (1 test) - Avatar generation (2 tests) - Error handling (3 tests) - Sect initialization (1 test) - Phase name constants (2 tests) Closes #72 --- tests/test_game_init_integration.py | 507 ++++++++++++++++++++++++++++ 1 file changed, 507 insertions(+) create mode 100644 tests/test_game_init_integration.py diff --git a/tests/test_game_init_integration.py b/tests/test_game_init_integration.py new file mode 100644 index 0000000..3d25cf4 --- /dev/null +++ b/tests/test_game_init_integration.py @@ -0,0 +1,507 @@ +""" +Integration tests for game initialization flow. + +## What's Tested + +Tests the complete game initialization flow (init_game_async): +- Phase 0: Asset scanning +- Phase 1: Map loading +- Phase 2: History processing +- Phase 3: Sect initialization +- Phase 4: Avatar generation (protagonists + NPCs) +- Phase 5: LLM connectivity check +- Phase 6: Initial event generation + +## Why Integration Tests Matter + +Unit tests verify individual components work in isolation. +Integration tests verify the components work together correctly: +- World is created with correct initial state +- Avatars are generated and registered properly +- LLM check results are recorded +- Initial events are generated +- Game is paused after initialization + +## What's NOT Tested Here + +- Actual LLM API calls (mocked) +- Actual file I/O for map loading (mocked) +- WebSocket broadcasting during game loop +""" + +import pytest +import asyncio +import tempfile +from pathlib import Path +from unittest.mock import patch, MagicMock, AsyncMock + +from src.server import main +from src.server.main import ( + app, + game_instance, + init_game_async, + update_init_progress, + INIT_PHASE_NAMES, +) + + +@pytest.fixture +def reset_game_instance(): + """Reset game_instance to initial state before each test.""" + original_state = dict(game_instance) + game_instance.clear() + game_instance.update({ + "world": None, + "sim": None, + "is_paused": True, + "init_status": "idle", + "init_phase": 0, + "init_phase_name": "", + "init_progress": 0, + "init_start_time": None, + "init_error": None, + "llm_check_failed": False, + "llm_error_message": "", + "current_save_path": None, + }) + yield + game_instance.clear() + game_instance.update(original_state) + + +@pytest.fixture +def temp_saves_dir(): + """Create a temporary saves directory.""" + with tempfile.TemporaryDirectory() as tmpdir: + yield Path(tmpdir) + + +class TestUpdateInitProgress: + """Tests for update_init_progress function.""" + + def test_update_progress_sets_phase(self, reset_game_instance): + """Test that update_init_progress sets phase correctly.""" + update_init_progress(3, "initializing_sects") + + assert game_instance["init_phase"] == 3 + assert game_instance["init_phase_name"] == "initializing_sects" + + def test_update_progress_uses_default_phase_name(self, reset_game_instance): + """Test that default phase name is used when not provided.""" + update_init_progress(4) + + assert game_instance["init_phase"] == 4 + assert game_instance["init_phase_name"] == INIT_PHASE_NAMES[4] + + def test_update_progress_calculates_percentage(self, reset_game_instance): + """Test that progress percentage is calculated correctly.""" + progress_map = {0: 0, 1: 10, 2: 25, 3: 40, 4: 55, 5: 70, 6: 85} + + for phase, expected_progress in progress_map.items(): + update_init_progress(phase) + assert game_instance["init_progress"] == expected_progress + + def test_all_phase_names_defined(self): + """Test that all phases have names defined.""" + for phase in range(7): + assert phase in INIT_PHASE_NAMES + + +class TestInitGameAsyncSuccess: + """Tests for successful game initialization.""" + + @pytest.mark.asyncio + async def test_full_init_success(self, reset_game_instance, temp_saves_dir, mock_llm_managers): + """Test complete initialization flow succeeds.""" + mock_map = MagicMock() + mock_map.width = 100 + mock_map.height = 100 + + mock_world = MagicMock() + mock_world.avatar_manager.avatars = {} + mock_world.month_stamp = MagicMock() + + mock_sim = MagicMock() + mock_sim.step = AsyncMock() + + with patch.object(main, "reload_all_static_data"), \ + patch.object(main, "scan_avatar_assets"), \ + patch.object(main, "load_cultivation_world_map", return_value=mock_map), \ + patch.object(main, "check_llm_connectivity", return_value=(True, "")), \ + patch("src.server.main.World") as mock_world_class, \ + patch("src.server.main.Simulator", return_value=mock_sim), \ + patch("src.server.main.CONFIG") as mock_config, \ + patch("src.server.main.sects_by_id", {"sect1": MagicMock()}): + + mock_config.paths.saves = temp_saves_dir + mock_config.game.sect_num = 1 + mock_config.game.init_npc_num = 5 + mock_config.game.world_history = "" + mock_config.avatar.protagonist = "none" + + mock_world_class.create_with_db.return_value = mock_world + + await init_game_async() + + # Verify final state. + assert game_instance["init_status"] == "ready" + assert game_instance["init_progress"] == 100 + assert game_instance["is_paused"] is True + assert game_instance["world"] is mock_world + assert game_instance["sim"] is mock_sim + assert game_instance["llm_check_failed"] is False + + @pytest.mark.asyncio + async def test_init_sets_status_to_in_progress(self, reset_game_instance, temp_saves_dir, mock_llm_managers): + """Test that init sets status to in_progress immediately.""" + recorded_status = [] + + original_update = update_init_progress + def tracking_update(phase, phase_name=""): + recorded_status.append(game_instance["init_status"]) + original_update(phase, phase_name) + + mock_map = MagicMock() + mock_world = MagicMock() + mock_world.avatar_manager.avatars = {} + mock_sim = MagicMock() + mock_sim.step = AsyncMock() + + with patch.object(main, "reload_all_static_data"), \ + patch.object(main, "scan_avatar_assets"), \ + patch.object(main, "load_cultivation_world_map", return_value=mock_map), \ + patch.object(main, "check_llm_connectivity", return_value=(True, "")), \ + patch.object(main, "update_init_progress", side_effect=tracking_update), \ + patch("src.server.main.World") as mock_world_class, \ + patch("src.server.main.Simulator", return_value=mock_sim), \ + patch("src.server.main.CONFIG") as mock_config, \ + patch("src.server.main.sects_by_id", {}): + + mock_config.paths.saves = temp_saves_dir + mock_config.game.sect_num = 0 + mock_config.game.init_npc_num = 0 + mock_config.game.world_history = "" + mock_config.avatar.protagonist = "none" + mock_world_class.create_with_db.return_value = mock_world + + await init_game_async() + + # All recorded statuses should be "in_progress". + assert all(s == "in_progress" for s in recorded_status) + + +class TestInitGameAsyncWithHistory: + """Tests for initialization with world history.""" + + @pytest.mark.asyncio + async def test_init_applies_history(self, reset_game_instance, temp_saves_dir, mock_llm_managers): + """Test that world history is applied when configured.""" + mock_map = MagicMock() + mock_world = MagicMock() + mock_world.avatar_manager.avatars = {} + mock_sim = MagicMock() + mock_sim.step = AsyncMock() + + mock_history_mgr = MagicMock() + mock_history_mgr.apply_history_influence = AsyncMock() + + with patch.object(main, "reload_all_static_data"), \ + patch.object(main, "scan_avatar_assets"), \ + patch.object(main, "load_cultivation_world_map", return_value=mock_map), \ + patch.object(main, "check_llm_connectivity", return_value=(True, "")), \ + patch("src.server.main.World") as mock_world_class, \ + patch("src.server.main.Simulator", return_value=mock_sim), \ + patch("src.server.main.HistoryManager", return_value=mock_history_mgr) as mock_hm_class, \ + patch("src.server.main.CONFIG") as mock_config, \ + patch("src.server.main.sects_by_id", {}): + + mock_config.paths.saves = temp_saves_dir + mock_config.game.sect_num = 0 + mock_config.game.init_npc_num = 0 + mock_config.game.world_history = "Ancient cultivation world..." + mock_config.avatar.protagonist = "none" + mock_world_class.create_with_db.return_value = mock_world + + await init_game_async() + + # Verify history was applied. + mock_world.set_history.assert_called_once_with("Ancient cultivation world...") + mock_hm_class.assert_called_once_with(mock_world) + mock_history_mgr.apply_history_influence.assert_called_once_with("Ancient cultivation world...") + + @pytest.mark.asyncio + async def test_init_continues_if_history_fails(self, reset_game_instance, temp_saves_dir, mock_llm_managers): + """Test that init continues even if history application fails.""" + mock_map = MagicMock() + mock_world = MagicMock() + mock_world.avatar_manager.avatars = {} + mock_sim = MagicMock() + mock_sim.step = AsyncMock() + + mock_history_mgr = MagicMock() + mock_history_mgr.apply_history_influence = AsyncMock(side_effect=Exception("History failed")) + + with patch.object(main, "reload_all_static_data"), \ + patch.object(main, "scan_avatar_assets"), \ + patch.object(main, "load_cultivation_world_map", return_value=mock_map), \ + patch.object(main, "check_llm_connectivity", return_value=(True, "")), \ + patch("src.server.main.World") as mock_world_class, \ + patch("src.server.main.Simulator", return_value=mock_sim), \ + patch("src.server.main.HistoryManager", return_value=mock_history_mgr), \ + patch("src.server.main.CONFIG") as mock_config, \ + patch("src.server.main.sects_by_id", {}): + + mock_config.paths.saves = temp_saves_dir + mock_config.game.sect_num = 0 + mock_config.game.init_npc_num = 0 + mock_config.game.world_history = "Some history" + mock_config.avatar.protagonist = "none" + mock_world_class.create_with_db.return_value = mock_world + + # Should not raise, should continue. + await init_game_async() + + # Should still complete successfully. + assert game_instance["init_status"] == "ready" + + +class TestInitGameAsyncWithLLMFailure: + """Tests for initialization with LLM check failure.""" + + @pytest.mark.asyncio + async def test_init_records_llm_failure(self, reset_game_instance, temp_saves_dir, mock_llm_managers): + """Test that LLM check failure is recorded but doesn't stop init.""" + mock_map = MagicMock() + mock_world = MagicMock() + mock_world.avatar_manager.avatars = {} + mock_sim = MagicMock() + mock_sim.step = AsyncMock() + + with patch.object(main, "reload_all_static_data"), \ + patch.object(main, "scan_avatar_assets"), \ + patch.object(main, "load_cultivation_world_map", return_value=mock_map), \ + patch.object(main, "check_llm_connectivity", return_value=(False, "API key invalid")), \ + patch("src.server.main.World") as mock_world_class, \ + patch("src.server.main.Simulator", return_value=mock_sim), \ + patch("src.server.main.CONFIG") as mock_config, \ + patch("src.server.main.sects_by_id", {}): + + mock_config.paths.saves = temp_saves_dir + mock_config.game.sect_num = 0 + mock_config.game.init_npc_num = 0 + mock_config.game.world_history = "" + mock_config.avatar.protagonist = "none" + mock_world_class.create_with_db.return_value = mock_world + + await init_game_async() + + # Should still complete. + assert game_instance["init_status"] == "ready" + # But LLM failure should be recorded. + assert game_instance["llm_check_failed"] is True + assert game_instance["llm_error_message"] == "API key invalid" + + +class TestInitGameAsyncWithAvatars: + """Tests for avatar generation during initialization.""" + + @pytest.mark.asyncio + async def test_init_generates_npcs(self, reset_game_instance, temp_saves_dir, mock_llm_managers): + """Test that NPCs are generated.""" + mock_map = MagicMock() + mock_world = MagicMock() + # Use a real dict to track what gets added. + avatars_dict = {} + mock_world.avatar_manager.avatars = avatars_dict + mock_world.month_stamp = MagicMock() + mock_sim = MagicMock() + mock_sim.step = AsyncMock() + + mock_avatars = {"npc1": MagicMock(), "npc2": MagicMock(), "npc3": MagicMock()} + + with patch.object(main, "reload_all_static_data"), \ + patch.object(main, "scan_avatar_assets"), \ + patch.object(main, "load_cultivation_world_map", return_value=mock_map), \ + patch.object(main, "check_llm_connectivity", return_value=(True, "")), \ + patch.object(main, "_new_make_random", return_value=mock_avatars), \ + patch("src.server.main.World") as mock_world_class, \ + patch("src.server.main.Simulator", return_value=mock_sim), \ + patch("src.server.main.CONFIG") as mock_config, \ + patch("src.server.main.sects_by_id", {}): + + mock_config.paths.saves = temp_saves_dir + mock_config.game.sect_num = 0 + mock_config.game.init_npc_num = 3 + mock_config.game.world_history = "" + mock_config.avatar.protagonist = "none" + mock_world_class.create_with_db.return_value = mock_world + + await init_game_async() + + # Avatars should be registered - check the dict was updated. + assert len(avatars_dict) == 3 + assert "npc1" in avatars_dict + + @pytest.mark.asyncio + async def test_init_with_protagonist_mode_all(self, reset_game_instance, temp_saves_dir, mock_llm_managers): + """Test initialization with protagonist mode 'all'.""" + mock_map = MagicMock() + mock_world = MagicMock() + mock_world.avatar_manager.avatars = {} + mock_world.month_stamp = MagicMock() + mock_sim = MagicMock() + mock_sim.step = AsyncMock() + + mock_protagonists = {"prot1": MagicMock(), "prot2": MagicMock()} + + with patch.object(main, "reload_all_static_data"), \ + patch.object(main, "scan_avatar_assets"), \ + patch.object(main, "load_cultivation_world_map", return_value=mock_map), \ + patch.object(main, "check_llm_connectivity", return_value=(True, "")), \ + patch("src.server.main.prot_utils") as mock_prot_utils, \ + patch("src.server.main.World") as mock_world_class, \ + patch("src.server.main.Simulator", return_value=mock_sim), \ + patch("src.server.main.CONFIG") as mock_config, \ + patch("src.server.main.sects_by_id", {}): + + mock_prot_utils.spawn_protagonists.return_value = mock_protagonists + mock_config.paths.saves = temp_saves_dir + mock_config.game.sect_num = 0 + mock_config.game.init_npc_num = 10 + mock_config.game.world_history = "" + mock_config.avatar.protagonist = "all" + mock_world_class.create_with_db.return_value = mock_world + + await init_game_async() + + # Protagonists should be spawned with probability 1.0. + mock_prot_utils.spawn_protagonists.assert_called_once() + call_kwargs = mock_prot_utils.spawn_protagonists.call_args + assert call_kwargs[1]["probability"] == 1.0 + + +class TestInitGameAsyncErrors: + """Tests for error handling during initialization.""" + + @pytest.mark.asyncio + async def test_init_handles_map_load_error(self, reset_game_instance, temp_saves_dir, mock_llm_managers): + """Test that map loading error sets error status.""" + with patch.object(main, "reload_all_static_data"), \ + patch.object(main, "scan_avatar_assets"), \ + patch.object(main, "load_cultivation_world_map", side_effect=Exception("Map file not found")), \ + patch("src.server.main.CONFIG") as mock_config: + + mock_config.paths.saves = temp_saves_dir + + await init_game_async() + + assert game_instance["init_status"] == "error" + assert "Map file not found" in game_instance["init_error"] + + @pytest.mark.asyncio + async def test_init_handles_asset_scan_error(self, reset_game_instance, temp_saves_dir, mock_llm_managers): + """Test that asset scanning error sets error status.""" + with patch.object(main, "reload_all_static_data"), \ + patch.object(main, "scan_avatar_assets", side_effect=Exception("Asset scan failed")), \ + patch("src.server.main.CONFIG") as mock_config: + + mock_config.paths.saves = temp_saves_dir + + await init_game_async() + + assert game_instance["init_status"] == "error" + assert "Asset scan failed" in game_instance["init_error"] + + @pytest.mark.asyncio + async def test_init_continues_if_initial_events_fail(self, reset_game_instance, temp_saves_dir, mock_llm_managers): + """Test that init completes even if initial event generation fails.""" + mock_map = MagicMock() + mock_world = MagicMock() + mock_world.avatar_manager.avatars = {} + mock_sim = MagicMock() + mock_sim.step = AsyncMock(side_effect=Exception("Event generation failed")) + + with patch.object(main, "reload_all_static_data"), \ + patch.object(main, "scan_avatar_assets"), \ + patch.object(main, "load_cultivation_world_map", return_value=mock_map), \ + patch.object(main, "check_llm_connectivity", return_value=(True, "")), \ + patch("src.server.main.World") as mock_world_class, \ + patch("src.server.main.Simulator", return_value=mock_sim), \ + patch("src.server.main.CONFIG") as mock_config, \ + patch("src.server.main.sects_by_id", {}): + + mock_config.paths.saves = temp_saves_dir + mock_config.game.sect_num = 0 + mock_config.game.init_npc_num = 0 + mock_config.game.world_history = "" + mock_config.avatar.protagonist = "none" + mock_world_class.create_with_db.return_value = mock_world + + await init_game_async() + + # Should still complete (initial events failure is not fatal). + assert game_instance["init_status"] == "ready" + # Game should be paused. + assert game_instance["is_paused"] is True + + +class TestInitGameAsyncWithSects: + """Tests for sect initialization.""" + + @pytest.mark.asyncio + async def test_init_selects_random_sects(self, reset_game_instance, temp_saves_dir, mock_llm_managers): + """Test that random sects are selected from available sects.""" + mock_map = MagicMock() + mock_world = MagicMock() + mock_world.avatar_manager.avatars = {} + mock_world.month_stamp = MagicMock() + mock_sim = MagicMock() + mock_sim.step = AsyncMock() + + mock_sect1 = MagicMock() + mock_sect2 = MagicMock() + mock_sect3 = MagicMock() + + with patch.object(main, "reload_all_static_data"), \ + patch.object(main, "scan_avatar_assets"), \ + patch.object(main, "load_cultivation_world_map", return_value=mock_map), \ + patch.object(main, "check_llm_connectivity", return_value=(True, "")), \ + patch.object(main, "_new_make_random", return_value={}) as mock_make_random, \ + patch("src.server.main.World") as mock_world_class, \ + patch("src.server.main.Simulator", return_value=mock_sim), \ + patch("src.server.main.CONFIG") as mock_config, \ + patch("src.server.main.sects_by_id", { + "s1": mock_sect1, + "s2": mock_sect2, + "s3": mock_sect3, + }): + + mock_config.paths.saves = temp_saves_dir + mock_config.game.sect_num = 2 # Request 2 sects from 3 available. + mock_config.game.init_npc_num = 5 + mock_config.game.world_history = "" + mock_config.avatar.protagonist = "none" + mock_world_class.create_with_db.return_value = mock_world + + await init_game_async() + + # _new_make_random should be called with existed_sects. + call_args = mock_make_random.call_args + existed_sects = call_args[1]["existed_sects"] + assert len(existed_sects) == 2 # Should have selected 2 sects. + + +class TestInitPhaseNames: + """Tests for phase name constants.""" + + def test_phase_names_are_strings(self): + """Test that all phase names are non-empty strings.""" + for phase, name in INIT_PHASE_NAMES.items(): + assert isinstance(name, str) + assert len(name) > 0 + + def test_phase_names_are_snake_case(self): + """Test that phase names follow snake_case convention.""" + for name in INIT_PHASE_NAMES.values(): + assert name == name.lower() + assert " " not in name