Files
cultivation-world-simulator/tests/test_init_status_api.py
Zihao Xu 9485b62cfd feat: add loading screen with progress tracking
- Add async initialization with 6 phases: scanning_assets, loading_map,
  initializing_sects, generating_avatars, checking_llm, generating_initial_events
- Add /api/init-status endpoint for frontend polling
- Add /api/control/reinit endpoint for error recovery
- Add LoadingOverlay.vue component with:
  - Progress ring with gradient
  - Phase text in xianxia style (rotating messages for LLM phase)
  - Tips that rotate every 5 seconds
  - Time-based background transparency (fades to 80% over 20s)
  - Backdrop blur effect
  - Error state with retry button
- Preload map and avatars during LLM initialization for smoother UX
- Add comprehensive tests for init status API
2026-01-08 21:12:18 +08:00

406 lines
16 KiB
Python

"""
Tests for the initialization status API endpoints.
These tests verify the loading screen backend functionality:
- /api/init-status endpoint
- /api/game/new endpoint
- /api/control/reinit endpoint
- Initialization phases and progress tracking
"""
import pytest
import asyncio
import time
from unittest.mock import patch, MagicMock, AsyncMock
from fastapi.testclient import TestClient
from src.server import main
from src.server.main import app, game_instance, update_init_progress, INIT_PHASE_NAMES
@pytest.fixture
def client():
"""Create a test client for the FastAPI app."""
return TestClient(app)
@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": "",
})
yield
game_instance.clear()
game_instance.update(original_state)
class TestInitStatusEndpoint:
"""Tests for /api/init-status endpoint."""
def test_init_status_idle(self, client, reset_game_instance):
"""Test init-status returns idle state correctly."""
response = client.get("/api/init-status")
assert response.status_code == 200
data = response.json()
assert data["status"] == "idle"
assert data["phase"] == 0
assert data["phase_name"] == ""
assert data["progress"] == 0
assert data["error"] is None
assert data["llm_check_failed"] is False
assert data["llm_error_message"] == ""
def test_init_status_in_progress(self, client, reset_game_instance):
"""Test init-status during initialization."""
game_instance["init_status"] = "in_progress"
game_instance["init_phase"] = 2
game_instance["init_phase_name"] = "initializing_sects"
game_instance["init_progress"] = 33
game_instance["init_start_time"] = time.time() - 5 # 5 seconds ago
response = client.get("/api/init-status")
assert response.status_code == 200
data = response.json()
assert data["status"] == "in_progress"
assert data["phase"] == 2
assert data["phase_name"] == "initializing_sects"
assert data["progress"] == 33
assert data["elapsed_seconds"] >= 5
def test_init_status_ready(self, client, reset_game_instance):
"""Test init-status when initialization is complete."""
game_instance["init_status"] = "ready"
game_instance["init_phase"] = 5
game_instance["init_phase_name"] = "generating_initial_events"
game_instance["init_progress"] = 100
response = client.get("/api/init-status")
assert response.status_code == 200
data = response.json()
assert data["status"] == "ready"
assert data["progress"] == 100
def test_init_status_error(self, client, reset_game_instance):
"""Test init-status when initialization failed."""
game_instance["init_status"] = "error"
game_instance["init_error"] = "LLM connection failed"
response = client.get("/api/init-status")
assert response.status_code == 200
data = response.json()
assert data["status"] == "error"
assert data["error"] == "LLM connection failed"
def test_init_status_llm_check_failed(self, client, reset_game_instance):
"""Test init-status includes LLM check status."""
game_instance["init_status"] = "ready"
game_instance["llm_check_failed"] = True
game_instance["llm_error_message"] = "API key invalid"
response = client.get("/api/init-status")
assert response.status_code == 200
data = response.json()
assert data["llm_check_failed"] is True
assert data["llm_error_message"] == "API key invalid"
class TestUpdateInitProgress:
"""Tests for update_init_progress function."""
def test_update_progress_with_phase_name(self, reset_game_instance):
"""Test updating progress with explicit phase name."""
update_init_progress(2, "initializing_sects")
assert game_instance["init_phase"] == 2
assert game_instance["init_phase_name"] == "initializing_sects"
assert game_instance["init_progress"] == 33
def test_update_progress_without_phase_name(self, reset_game_instance):
"""Test updating progress uses default phase name from mapping."""
update_init_progress(3)
assert game_instance["init_phase"] == 3
assert game_instance["init_phase_name"] == "generating_avatars"
assert game_instance["init_progress"] == 50
def test_all_phase_names_mapped(self):
"""Test all phases have corresponding names."""
expected_phases = {
0: "scanning_assets",
1: "loading_map",
2: "initializing_sects",
3: "generating_avatars",
4: "checking_llm",
5: "generating_initial_events",
}
assert INIT_PHASE_NAMES == expected_phases
def test_progress_percentages(self, reset_game_instance):
"""Test progress percentages for each phase."""
expected_progress = {0: 0, 1: 17, 2: 33, 3: 50, 4: 67, 5: 83}
for phase, expected in expected_progress.items():
update_init_progress(phase)
assert game_instance["init_progress"] == expected, f"Phase {phase} should have progress {expected}"
class TestNewGameEndpoint:
"""Tests for /api/game/new endpoint."""
def test_new_game_starts_initialization(self, client, reset_game_instance):
"""Test /api/game/new starts initialization process."""
with patch.object(main, 'init_game_async', new_callable=AsyncMock) as mock_init:
response = client.post("/api/game/new")
assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
assert "started" in data["message"].lower()
assert game_instance["init_status"] == "pending"
def test_new_game_rejects_when_in_progress(self, client, reset_game_instance):
"""Test /api/game/new rejects request when already initializing."""
game_instance["init_status"] = "in_progress"
response = client.post("/api/game/new")
assert response.status_code == 400
assert "already initializing" in response.json()["detail"].lower()
def test_new_game_clears_existing_state(self, client, reset_game_instance):
"""Test /api/game/new clears existing game state when ready."""
mock_world = MagicMock()
mock_sim = MagicMock()
game_instance["world"] = mock_world
game_instance["sim"] = mock_sim
game_instance["init_status"] = "ready"
with patch.object(main, 'init_game_async', new_callable=AsyncMock):
response = client.post("/api/game/new")
assert response.status_code == 200
assert game_instance["world"] is None
assert game_instance["sim"] is None
class TestReinitEndpoint:
"""Tests for /api/control/reinit endpoint."""
def test_reinit_clears_state(self, client, reset_game_instance):
"""Test /api/control/reinit clears all game state."""
game_instance["world"] = MagicMock()
game_instance["sim"] = MagicMock()
game_instance["init_status"] = "error"
game_instance["init_error"] = "Some error"
game_instance["init_phase"] = 3
game_instance["init_progress"] = 50
with patch.object(main, 'init_game_async', new_callable=AsyncMock):
response = client.post("/api/control/reinit")
assert response.status_code == 200
assert game_instance["world"] is None
assert game_instance["sim"] is None
assert game_instance["init_status"] == "pending"
assert game_instance["init_phase"] == 0
assert game_instance["init_progress"] == 0
assert game_instance["init_error"] is None
def test_reinit_starts_new_initialization(self, client, reset_game_instance):
"""Test /api/control/reinit starts new initialization task."""
with patch.object(main, 'init_game_async', new_callable=AsyncMock) as mock_init:
response = client.post("/api/control/reinit")
assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
assert "reinitialization" in data["message"].lower()
class TestMapAndStateAPIDuringInit:
"""Tests to verify /api/map and /api/state availability during initialization phases."""
def test_map_available_during_checking_llm(self, client, reset_game_instance):
"""Test /api/map is available when world exists (checking_llm phase)."""
# Simulate world being created but LLM check in progress.
mock_world = MagicMock()
mock_map = MagicMock()
mock_map.width = 100
mock_map.height = 100
mock_map.tiles = {}
mock_map.regions = {}
mock_world.map = mock_map
game_instance["world"] = mock_world
game_instance["init_status"] = "in_progress"
game_instance["init_phase"] = 4
game_instance["init_phase_name"] = "checking_llm"
# The /api/map endpoint should work.
response = client.get("/api/map")
# It may return data or empty, but should not error with 503.
assert response.status_code == 200
def test_state_available_during_generating_events(self, client, reset_game_instance):
"""Test /api/state is available during generating_initial_events phase."""
mock_world = MagicMock()
mock_world.month_stamp.get_year.return_value = 100
mock_world.month_stamp.get_month.return_value = MagicMock(value=1)
mock_world.avatar_manager.avatars = {}
mock_world.event_manager = None
game_instance["world"] = mock_world
game_instance["init_status"] = "in_progress"
game_instance["init_phase"] = 5
game_instance["init_phase_name"] = "generating_initial_events"
response = client.get("/api/state")
assert response.status_code == 200
class TestInitGameAsync:
"""Tests for the async initialization flow."""
@pytest.mark.asyncio
async def test_init_sets_status_to_in_progress(self, reset_game_instance):
"""Test initialization sets status to in_progress immediately."""
with patch.object(main, 'scan_avatar_assets'), \
patch.object(main, 'load_cultivation_world_map') as mock_load_map, \
patch.object(main, 'check_llm_connectivity', return_value=(True, "")), \
patch('src.server.main.World') as mock_world_class, \
patch('src.server.main.Simulator') as mock_sim_class:
mock_map = MagicMock()
mock_load_map.return_value = mock_map
mock_world = MagicMock()
mock_world.avatar_manager.avatars = {}
mock_world_class.return_value = mock_world
mock_sim = MagicMock()
mock_sim.step = AsyncMock()
mock_sim_class.return_value = mock_sim
# Start init but check status immediately.
task = asyncio.create_task(main.init_game_async())
await asyncio.sleep(0.01) # Let it start.
assert game_instance["init_status"] in ["in_progress", "ready"]
await task # Let it complete.
@pytest.mark.asyncio
async def test_init_error_sets_error_status(self, reset_game_instance):
"""Test initialization error sets status to error."""
with patch.object(main, 'scan_avatar_assets', side_effect=Exception("Test error")):
await main.init_game_async()
assert game_instance["init_status"] == "error"
assert "Test error" in game_instance["init_error"]
@pytest.mark.asyncio
async def test_init_completes_with_ready_status(self, reset_game_instance):
"""Test successful initialization sets status to ready."""
with patch.object(main, 'scan_avatar_assets'), \
patch.object(main, 'load_cultivation_world_map') as mock_load_map, \
patch.object(main, 'check_llm_connectivity', return_value=(True, "")), \
patch('src.server.main.World') as mock_world_class, \
patch('src.server.main.Simulator') as mock_sim_class, \
patch('src.server.main.sects_by_id', {}), \
patch('src.server.main.CONFIG') as mock_config:
mock_config.game.sect_num = 0
mock_config.game.init_npc_num = 0
mock_config.avatar.protagonist = "none"
mock_map = MagicMock()
mock_load_map.return_value = mock_map
mock_world = MagicMock()
mock_world.avatar_manager.avatars = {}
mock_world_class.return_value = mock_world
mock_sim = MagicMock()
mock_sim.step = AsyncMock()
mock_sim_class.return_value = mock_sim
await main.init_game_async()
assert game_instance["init_status"] == "ready"
assert game_instance["init_progress"] == 100
@pytest.mark.asyncio
async def test_init_records_llm_failure(self, reset_game_instance):
"""Test LLM check failure is recorded but doesn't stop initialization."""
with patch.object(main, 'scan_avatar_assets'), \
patch.object(main, 'load_cultivation_world_map') as mock_load_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') as mock_sim_class, \
patch('src.server.main.sects_by_id', {}), \
patch('src.server.main.CONFIG') as mock_config:
mock_config.game.sect_num = 0
mock_config.game.init_npc_num = 0
mock_config.avatar.protagonist = "none"
mock_map = MagicMock()
mock_load_map.return_value = mock_map
mock_world = MagicMock()
mock_world.avatar_manager.avatars = {}
mock_world_class.return_value = mock_world
mock_sim = MagicMock()
mock_sim.step = AsyncMock()
mock_sim_class.return_value = mock_sim
await main.init_game_async()
# Should still complete successfully.
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"
@pytest.mark.asyncio
async def test_init_pauses_after_initial_events(self, reset_game_instance):
"""Test game is paused after generating initial events."""
with patch.object(main, 'scan_avatar_assets'), \
patch.object(main, 'load_cultivation_world_map') as mock_load_map, \
patch.object(main, 'check_llm_connectivity', return_value=(True, "")), \
patch('src.server.main.World') as mock_world_class, \
patch('src.server.main.Simulator') as mock_sim_class, \
patch('src.server.main.sects_by_id', {}), \
patch('src.server.main.CONFIG') as mock_config:
mock_config.game.sect_num = 0
mock_config.game.init_npc_num = 0
mock_config.avatar.protagonist = "none"
mock_map = MagicMock()
mock_load_map.return_value = mock_map
mock_world = MagicMock()
mock_world.avatar_manager.avatars = {}
mock_world_class.return_value = mock_world
mock_sim = MagicMock()
mock_sim.step = AsyncMock()
mock_sim_class.return_value = mock_sim
await main.init_game_async()
# Game should be paused after initialization.
assert game_instance["is_paused"] is True