diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0568912 --- /dev/null +++ b/.gitignore @@ -0,0 +1,72 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Virtual environments +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# PyCharm +.idea/ + +# VS Code +.vscode/ + +# Testing +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# Environments +.env +.venv + +# macOS +.DS_Store + +# Windows +Thumbs.db +ehthumbs.db \ No newline at end of file diff --git a/__pycache__/gumyum_models.cpython-313.pyc b/__pycache__/gumyum_models.cpython-313.pyc deleted file mode 100644 index ef2972b..0000000 Binary files a/__pycache__/gumyum_models.cpython-313.pyc and /dev/null differ diff --git a/__pycache__/gumyum_npc_client.cpython-313.pyc b/__pycache__/gumyum_npc_client.cpython-313.pyc deleted file mode 100644 index 03b1fbc..0000000 Binary files a/__pycache__/gumyum_npc_client.cpython-313.pyc and /dev/null differ diff --git a/__pycache__/gumyum_npc_sync.cpython-313.pyc b/__pycache__/gumyum_npc_sync.cpython-313.pyc deleted file mode 100644 index 1e3f23d..0000000 Binary files a/__pycache__/gumyum_npc_sync.cpython-313.pyc and /dev/null differ diff --git a/gumyum_models.py b/gumyum_models.py index ae0eb6b..5421967 100644 --- a/gumyum_models.py +++ b/gumyum_models.py @@ -174,18 +174,18 @@ class SpawnedData(BaseModel): location: str mood: str - stress_level: int # 1-10 + stress_level: int # 1-9 class NPCProfile(BaseModel): """Full NPC profile data returned from save/update operations""" - npc_id: str + npc_id: int # 64-bit integer NPC ID name: str profession: str age: Optional[int] = None sex: Optional[str] = None # "male", "female", "neutral" - personality_type: int # Enneagram type (1-9) + personality_type: int # Personality type (1-9) wing: Optional[int] = None stress_level: Optional[int] = None mood: Optional[str] = None @@ -198,8 +198,8 @@ class NPCProfile(BaseModel): location: Optional[str] = None purpose: Optional[str] = None spawned: SpawnedData - created_at: Optional[str] = None - updated_at: Optional[str] = None + created_at: Optional[Union[str, int]] = None + updated_at: Optional[Union[str, int]] = None custom_name: Optional[str] = None inventory: List[Dict[str, Any]] = Field(default_factory=list) stats: Dict[str, Any] = Field(default_factory=dict) @@ -218,11 +218,11 @@ class NPC(BaseModel): npc_id: int name: str profession: str - personality_type: int # Enneagram type (1-9) + personality_type: int # Personality type (1-9) mood: Optional[str] = None spawned: Optional[SpawnedData] = None universe_id: Optional[str] = None - seed: Optional[int] = None + world_seed: Optional[int] = None cached: bool = False cache_url: Optional[str] = None chat_history: List[dict] = Field(default_factory=list) @@ -315,9 +315,10 @@ class UserProfile(BaseModel): user_id: str username: str email: str - created_at: str + created_at: Union[str, int] # Can be timestamp or ISO string universes_count: int = 0 npcs_count: int = 0 + statistics: Optional[Dict[str, Any]] = None class ApiKey(BaseModel): @@ -350,20 +351,27 @@ class ApiKeyPair(BaseModel): class APIUsage(BaseModel): - """API usage statistics""" + """API usage statistics - flexible structure""" - requests_today: int - requests_month: int - rate_limit: int + model_config = {"extra": "allow"} # Allow additional fields + + requests_today: Optional[int] = 0 + requests_month: Optional[int] = 0 + rate_limit: Optional[int] = 0 class NPCStats(BaseModel): - """User's NPC statistics""" + """User's NPC statistics - flexible structure to match API""" - total_npcs: int - total_messages: int - unique_universes: int - api_usage: APIUsage + model_config = {"extra": "allow"} # Allow additional fields + + total_npcs: Optional[int] = 0 + total_messages: Optional[int] = 0 + unique_universes: Optional[int] = 0 + api_usage: Optional[Dict[str, Any]] = Field( + default_factory=dict + ) # API returns dict structure + source: Optional[str] = None # ============================================================================ diff --git a/gumyum_npc_client.py b/gumyum_npc_client.py index 4b81810..9aeeee3 100644 --- a/gumyum_npc_client.py +++ b/gumyum_npc_client.py @@ -142,7 +142,7 @@ class NPCChatProxy: # Build npc_params with the NPC's context npc_params = { "universe_id": self.npc.universe_id, - "world_seed": self.npc.seed, + "world_seed": self.npc.world_seed, "npc_id": self.npc.npc_id, } @@ -284,6 +284,49 @@ class NPCAsync(NPCBase): """ return await self.chat.chat_with_history(message, **kwargs) + def clear_history(self): + """Clear the chat history for this NPC""" + self.chat_history = [] + + def chat_history_to_json(self) -> str: + """Convert chat history to JSON string""" + return json.dumps(self.chat_history) + + def to_dict(self) -> dict: + """Convert NPC to dictionary""" + # Use pydantic's model_dump if available, otherwise dict + if hasattr(self, "model_dump"): + return self.model_dump(exclude={"client", "_chat"}) + else: + data = self.dict(exclude={"client", "_chat"}) + return data + + def to_json(self) -> str: + """Convert NPC to JSON string""" + return json.dumps(self.to_dict()) + + @classmethod + def from_dict( + cls, data: dict, client: Optional["GumYumClient"] = None + ) -> "NPCAsync": + """Create NPC from dictionary""" + npc = cls(**data) + if client: + npc.client = client + return npc + + @classmethod + def from_json( + cls, json_str: str, client: Optional["GumYumClient"] = None + ) -> "NPCAsync": + """Create NPC from JSON string""" + try: + return cls.from_dict(json.loads(json_str), client) + except json.JSONDecodeError as e: + raise ValueError(f"Failed to parse NPC JSON: {e}") + except Exception as e: + raise ValueError(f"Failed to parse NPC JSON: {e}") + # Use NPCAsync as NPC for compatibility NPC = NPCAsync @@ -665,10 +708,16 @@ class UniverseManager: GumYumAuthError: If not owner of private universe """ response = await self.client.get(f"universes/{universe_id}") - return UniverseData(**response) + # The API returns the raw universe data, we need to wrap it + return UniverseData( + universe_id=universe_id, + name=response.get("theme_info", {}).get("name", "Unknown"), + hash="", # Not returned by API + data=response, + ) async def update( - self, universe_id: str, universe_data: UniverseData + self, universe_id: str, universe_data: Union[UniverseData, dict] ) -> UniverseData: """ Update an existing universe @@ -693,7 +742,8 @@ class UniverseManager: data = universe_data response = await self.client.put(f"universes/{universe_id}", json_data=data) - return UniverseData(**response) + # Return the updated universe + return await self.get(universe_id) async def fork(self, universe_id: str, name: Optional[str] = None) -> str: """ @@ -821,7 +871,6 @@ class NPCManager: universe_id: str, seed: int, npc_id: Optional[int] = None, - location_filter: Optional[List[str]] = None, ) -> NPC: """ Spawn a deterministic NPC @@ -847,40 +896,39 @@ class NPCManager: if npc_id is not None: # Spawn with specific 9-digit NPC ID params = {"universe_id": universe_id, "seed": seed, "npc_id": npc_id} - if location_filter: - params["location_filter"] = location_filter - response = await self.client.get("npc", params=params) + response = await self.client.get("npc/spawn", params=params) npc = NPC(**response) npc.client = self.client + # Set world_seed if not in response (API may not return it) + if npc.world_seed is None: + npc.world_seed = seed return npc else: # Generate random NPC ID from seed and redirect params = {"universe_id": universe_id, "seed": seed} - if location_filter: - params["location_filter"] = location_filter response = await self.client.get("npc/spawn", params=params) npc = NPC(**response) npc.client = self.client + # Set world_seed if not in response (API may not return it) + if npc.world_seed is None: + npc.world_seed = seed return npc - async def spawn_random( - self, universe_id: str, seed: int, location_filter: Optional[List[str]] = None - ) -> NPC: + async def spawn_random(self, universe_id: str, seed: int) -> NPC: """ Spawn random NPC using deterministic seed Args: universe_id: Universe to spawn NPC in seed: Deterministic seed for generation - location_filter: Filter NPCs by location types Returns: Spawned NPC data """ - return await self.spawn(universe_id, seed, None, location_filter) + return await self.spawn(universe_id, seed, None) async def spawn_filtered( self, @@ -905,18 +953,18 @@ class NPCManager: Filter options: - profession: List[str] - Specific professions - - personality_type: List[int] - Enneagram types (1-9) + - personality_type: List[int] - Personality types (1-9) - wing: List[int] - Wing types (1-9) - - stress_level: Dict[str, int] - {'min': 0, 'max': 10} + - stress_level: Dict[str, int] - {'min': 1, 'max': 9} - mood: List[str] - Specific moods - gender: List[str] - ['male', 'female', 'neutral'] - - location_filter: List[str] - Location types + - location: List[str] - Location names to filter by Example: filters = { "profession": ["warrior", "mage"], "personality_type": [1, 8], # Perfectionist or Challenger - "stress_level": {"min": 0, "max": 3} # Low stress + "stress_level": {"min": 1, "max": 3} # Low stress } """ data = { @@ -927,25 +975,42 @@ class NPCManager: "max_attempts": max_attempts, } - response = await self.client.post("npc/spawn/filtered", json_data=data) + try: + response = await self.client.post("npc/spawn/filtered", json_data=data) + except GumYumNotFoundError as e: + # If the error is specifically "No NPC found matching filters", return None + if "No NPC found matching filters" in str(e): + return None + # Re-raise other NotFound errors + raise # Check if NPC was found (response will have npc_id if found) if not response.get("npc_id"): return None # Convert response to NPC + spawned_data = None + if "spawned" in response and response["spawned"]: + spawned_info = response["spawned"] + spawned_data = SpawnedData( + location=spawned_info.get("location"), + mood=spawned_info.get("mood"), + stress_level=spawned_info.get("stress_level"), + ) + npc = NPC( npc_id=response["npc_id"], name=response["name"], profession=response["profession"], personality_type=response["personality_type"], - spawned=SpawnedData( - location=response["spawned"]["location"], - mood=response["spawned"]["mood"], - stress_level=response["spawned"]["stress_level"], + mood=( + response.get("spawned", {}).get("mood") + if "spawned" in response + else None ), + spawned=spawned_data, universe_id=universe_id, # Use the passed universe_id - seed=world_seed, # Use the passed world_seed + world_seed=world_seed, # Use the passed world_seed cached=False, cache_url=None, ) @@ -966,6 +1031,9 @@ class NPCManager: GumYumNotFoundError: If NPC not found """ response = await self.client.get(f"npc/saved/{npc_id}") + # API returns data wrapped in 'npc' field + if "npc" in response: + return NPCProfile(**response["npc"]) return NPCProfile(**response) async def save( @@ -1453,7 +1521,7 @@ class GumYumClient: # Initialize managers self.auth = AuthManager(self) self.universes = UniverseManager(self) - self.npc = NPCManager(self) + self.npcs = NPCManager(self) self.chat = ChatManager(self) self.moods = MoodManager(self) @@ -1689,6 +1757,9 @@ class GumYumClient: Raises: GumYumError: On API errors """ + # Ensure client is authenticated + await self._ensure_authenticated() + # Exchange API key for JWT on first request if using API key auth if self._public_key and self._secret_key and not self._jwt_obtained: await self._exchange_api_key_for_jwt() @@ -1781,21 +1852,22 @@ class GumYumClient: # Check if it's a token error that might benefit from refresh # The server returns "Invalid token" for expired tokens too + error_str = str(error_data.get("error", "")).lower() + message_str = str(error_data.get("message", "")).lower() + is_token_error = ( "expired" in error_msg - or "token expired" in error_data.get("error", "").lower() - or "token has expired" in error_data.get("message", "").lower() - or "invalid token" in error_data.get("error", "").lower() - or "authentication token is invalid" - in error_data.get("message", "").lower() + or "token expired" in error_str + or "token has expired" in message_str + or "invalid token" in error_str + or "authentication token is invalid" in message_str ) # Don't refresh for API key errors or missing auth is_api_key_error = ( "api key" in error_msg - or "api key" in error_data.get("error", "").lower() - or "authentication required" - in error_data.get("message", "").lower() + or "api key" in error_str + or "authentication required" in message_str ) if is_token_error and not is_api_key_error: @@ -1926,9 +1998,17 @@ class GumYumClient: """Handle HTTP error responses""" try: error_data = response.json() - message = error_data.get("message", f"HTTP {response.status_code}") + message = error_data.get( + "message", error_data.get("error", f"HTTP {response.status_code}") + ) + if not message or message == f"HTTP {response.status_code}": + # Try to extract more details + if "errors" in error_data: + message = f"HTTP {response.status_code}: {error_data['errors']}" + else: + message = f"HTTP {response.status_code}: {response.text[:200]}" except: - message = f"HTTP {response.status_code}: {response.text}" + message = f"HTTP {response.status_code}: {response.text[:200]}" error_data = {} # Map status codes to exception types diff --git a/gumyum_npc_sync.py b/gumyum_npc_sync.py index 9129e08..40ec730 100644 --- a/gumyum_npc_sync.py +++ b/gumyum_npc_sync.py @@ -148,7 +148,7 @@ class NPCChatProxy: # Build npc_params with the NPC's context npc_params = { "universe_id": self.npc.universe_id, - "world_seed": self.npc.seed, + "world_seed": self.npc.world_seed, "npc_id": self.npc.npc_id, } @@ -285,6 +285,45 @@ class NPC(AsyncNPC): """ return self.chat.chat_with_history(message, **kwargs) + def clear_history(self): + """Clear the chat history for this NPC""" + self.chat_history = [] + + def chat_history_to_json(self) -> str: + """Convert chat history to JSON string""" + return json.dumps(self.chat_history) + + def to_dict(self) -> dict: + """Convert NPC to dictionary""" + # Use pydantic's model_dump if available, otherwise dict + if hasattr(self, "model_dump"): + return self.model_dump(exclude={"client", "_chat"}) + else: + data = self.dict(exclude={"client", "_chat"}) + return data + + def to_json(self) -> str: + """Convert NPC to JSON string""" + return json.dumps(self.to_dict()) + + @classmethod + def from_dict(cls, data: dict, client: Optional["GumYumClient"] = None) -> "NPC": + """Create NPC from dictionary""" + npc = cls(**data) + if client: + npc.client = client + return npc + + @classmethod + def from_json(cls, json_str: str, client: Optional["GumYumClient"] = None) -> "NPC": + """Create NPC from JSON string""" + try: + return cls.from_dict(json.loads(json_str), client) + except json.JSONDecodeError as e: + raise ValueError(f"Failed to parse NPC JSON: {e}") + except Exception as e: + raise ValueError(f"Failed to parse NPC JSON: {e}") + # ============================================================================ # STREAMING SUPPORT @@ -476,9 +515,10 @@ class AuthManager: """Logout by clearing the authentication token""" self.client.set_token(None, None) - def get_profile(self) -> dict: + def get_profile(self) -> UserProfile: """Get current user profile""" - return self.client.get("auth/profile") + response = self.client.get("auth/profile") + return UserProfile(**response) def is_authenticated(self) -> bool: """Check if client has an authentication token""" @@ -657,12 +697,19 @@ class UniverseManager: def get(self, universe_id: str) -> UniverseData: """Get a specific universe by ID""" response = self.client.get(f"universes/{universe_id}") - return UniverseData(**response) + # The API returns the raw universe data, we need to wrap it + return UniverseData( + universe_id=universe_id, + name=response.get("theme_info", {}).get("name", "Unknown"), + hash="", # Not returned by API + data=response, + ) def update(self, universe_id: str, universe_data: dict) -> UniverseData: """Update an existing universe""" response = self.client.put(f"universes/{universe_id}", json=universe_data) - return UniverseData(**response) + # Return the updated universe + return self.get(universe_id) def fork(self, universe_id: str, name: Optional[str] = None) -> str: """Fork (copy) an existing universe""" @@ -690,36 +737,35 @@ class NPCManager: universe_id: str, seed: int, npc_id: Optional[int] = None, - location_filter: Optional[List[str]] = None, ) -> NPC: """Spawn a deterministic NPC""" if npc_id is not None: # Spawn with specific 64-bit NPC ID params = {"universe_id": universe_id, "seed": seed, "npc_id": npc_id} - if location_filter: - params["location_filter"] = location_filter - response = self.client.get("npc", params=params) + response = self.client.get("npc/spawn", params=params) npc = NPC(**response) npc.client = self.client + # Set world_seed if not in response (API may not return it) + if npc.world_seed is None: + npc.world_seed = seed return npc else: # Generate random NPC ID from seed and redirect params = {"universe_id": universe_id, "seed": seed} - if location_filter: - params["location_filter"] = location_filter response = self.client.get("npc/spawn", params=params) npc = NPC(**response) npc.client = self.client + # Set world_seed if not in response (API may not return it) + if npc.world_seed is None: + npc.world_seed = seed return npc - def spawn_random( - self, universe_id: str, seed: int, location_filter: Optional[List[str]] = None - ) -> NPC: + def spawn_random(self, universe_id: str, seed: int) -> NPC: """Spawn random NPC using deterministic seed""" - return self.spawn(universe_id, seed, None, location_filter) + return self.spawn(universe_id, seed, None) def spawn_filtered( self, @@ -744,12 +790,12 @@ class NPCManager: Filter options: - profession: List[str] - Specific professions - - personality_type: List[int] - Enneagram types (1-9) + - personality_type: List[int] - Personality types (1-9) - wing: List[int] - Wing types (1-9) - - stress_level: Dict[str, int] - {'min': 0, 'max': 10} + - stress_level: Dict[str, int] - {'min': 1, 'max': 9} - mood: List[str] - Specific moods - gender: List[str] - ['male', 'female', 'neutral'] - - location_filter: List[str] - Location types + - location: List[str] - Location names to filter by """ data = { "universe_id": universe_id, @@ -759,29 +805,59 @@ class NPCManager: "max_attempts": max_attempts, } - response = self.client.post("npc/spawn/filtered", json=data) + try: + response = self.client.post("npc/spawn/filtered", json=data) + except GumYumNotFoundError as e: + # If the error is specifically "No NPC found matching filters", return None + if "No NPC found matching filters" in str(e): + return None + # Re-raise other NotFound errors + raise - if not response.get("found"): + # Check if NPC was found + if not response or "npc_id" not in response: + # If we get an error response or no NPC data + if response and response.get("error"): + if "No NPC found" in response["error"]: + return None + raise GumYumNotFoundError(response["error"]) return None # Convert response to NPC + spawned_data = None + if "spawned" in response and response["spawned"]: + spawned_info = response["spawned"] + spawned_data = SpawnedData( + location=spawned_info.get("location"), + mood=spawned_info.get("mood"), + stress_level=spawned_info.get("stress_level"), + ) + npc = NPC( npc_id=response["npc_id"], name=response["name"], profession=response["profession"], personality_type=response["personality_type"], - mood=response["mood"], - universe_id=response["universe_id"], - seed=response["world_seed"], + mood=( + response.get("spawned", {}).get("mood") + if "spawned" in response + else None + ), + spawned=spawned_data, + universe_id=universe_id, # Use the passed universe_id + world_seed=world_seed, # Use the passed world_seed cached=False, cache_url=None, ) npc.client = self.client return npc - def get_profile(self, npc_id: str) -> NPCProfile: + def get_profile(self, npc_id: Union[int, str]) -> NPCProfile: """Get full NPC profile data""" response = self.client.get(f"npc/saved/{npc_id}") + # API returns data wrapped in 'npc' field + if "npc" in response: + return NPCProfile(**response["npc"]) return NPCProfile(**response) def save( @@ -832,12 +908,12 @@ class NPCManager: npcs.append(npc) return npcs - def update(self, npc_id: str, updates: dict) -> NPCProfile: + def update(self, npc_id: Union[int, str], updates: dict) -> NPCProfile: """Update a saved NPC's data""" response = self.client.put(f"npc/saved/{npc_id}", json=updates) return NPCProfile(**response) - def delete(self, npc_id: str) -> bool: + def delete(self, npc_id: Union[int, str]) -> bool: """Delete a saved NPC""" self.client.delete(f"npc/saved/{npc_id}") return True @@ -967,8 +1043,6 @@ class ChatManager: content = self.json_data["choices"][0]["message"]["content"] # Split content into words to simulate streaming words = content.split() - import time - for i, word in enumerate(words): chunk = { "choices": [ @@ -1101,8 +1175,6 @@ class GumYumClient: if refresh_token is not None: self._refresh_token = refresh_token if expires_in is not None: - import time - self._token_expires_at = time.time() + expires_in # Mark that user has explicitly authenticated if token: @@ -1205,8 +1277,6 @@ class GumYumClient: self._auto_auth_attempts += 1 try: # Try to login with load test credentials - import time - timestamp = int(time.time()) username = f"auto_user_{timestamp}_{self._auto_auth_attempts}" self.auth.register( @@ -1247,8 +1317,6 @@ class GumYumClient: """ # Check if token needs refresh before making request if self._token and self._token_expires_at: - import time - if ( time.time() >= self._token_expires_at - 60 ): # Refresh 1 minute before expiry @@ -1400,7 +1468,7 @@ class GumYumClient: self._token = None self._refresh_token = None raise GumYumAuthError( - "Authentication failed - please login again", e.status_code + "Authentication failed - token cleared", e.status_code ) else: # Don't retry, bubble up the auth error @@ -1437,9 +1505,17 @@ class GumYumClient: """Handle HTTP error responses""" try: error_data = response.json() - message = error_data.get("message", f"HTTP {response.status_code}") + message = error_data.get( + "message", error_data.get("error", f"HTTP {response.status_code}") + ) + if not message or message == f"HTTP {response.status_code}": + # Try to extract more details + if "errors" in error_data: + message = f"HTTP {response.status_code}: {error_data['errors']}" + else: + message = f"HTTP {response.status_code}: {response.text[:200]}" except: - message = f"HTTP {response.status_code}: {response.text}" + message = f"HTTP {response.status_code}: {response.text[:200]}" error_data = {} # Map status codes to exception types diff --git a/tests/__pycache__/conftest.cpython-313-pytest-8.4.1.pyc b/tests/__pycache__/conftest.cpython-313-pytest-8.4.1.pyc deleted file mode 100644 index 938d3dc..0000000 Binary files a/tests/__pycache__/conftest.cpython-313-pytest-8.4.1.pyc and /dev/null differ diff --git a/tests/__pycache__/test_async.cpython-313-pytest-8.4.1.pyc b/tests/__pycache__/test_async.cpython-313-pytest-8.4.1.pyc deleted file mode 100644 index c0444c0..0000000 Binary files a/tests/__pycache__/test_async.cpython-313-pytest-8.4.1.pyc and /dev/null differ diff --git a/tests/__pycache__/test_auth_retry_fix.cpython-313-pytest-8.4.1.pyc b/tests/__pycache__/test_auth_retry_fix.cpython-313-pytest-8.4.1.pyc deleted file mode 100644 index 26f997f..0000000 Binary files a/tests/__pycache__/test_auth_retry_fix.cpython-313-pytest-8.4.1.pyc and /dev/null differ diff --git a/tests/__pycache__/test_filtering.cpython-313-pytest-8.4.1.pyc b/tests/__pycache__/test_filtering.cpython-313-pytest-8.4.1.pyc deleted file mode 100644 index aa28fe3..0000000 Binary files a/tests/__pycache__/test_filtering.cpython-313-pytest-8.4.1.pyc and /dev/null differ diff --git a/tests/__pycache__/test_filtering_functional.cpython-313-pytest-8.4.1.pyc b/tests/__pycache__/test_filtering_functional.cpython-313-pytest-8.4.1.pyc deleted file mode 100644 index 3962048..0000000 Binary files a/tests/__pycache__/test_filtering_functional.cpython-313-pytest-8.4.1.pyc and /dev/null differ diff --git a/tests/__pycache__/test_filtering_no_ai.cpython-313-pytest-8.4.1.pyc b/tests/__pycache__/test_filtering_no_ai.cpython-313-pytest-8.4.1.pyc deleted file mode 100644 index 5bea088..0000000 Binary files a/tests/__pycache__/test_filtering_no_ai.cpython-313-pytest-8.4.1.pyc and /dev/null differ diff --git a/tests/__pycache__/test_list_saved_chat.cpython-313-pytest-8.4.1.pyc b/tests/__pycache__/test_list_saved_chat.cpython-313-pytest-8.4.1.pyc deleted file mode 100644 index f864a6b..0000000 Binary files a/tests/__pycache__/test_list_saved_chat.cpython-313-pytest-8.4.1.pyc and /dev/null differ diff --git a/tests/__pycache__/test_list_saved_integration.cpython-313-pytest-8.4.1.pyc b/tests/__pycache__/test_list_saved_integration.cpython-313-pytest-8.4.1.pyc deleted file mode 100644 index 3ba7cdb..0000000 Binary files a/tests/__pycache__/test_list_saved_integration.cpython-313-pytest-8.4.1.pyc and /dev/null differ diff --git a/tests/__pycache__/test_list_saved_unit.cpython-313-pytest-8.4.1.pyc b/tests/__pycache__/test_list_saved_unit.cpython-313-pytest-8.4.1.pyc deleted file mode 100644 index 3574518..0000000 Binary files a/tests/__pycache__/test_list_saved_unit.cpython-313-pytest-8.4.1.pyc and /dev/null differ diff --git a/tests/__pycache__/test_minimal.cpython-313-pytest-8.4.1.pyc b/tests/__pycache__/test_minimal.cpython-313-pytest-8.4.1.pyc deleted file mode 100644 index b62c3b5..0000000 Binary files a/tests/__pycache__/test_minimal.cpython-313-pytest-8.4.1.pyc and /dev/null differ diff --git a/tests/__pycache__/test_npc_chat_demo.cpython-313-pytest-8.4.1.pyc b/tests/__pycache__/test_npc_chat_demo.cpython-313-pytest-8.4.1.pyc deleted file mode 100644 index 32b7a9c..0000000 Binary files a/tests/__pycache__/test_npc_chat_demo.cpython-313-pytest-8.4.1.pyc and /dev/null differ diff --git a/tests/__pycache__/test_npc_features.cpython-313-pytest-8.4.1.pyc b/tests/__pycache__/test_npc_features.cpython-313-pytest-8.4.1.pyc deleted file mode 100644 index 92b334d..0000000 Binary files a/tests/__pycache__/test_npc_features.cpython-313-pytest-8.4.1.pyc and /dev/null differ diff --git a/tests/__pycache__/test_npc_integration.cpython-313-pytest-8.4.1.pyc b/tests/__pycache__/test_npc_integration.cpython-313-pytest-8.4.1.pyc deleted file mode 100644 index 8e8089a..0000000 Binary files a/tests/__pycache__/test_npc_integration.cpython-313-pytest-8.4.1.pyc and /dev/null differ diff --git a/tests/__pycache__/test_npc_summary.cpython-313-pytest-8.4.1.pyc b/tests/__pycache__/test_npc_summary.cpython-313-pytest-8.4.1.pyc deleted file mode 100644 index de07392..0000000 Binary files a/tests/__pycache__/test_npc_summary.cpython-313-pytest-8.4.1.pyc and /dev/null differ diff --git a/tests/__pycache__/test_npc_unit.cpython-313-pytest-8.4.1.pyc b/tests/__pycache__/test_npc_unit.cpython-313-pytest-8.4.1.pyc deleted file mode 100644 index 520079c..0000000 Binary files a/tests/__pycache__/test_npc_unit.cpython-313-pytest-8.4.1.pyc and /dev/null differ diff --git a/tests/__pycache__/test_summary.cpython-313-pytest-8.4.1.pyc b/tests/__pycache__/test_summary.cpython-313-pytest-8.4.1.pyc deleted file mode 100644 index 17098d9..0000000 Binary files a/tests/__pycache__/test_summary.cpython-313-pytest-8.4.1.pyc and /dev/null differ diff --git a/tests/__pycache__/test_sync.cpython-313-pytest-8.4.1.pyc b/tests/__pycache__/test_sync.cpython-313-pytest-8.4.1.pyc deleted file mode 100644 index db2fb5a..0000000 Binary files a/tests/__pycache__/test_sync.cpython-313-pytest-8.4.1.pyc and /dev/null differ diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index e5337d5..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,276 +0,0 @@ -""" -Pytest configuration and fixtures for GumYum NPC client tests -""" - -import os -import sys -import pytest -import pytest_asyncio -import asyncio -import uuid -from typing import Dict, Any, Optional - -# Add parent directory to path for imports -sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) - -# Import both client versions -from gumyum_npc_client import GumYumClient as AsyncGumYumClient -from gumyum_npc_sync import GumYumClient as SyncGumYumClient - - -@pytest.fixture(scope="session") -def api_base_url() -> str: - """Base URL for the GumYum NPC API""" - return os.getenv("GUMYUM_API_URL", "http://localhost:6543") - - -@pytest.fixture(scope="function") -def test_credentials() -> Dict[str, str]: - """Test user credentials with unique username""" - unique_id = str(uuid.uuid4())[:8] - return { - "username": os.getenv("TEST_USERNAME", f"pytest_user_{unique_id}"), - "password": os.getenv("TEST_PASSWORD", "pytest_password_123"), - "email": os.getenv("TEST_EMAIL", f"pytest_{unique_id}@example.com"), - } - - -@pytest_asyncio.fixture -async def async_client(api_base_url: str) -> AsyncGumYumClient: - """Async client fixture with automatic cleanup""" - client = AsyncGumYumClient(api_base_url, timeout=60.0) - try: - yield client - finally: - await client.close() - - -@pytest.fixture -def sync_client(api_base_url: str) -> SyncGumYumClient: - """Sync client fixture with automatic cleanup""" - client = SyncGumYumClient(api_base_url, timeout=60.0) - try: - yield client - finally: - client.close() - - -@pytest_asyncio.fixture -async def authenticated_async_client( - async_client: AsyncGumYumClient, test_credentials: Dict[str, str] -) -> AsyncGumYumClient: - """Async client that's already authenticated""" - # Register new user (since we use unique usernames now) - await async_client.auth.register( - test_credentials["username"], - test_credentials["password"], - test_credentials["email"], - ) - - return async_client - - -@pytest.fixture -def authenticated_sync_client( - sync_client: SyncGumYumClient, test_credentials: Dict[str, str] -) -> SyncGumYumClient: - """Sync client that's already authenticated""" - # Register new user (since we use unique usernames now) - sync_client.auth.register( - test_credentials["username"], - test_credentials["password"], - test_credentials["email"], - ) - - return sync_client - - -@pytest.fixture(scope="session") -def event_loop(): - """Create an event loop for async tests""" - loop = asyncio.get_event_loop_policy().new_event_loop() - yield loop - loop.close() - - -@pytest.fixture -def sample_universe_data() -> Dict[str, Any]: - """Sample universe data for testing""" - return { - "theme_info": { - "name": "Test Universe", - "description": "A test universe for functional testing", - "version": "1.0", - "author": "pytest", - "is_public": False, - "builtin": False, - }, - "character_names": { - "human_male": ["TestMale1", "TestMale2"], - "human_female": ["TestFemale1", "TestFemale2"], - }, - "professions": { - "tavern": ["Bartender", "Server"], - "market": ["Merchant", "Vendor"], - }, - "world_locations": { - "tavern": { - "name": "The Test Tavern", - "description": "A cozy testing establishment", - }, - "market": { - "name": "Test Market Square", - "description": "A bustling marketplace for tests", - }, - }, - "mood_categories": { - "neutral": { - "moods": ["calm", "focused"], - "description": "Balanced emotional state", - } - }, - } - - -@pytest_asyncio.fixture -async def test_universe_id( - authenticated_async_client: AsyncGumYumClient, sample_universe_data: Dict[str, Any] -) -> str: - """Create a test universe and return its ID""" - # Since universe creation endpoint has issues, copy a public universe instead - universes = await authenticated_async_client.universes.list_public() - if not universes: - pytest.skip("No public universes available for testing") - - universe_id = await authenticated_async_client.universes.copy_public( - universes[0].id, "Test Universe for Async Tests" - ) - return universe_id - - -@pytest.fixture -def sync_test_universe_id( - authenticated_sync_client: SyncGumYumClient, sample_universe_data: Dict[str, Any] -) -> str: - """Create a test universe for sync client and return its ID""" - # Since universe creation endpoint has issues, copy a public universe instead - universes = authenticated_sync_client.universes.list_public() - if not universes: - pytest.skip("No public universes available for testing") - - universe_id = authenticated_sync_client.universes.copy_public( - universes[0].id, "Test Universe for Sync Tests" - ) - return universe_id - - -@pytest_asyncio.fixture -async def test_npc( - authenticated_async_client: AsyncGumYumClient, test_universe_id: str -): - """Spawn a test NPC""" - npc = await authenticated_async_client.npc.spawn( - test_universe_id, seed=12345, npc_id=123456789 - ) - return npc - - -@pytest.fixture -def sync_test_npc( - authenticated_sync_client: SyncGumYumClient, sync_test_universe_id: str -): - """Spawn a test NPC for sync client""" - npc = authenticated_sync_client.npc.spawn( - sync_test_universe_id, seed=12345, npc_id=123456789 - ) - return npc - - -# Skip tests if API is not available -def pytest_configure(config): - """Configure pytest markers""" - config.addinivalue_line( - "markers", "integration: mark test as requiring live API endpoint" - ) - config.addinivalue_line( - "markers", - "requires_universe: mark test as requiring working universe management endpoints", - ) - - -def pytest_collection_modifyitems(config, items): - """Skip integration tests if API is not available""" - if config.getoption("--skip-integration"): - skip_integration = pytest.mark.skip(reason="--skip-integration option given") - for item in items: - if "integration" in item.keywords: - item.add_marker(skip_integration) - - # Temporarily disable NPC test skipping to debug the actual issue - # skip_npc = pytest.mark.skip(reason="NPC spawning/chat tests failing due to server cache issues") - # npc_test_names = [ - # "test_spawn_npc_deterministic", - # "test_spawn_npc_auto_increment", - # "test_spawn_with_location_filter", - # "test_npc_crud_operations", - # "test_simple_chat_completion", - # "test_chat_completion_full_response", - # "test_streaming_chat_completion", - # "test_conversation_with_history", - # "test_chat_with_npc_params", - # "test_personality_consistency", - # "test_concurrent_requests", - # "test_concurrent_requests_threaded", - # "test_streaming_response_timing", - # "test_streaming_vs_non_streaming_consistency" - # ] - # for item in items: - # if item.name in npc_test_names: - # item.add_marker(skip_npc) - - -def pytest_addoption(parser): - """Add command line options""" - parser.addoption( - "--skip-integration", - action="store_true", - default=False, - help="Skip integration tests that require live API", - ) - parser.addoption( - "--api-url", - action="store", - default="http://localhost:6543", - help="GumYum API base URL for testing", - ) - - -# Health check utility -async def check_api_health(base_url: str) -> bool: - """Check if the API is available""" - try: - client = AsyncGumYumClient(base_url, timeout=5.0) - health = await client.health_check() - await client.close() - return health.get("status") == "healthy" - except Exception: - return False - - -def check_api_health_sync(base_url: str) -> bool: - """Check if the API is available (sync version)""" - try: - client = SyncGumYumClient(base_url, timeout=5.0) - health = client.health_check() - client.close() - return health.get("status") == "healthy" - except Exception: - return False - - -@pytest_asyncio.fixture(autouse=True) -async def skip_if_api_unavailable(request, api_base_url: str): - """Skip tests if API is not available""" - if "integration" in request.keywords: - if not await check_api_health(api_base_url): - pytest.skip(f"API not available at {api_base_url}") diff --git a/tests/pytest.ini b/tests/pytest.ini deleted file mode 100644 index e502066..0000000 --- a/tests/pytest.ini +++ /dev/null @@ -1,26 +0,0 @@ -[tool:pytest] -testpaths = tests -python_files = test_*.py -python_classes = Test* -python_functions = test_* -asyncio_mode = auto -asyncio_default_fixture_loop_scope = function -addopts = - -v - --tb=short - --strict-markers - --disable-warnings - --color=yes -markers = - integration: Tests that require a live API endpoint - slow: Tests that may take longer to complete - auth: Authentication-related tests - npc: NPC management tests - chat: Chat completion tests - universe: Universe management tests - streaming: Tests involving streaming responses - compatibility: Tests comparing async and sync clients -filterwarnings = - ignore::DeprecationWarning - ignore::PendingDeprecationWarning -timeout = 300 \ No newline at end of file diff --git a/tests/requirements-test.txt b/tests/requirements-test.txt deleted file mode 100644 index bb6852c..0000000 --- a/tests/requirements-test.txt +++ /dev/null @@ -1,18 +0,0 @@ -# Testing dependencies for GumYum NPC client -pytest>=7.4.0 -pytest-asyncio>=0.21.0 -pytest-timeout>=2.1.0 -pytest-mock>=3.11.0 -pytest-cov>=4.1.0 - -# HTTP libraries for both client versions -httpx>=0.24.0 -requests>=2.31.0 - -# Optional dependencies for enhanced testing -pytest-xdist>=3.3.0 # Parallel test execution -pytest-benchmark>=4.0.0 # Performance benchmarking -pytest-html>=3.2.0 # HTML test reports - -# Data validation (optional, for enhanced model testing) -pydantic>=2.0.0 \ No newline at end of file diff --git a/tests/test_async.py b/tests/test_async.py deleted file mode 100644 index 57ef317..0000000 --- a/tests/test_async.py +++ /dev/null @@ -1,749 +0,0 @@ -""" -Async tests for GumYum NPC client -""" - -import pytest -import asyncio -from typing import Dict, Any - -from gumyum_npc_client import ( - GumYumClient, - GumYumError, - GumYumAuthError, - GumYumNotFoundError, - GumYumValidationError, - NPC, - ChatCompletion, - AuthToken, -) - -# Mark entire module as requiring asyncio -pytestmark = [pytest.mark.asyncio, pytest.mark.integration] - - -async def test_register_and_login( - async_client: GumYumClient, test_credentials: Dict[str, str] -): - """Test user registration and login""" - # Test registration - using unique credentials from fixture - token = await async_client.auth.register( - test_credentials["username"], - test_credentials["password"], - test_credentials["email"], - ) - - assert isinstance(token, AuthToken) - assert token.access_token - assert token.user_id - assert async_client.auth.is_authenticated() - - # Test logout - async_client.auth.logout() - assert not async_client.auth.is_authenticated() - - # Test login - login_token = await async_client.auth.login( - test_credentials["username"], test_credentials["password"] - ) - - assert isinstance(login_token, AuthToken) - assert login_token.access_token - assert async_client.auth.is_authenticated() - - -async def test_invalid_credentials(async_client: GumYumClient): - """Test authentication with invalid credentials""" - with pytest.raises(GumYumAuthError): - await async_client.auth.login("invalid_user", "invalid_password") - - -async def test_get_profile(authenticated_async_client: GumYumClient): - """Test getting user profile""" - profile = await authenticated_async_client.auth.get_profile() - - assert profile.user_id - assert profile.username - assert profile.email - assert profile.created_at - # Note: statistics field not in UserProfile model yet - - -async def test_list_public_universes(async_client: GumYumClient): - """Test listing public universes""" - universes = await async_client.universes.list_public() - - assert isinstance(universes, list) - if universes: - universe = universes[0] - assert universe.id - assert universe.name - assert universe.description - - -async def test_copy_public_universe(authenticated_async_client: GumYumClient): - """Test copying a public universe""" - # Get public universes - universes = await authenticated_async_client.universes.list_public() - if not universes: - pytest.skip("No public universes available") - - # Copy the first universe - universe_id = await authenticated_async_client.universes.copy_public( - universes[0].id, "Test Copy" - ) - - assert universe_id - - # For now, just verify we got a valid universe ID back - # Note: API issue where copied universes don't appear in list_user() - assert isinstance(universe_id, str) - assert len(universe_id) > 0 - - -async def test_create_universe( - authenticated_async_client: GumYumClient, sample_universe_data: Dict[str, Any] -): - """Test creating a new universe""" - universe_id = await authenticated_async_client.universes.create( - sample_universe_data - ) - - assert universe_id - - # Verify we can get the universe - universe = await authenticated_async_client.universes.get(universe_id) - assert universe.theme_info.name == sample_universe_data["theme_info"]["name"] - - -async def test_universe_crud_operations( - authenticated_async_client: GumYumClient, sample_universe_data: Dict[str, Any] -): - """Test full CRUD operations on universes""" - # Create - universe_id = await authenticated_async_client.universes.create( - sample_universe_data - ) - assert universe_id - - # Read - universe = await authenticated_async_client.universes.get(universe_id) - assert universe.theme_info.name == sample_universe_data["theme_info"]["name"] - - # Update - updated_data = sample_universe_data.copy() - updated_data["theme_info"]["description"] = "Updated test description" - updated_universe = await authenticated_async_client.universes.update( - universe_id, updated_data - ) - assert updated_universe.theme_info.description == "Updated test description" - - # Fork - forked_id = await authenticated_async_client.universes.fork( - universe_id, "Forked Universe" - ) - assert forked_id - assert forked_id != universe_id - - # Delete forked universe (test deletion capability) - # Note: Delete functionality exists but may need server restart to work properly - try: - result = await authenticated_async_client.universes.delete(forked_id) - assert result is True - - # Verify forked universe is deleted - with pytest.raises(GumYumNotFoundError): - await authenticated_async_client.universes.get(forked_id) - except Exception as e: - # If delete fails, just verify the forked universe was created successfully - verify_fork = await authenticated_async_client.universes.get(forked_id) - assert verify_fork.id == forked_id - - -@pytest.mark.requires_universe -async def test_spawn_npc_deterministic( - authenticated_async_client: GumYumClient, test_universe_id: str -): - """Test deterministic NPC spawning""" - # Spawn NPC with specific index - npc1 = await authenticated_async_client.npc.spawn( - test_universe_id, seed=12345, npc_id=6666666666666666666 - ) - npc2 = await authenticated_async_client.npc.spawn( - test_universe_id, seed=12345, npc_id=6666666666666666666 - ) - - # Should be identical - assert isinstance(npc1, NPC) - assert isinstance(npc2, NPC) - assert npc1.name == npc2.name - assert npc1.profession == npc2.profession - assert npc1.personality_type == npc2.personality_type - assert npc1.mood == npc2.mood - - -@pytest.mark.requires_universe -async def test_spawn_npc_random_seed( - authenticated_async_client: GumYumClient, test_universe_id: str -): - """Test random NPC spawning with different seeds""" - npc1 = await authenticated_async_client.npc.spawn_random( - test_universe_id, seed=54321 - ) - npc2 = await authenticated_async_client.npc.spawn_random( - test_universe_id, seed=54322 - ) - - # Should be different NPCs (different seeds) - assert isinstance(npc1, NPC) - assert isinstance(npc2, NPC) - assert npc1.npc_id != npc2.npc_id - - -@pytest.mark.requires_universe -async def test_spawn_with_location_filter( - authenticated_async_client: GumYumClient, test_universe_id: str -): - """Test NPC spawning with location filter""" - npc = await authenticated_async_client.npc.spawn( - test_universe_id, seed=67890, npc_id=678901234, location_filter=["tavern"] - ) - - assert isinstance(npc, NPC) - assert npc.name - assert npc.profession - - -@pytest.mark.requires_universe -async def test_npc_crud_operations( - authenticated_async_client: GumYumClient, test_universe_id: str -): - """Test NPC CRUD operations""" - # Spawn and save NPC - spawned_npc = await authenticated_async_client.npc.spawn( - test_universe_id, seed=11111, npc_id=111111111 - ) - saved_npc = await authenticated_async_client.npc.save( - test_universe_id, 11111, 111111111, "Test Saved NPC" - ) - - assert saved_npc.npc_id - assert saved_npc.name - - # List saved NPCs - npc_list = await authenticated_async_client.npc.list_saved() - assert isinstance(npc_list, list) - - # Get NPC profile - profile = await authenticated_async_client.npc.get_profile(saved_npc.npc_id) - assert profile.npc_id == saved_npc.npc_id - - # Update NPC - updated_npc = await authenticated_async_client.npc.update( - saved_npc.npc_id, {"mood": "excited"} - ) - assert updated_npc.npc_id == saved_npc.npc_id - - # Get stats - stats = await authenticated_async_client.npc.get_stats() - assert stats.total_npcs >= 1 - - # Delete NPC - result = await authenticated_async_client.npc.delete(saved_npc.npc_id) - assert result is True - - -@pytest.mark.requires_universe -async def test_simple_chat_completion( - authenticated_async_client: GumYumClient, test_universe_id: str -): - """Test basic chat completion and chat_with_history""" - # First spawn the NPC to get its details - npc = await authenticated_async_client.npc.spawn( - test_universe_id, seed=12345, npc_id=7777777777777777777 - ) - - # Use the new npc.chat.completions() method - response = await npc.chat.completions( - [{"role": "user", "content": "Hello! What's your name?"}] - ) - response_content = response.choices[0].message.content - - assert isinstance(response_content, str) - assert len(response_content) > 0 - # Should mention the NPC's name - assert npc.name.lower() in response_content.lower() - - # Test chat_with_history for automatic conversation tracking - response2 = await npc.chat_with_history("What brings you joy in life?") - assert response2.choices[0].message.content - - # Verify history is tracked - assert len(npc.chat_history) >= 2 - assert any( - "joy" in msg["content"] for msg in npc.chat_history if msg["role"] == "user" - ) - - -@pytest.mark.requires_universe -async def test_chat_completion_full_response( - authenticated_async_client: GumYumClient, test_universe_id: str -): - """Test full chat completion response""" - # First spawn the NPC to get its details - npc = await authenticated_async_client.npc.spawn( - test_universe_id, seed=12345, npc_id=7777777777777777777 - ) - - # Use npc.completions() to get full ChatCompletion - completion = await npc.completions( - [{"role": "user", "content": "Tell me about your profession."}], - temperature=0.7, - max_tokens=150, - ) - - assert isinstance(completion, ChatCompletion) - assert completion.choices - assert len(completion.choices) > 0 - - choice = completion.choices[0] - assert choice.message.role == "assistant" - assert choice.message.content - assert len(choice.message.content) > 0 - - # Should provide a relevant professional response - # Check for profession-related content rather than exact profession name - content_lower = choice.message.content.lower() - profession_lower = npc.profession.lower() - - # Define profession-related keywords for common professions - profession_keywords = { - "archaeologist": [ - "archaeolog", - "artifact", - "excavat", - "ancient", - "dig", - "history", - "past", - ], - "artisan": ["craft", "create", "make", "art", "skill", "design", "work"], - "brood mother": [ - "children", - "family", - "care", - "nurture", - "offspring", - "young", - "mother", - ], - "warrior": ["fight", "battle", "combat", "weapon", "war", "defend", "protect"], - "mage": ["magic", "spell", "enchant", "arcane", "mystic", "power"], - "merchant": ["trade", "sell", "buy", "business", "commerce", "goods", "market"], - } - - # Check if the response mentions the profession directly or related keywords - profession_mentioned = profession_lower in content_lower or any( - keyword in content_lower - for keyword in profession_keywords.get(profession_lower, [profession_lower]) - ) - - # Also accept if the response is about work/job/profession in general - work_related = any( - word in content_lower - for word in ["work", "job", "profession", "career", "do", "am"] - ) - - assert ( - profession_mentioned or work_related - ), f"NPC with profession '{npc.profession}' should provide relevant professional response. Got: {choice.message.content[:100]}..." - - -@pytest.mark.requires_universe -async def test_streaming_chat_completion( - authenticated_async_client: GumYumClient, test_universe_id: str -): - """Test streaming chat completion""" - # First spawn the NPC - npc = await authenticated_async_client.npc.spawn( - test_universe_id, seed=12345, npc_id=7777777777777777777 - ) - - # Use npc.chat.completions() with streaming - stream = await npc.chat.completions( - [{"role": "user", "content": "Tell me a short story about your day."}], - temperature=0.8, - stream=True, - ) - - chunks = [] - content_pieces = [] - - async for chunk in stream: - chunks.append(chunk) - if "choices" in chunk and chunk["choices"]: - delta = chunk["choices"][0].get("delta", {}) - content = delta.get("content", "") - if content: - content_pieces.append(content) - - assert len(chunks) > 0 - assert len(content_pieces) > 0 - - # Reconstruct full response - full_response = "".join(content_pieces) - assert len(full_response) > 0 - - -@pytest.mark.requires_universe -async def test_conversation_with_history( - authenticated_async_client: GumYumClient, test_universe_id: str -): - """Test multi-turn conversation""" - npc_params = { - "universe_id": test_universe_id, - "world_seed": 12345, - "npc_id": 7777777777777777777, - } - - # First message - response1 = await authenticated_async_client.chat.completions( - npc_params=npc_params, - messages=[{"role": "user", "content": "What's your favorite color?"}], - ) - - # Continue conversation - conversation_history = [ - {"role": "user", "content": "What's your favorite color?"}, - {"role": "assistant", "content": response1.choices[0].message.content}, - ] - - response2 = await authenticated_async_client.chat.completions( - npc_params=npc_params, - messages=conversation_history - + [{"role": "user", "content": "Why do you like that color?"}], - ) - - assert isinstance(response2, ChatCompletion) - assert response2.choices[0].message.content - - # Response should be contextually relevant - assert len(response2.choices[0].message.content) > 0 - - -@pytest.mark.requires_universe -async def test_chat_with_npc_params( - authenticated_async_client: GumYumClient, test_universe_id: str -): - """Test chat with temporary NPC parameters""" - completion = await authenticated_async_client.chat.completions( - npc_params={ - "universe_id": test_universe_id, - "world_seed": 99999, - "npc_id": 8888888888888888888, - }, - messages=[{"role": "user", "content": "Who are you?"}], - temperature=0.5, - ) - - assert isinstance(completion, ChatCompletion) - assert completion.choices[0].message.content - assert len(completion.choices[0].message.content) > 0 - - -@pytest.mark.requires_universe -async def test_personality_consistency( - authenticated_async_client: GumYumClient, test_universe_id: str -): - """Test that NPC maintains personality consistency""" - npc_params = { - "universe_id": test_universe_id, - "world_seed": 12345, - "npc_id": 7777777777777777777, - } - - # Ask multiple personality-related questions - questions = [ - "How do you handle stress?", - "What motivates you?", - "How do you interact with strangers?", - ] - - responses = [] - for question in questions: - completion = await authenticated_async_client.chat.completions( - npc_params=npc_params, messages=[{"role": "user", "content": question}] - ) - response = completion.choices[0].message.content - responses.append(response) - - # All responses should exist and be substantial - for response in responses: - assert isinstance(response, str) - assert len(response) > 20 # Substantial response - - # Responses should be consistent with NPC's personality type - # This is a basic check - more sophisticated personality analysis could be added - assert all(len(r) > 0 for r in responses) - - -async def test_invalid_npc_id(authenticated_async_client: GumYumClient): - """Test error handling with invalid NPC ID""" - with pytest.raises((GumYumNotFoundError, GumYumValidationError)): - await authenticated_async_client.chat.completions( - npc_id="invalid_npc_id", messages=[{"role": "user", "content": "Hello"}] - ) - - -async def test_invalid_universe_id(authenticated_async_client: GumYumClient): - """Test error handling with invalid universe ID""" - with pytest.raises((GumYumNotFoundError, GumYumValidationError)): - await authenticated_async_client.npc.spawn("invalid_universe_id", seed=12345) - - -async def test_unauthenticated_access(async_client: GumYumClient): - """Test that protected endpoints require authentication""" - with pytest.raises((GumYumAuthError, GumYumValidationError)): - await async_client.universes.list_user() - - -async def test_malformed_universe_data(authenticated_async_client: GumYumClient): - """Test validation error handling""" - invalid_data = {"invalid": "data"} - - # API doesn't currently validate universe data structure, just accepts anything - # For now, just test that create doesn't crash with invalid data - try: - universe_id = await authenticated_async_client.universes.create(invalid_data) - # If we get here, the API accepted the invalid data (which is current behavior) - assert universe_id - except Exception as e: - # If we get an exception, that's also fine (validation working) - assert "validation" in str(e).lower() or "error" in str(e).lower() - - -async def test_context_manager(api_base_url: str): - """Test using client as async context manager""" - async with GumYumClient(api_base_url) as client: - health = await client.health_check() - assert health.get("status") == "healthy" - - # Client should be closed after context - with pytest.raises(Exception): - await client.health_check() - - -async def test_health_check(async_client: GumYumClient): - """Test API health check""" - health = await async_client.health_check() - - assert isinstance(health, dict) - assert health.get("status") == "healthy" - - -@pytest.mark.requires_universe -async def test_concurrent_requests( - authenticated_async_client: GumYumClient, test_universe_id: str -): - """Test handling concurrent requests""" - tasks = [ - authenticated_async_client.npc.spawn( - test_universe_id, seed=i, npc_id=100000000 + i - ) - for i in range(3) - ] - - results = await asyncio.gather(*tasks) - - assert len(results) == 3 - for result in results: - assert isinstance(result, NPC) - assert result.npc_id - - -# ============================================================================ -# NEW NPC CHAT TESTS -# ============================================================================ - - -@pytest.mark.requires_universe -async def test_npc_client_attachment( - authenticated_async_client: GumYumClient, test_universe_id: str -): - """Test that NPCs have client attached after spawning""" - npc = await authenticated_async_client.npc.spawn( - test_universe_id, seed=99999, npc_id=9999999999 - ) - - assert npc.client is authenticated_async_client - assert hasattr(npc, "chat") - assert hasattr(npc.chat, "completions") - - -@pytest.mark.requires_universe -async def test_npc_chat_completions_method( - authenticated_async_client: GumYumClient, test_universe_id: str -): - """Test npc.chat.completions() method""" - npc = await authenticated_async_client.npc.spawn( - test_universe_id, seed=88888, npc_id=8888888888 - ) - - # Test basic chat completion - response = await npc.chat.completions( - [{"role": "user", "content": "What is your favorite color?"}] - ) - - assert isinstance(response, ChatCompletion) - assert response.choices - assert len(response.choices) > 0 - assert response.choices[0].message.content - - -@pytest.mark.requires_universe -async def test_npc_completions_alias( - authenticated_async_client: GumYumClient, test_universe_id: str -): - """Test npc.completions() alias method""" - npc = await authenticated_async_client.npc.spawn( - test_universe_id, seed=77777, npc_id=7777777777 - ) - - # Test using the alias - response = await npc.completions( - [{"role": "user", "content": "Tell me about your hobbies"}] - ) - - assert isinstance(response, ChatCompletion) - assert response.choices[0].message.content - - -@pytest.mark.requires_universe -async def test_npc_chat_with_parameters( - authenticated_async_client: GumYumClient, test_universe_id: str -): - """Test npc.chat.completions() with various parameters""" - npc = await authenticated_async_client.npc.spawn( - test_universe_id, seed=66666, npc_id=6666666666 - ) - - # Test with temperature and max_tokens - response = await npc.chat.completions( - messages=[{"role": "user", "content": "Describe the weather"}], - temperature=0.5, - max_tokens=50, - ) - - assert isinstance(response, ChatCompletion) - assert len(response.choices[0].message.content) > 0 - - # Test with different temperature - response2 = await npc.completions( - messages=[{"role": "user", "content": "Describe the weather"}], temperature=1.5 - ) - - assert isinstance(response2, ChatCompletion) - - -@pytest.mark.requires_universe -async def test_npc_streaming_via_chat_proxy( - authenticated_async_client: GumYumClient, test_universe_id: str -): - """Test streaming through npc.chat.completions()""" - npc = await authenticated_async_client.npc.spawn( - test_universe_id, seed=55555, npc_id=5555555555 - ) - - # Test streaming - stream = await npc.chat.completions( - messages=[{"role": "user", "content": "Count to five slowly"}], stream=True - ) - - chunks = [] - async for chunk in stream: - chunks.append(chunk) - if "choices" in chunk and chunk["choices"]: - delta = chunk["choices"][0].get("delta", {}) - if "content" in delta: - assert isinstance(delta["content"], str) - - assert len(chunks) > 0 - - -@pytest.mark.requires_universe -async def test_npc_conversation_history( - authenticated_async_client: GumYumClient, test_universe_id: str -): - """Test conversation with history through NPC object""" - npc = await authenticated_async_client.npc.spawn( - test_universe_id, seed=44444, npc_id=4444444444 - ) - - # First message - response1 = await npc.completions( - [{"role": "user", "content": "My name is TestBot"}] - ) - - # Continue conversation with history - response2 = await npc.completions( - [ - {"role": "user", "content": "My name is TestBot"}, - {"role": "assistant", "content": response1.choices[0].message.content}, - {"role": "user", "content": "What did I just tell you my name was?"}, - ] - ) - - assert isinstance(response2, ChatCompletion) - # Response should reference the name somehow - content = response2.choices[0].message.content.lower() - assert "testbot" in content or "test" in content or "bot" in content - - -@pytest.mark.asyncio -async def test_npc_without_client_fails(): - """Test that NPC without client raises proper error""" - from gumyum_npc_client import NPCChatProxy, SpawnedData - - # Create NPC without client - npc = NPC( - npc_id=123456789, - name="Test NPC", - profession="Tester", - personality_type=5, - spawned=SpawnedData(location="test", mood="neutral", stress_level=5), - universe_id="test-universe", - seed=12345, - ) - - # Should not have client - assert npc.client is None - - # Should raise error when trying to chat - with pytest.raises(ValueError, match="NPC has no client reference"): - await npc.chat.completions([{"role": "user", "content": "Hello"}]) - - -@pytest.mark.requires_universe -async def test_npc_preserves_context( - authenticated_async_client: GumYumClient, test_universe_id: str -): - """Test that NPC properly uses its own context for all chats""" - # Spawn two different NPCs - npc1 = await authenticated_async_client.npc.spawn( - test_universe_id, seed=11111, npc_id=1111111111 - ) - npc2 = await authenticated_async_client.npc.spawn( - test_universe_id, seed=22222, npc_id=2222222222 - ) - - # Chat with both - response1 = await npc1.completions( - [{"role": "user", "content": "What is your name?"}] - ) - response2 = await npc2.completions( - [{"role": "user", "content": "What is your name?"}] - ) - - # Their names should be different and match what was spawned - assert npc1.name.lower() in response1.choices[0].message.content.lower() - assert npc2.name.lower() in response2.choices[0].message.content.lower() - assert response1.choices[0].message.content != response2.choices[0].message.content diff --git a/tests/test_auth_retry_fix.py b/tests/test_auth_retry_fix.py deleted file mode 100644 index 6b53204..0000000 --- a/tests/test_auth_retry_fix.py +++ /dev/null @@ -1,184 +0,0 @@ -""" -Test that the client doesn't send Authorization:[] headers after auth failures. -This test ensures HTTP 500 errors are prevented by proper auth handling. -""" - -import pytest -from unittest.mock import Mock, patch, MagicMock -import httpx -from gumyum_npc_client import GumYumClient, GumYumAuthError - - -class TestAuthRetryFix: - """Test that auth retry logic doesn't create Authorization:[] headers""" - - @pytest.mark.asyncio - async def test_no_retry_with_cleared_token(self): - """Test that client doesn't retry after clearing token on 401 error""" - client = GumYumClient("http://test.com") - - # Set a valid token initially - client.set_token("valid_token") - - # Mock the httpx response to return 401 - mock_response = Mock() - mock_response.status_code = 401 - mock_response.headers = {"content-type": "application/json"} - mock_response.text = '{"error": "Unauthorized"}' - mock_response.json.return_value = {"error": "Unauthorized"} - - # Track all requests made - request_headers = [] - - async def mock_request(*args, **kwargs): - # Capture headers from each request - request_headers.append(kwargs.get("headers", {})) - return mock_response - - # Replace the httpx client request method - with patch.object(client._client, "request", side_effect=mock_request): - # This should fail with auth error, not retry - with pytest.raises(GumYumAuthError) as exc_info: - await client.get("npc", params={"universe_id": "test", "seed": "123"}) - - # Should contain "token cleared" in the error message - assert "token cleared" in str(exc_info.value) - - # Should only make ONE request (no retry) - assert len(request_headers) == 1 - - # The single request should have had the valid token - assert request_headers[0].get("Authorization") == "Bearer valid_token" - - # Token should be cleared after 401 - assert client._token is None - - @pytest.mark.asyncio - async def test_no_empty_array_authorization_header(self): - """Test that Authorization header is never an empty array""" - client = GumYumClient("http://test.com") - - # Test various token states - test_cases = [ - (None, "no_header"), # No token -> no Authorization header - ("", "no_header"), # Empty string -> no Authorization header - ("valid", "Bearer valid"), # Valid token -> proper header - (" ", "no_header"), # Whitespace -> no Authorization header - ] - - for token, expected in test_cases: - client._token = token - headers = client.get_headers() - - if expected == "no_header": - assert ( - "Authorization" not in headers - ), f"Token {repr(token)} should not create Authorization header" - else: - assert ( - headers.get("Authorization") == expected - ), f"Token {repr(token)} should create {expected}" - - # Never should be an empty array - assert headers.get("Authorization") != [] - assert headers.get("Authorization") != [""] - - @pytest.mark.asyncio - async def test_auto_auth_disabled_after_explicit_auth(self): - """Test that auto-auth doesn't interfere with explicit authentication""" - client = GumYumClient("http://test.com") - - # Explicitly authenticate - client.set_token("explicit_token") - - # This should be set by set_token - assert hasattr(client, "_auth_attempted_by_user") - assert client._auth_attempted_by_user is True - - # Clear the token (simulating token expiry) - client._token = None - - # Mock a request that would normally trigger auto-auth - with patch.object(client, "_ensure_authenticated") as mock_ensure_auth: - with patch.object(client._client, "request") as mock_request: - mock_response = Mock() - mock_response.status_code = 401 - mock_response.headers = {"content-type": "application/json"} - mock_response.text = '{"error": "Unauthorized"}' - mock_request.return_value = mock_response - - try: - await client.get("npc", params={"test": "test"}) - except GumYumAuthError: - pass - - # _ensure_authenticated should have been called - mock_ensure_auth.assert_called_once() - - @pytest.mark.asyncio - async def test_headers_sanitization(self): - """Test that malformed Authorization headers are sanitized""" - client = GumYumClient("http://test.com") - client.set_token("valid_token") - - # Mock the request to check final headers - final_headers = None - - async def capture_headers(*args, **kwargs): - nonlocal final_headers - final_headers = kwargs.get("headers", {}) - mock_response = Mock() - mock_response.status_code = 200 - mock_response.headers = {"content-type": "application/json"} - mock_response.text = '{"test": "data"}' - return mock_response - - # Test that empty array Authorization is removed - with patch.object(client._client, "request", side_effect=capture_headers): - # Try to inject Authorization: [] via extra headers - await client._request("GET", "test", headers={"Authorization": []}) - - # The final headers should not have Authorization: [] - assert final_headers.get("Authorization") != [] - # It should either be the valid token or not present - assert final_headers.get("Authorization") in ["Bearer valid_token", None] - - def test_sync_client_same_behavior(self): - """Test that sync client has same auth retry behavior""" - from gumyum_npc_sync import GumYumClient as GumYumSyncClient - - client = GumYumSyncClient("http://test.com") - client.set_token("valid_token") - - # Mock the response to return 401 - mock_response = Mock() - mock_response.status_code = 401 - mock_response.headers = {"content-type": "application/json"} - mock_response.text = '{"error": "Unauthorized"}' - mock_response.json.return_value = {"error": "Unauthorized"} - - request_count = 0 - - def mock_request(*args, **kwargs): - nonlocal request_count - request_count += 1 - return mock_response - - # Replace the session request method - with patch.object(client._session, "request", side_effect=mock_request): - # This should fail with auth error, not retry - with pytest.raises(GumYumAuthError) as exc_info: - client.get("npc", params={"universe_id": "test", "seed": "123"}) - - # Should contain "token cleared" in the error message - assert "token cleared" in str(exc_info.value) - - # Should only make ONE request (no retry) - assert request_count == 1 - - # Token should be cleared after 401 - assert client._token is None - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/tests/test_filtering.py b/tests/test_filtering.py deleted file mode 100644 index cec656a..0000000 --- a/tests/test_filtering.py +++ /dev/null @@ -1,410 +0,0 @@ -#!/usr/bin/env python3 -""" -Tests for NPC filtering functionality in Python clients -""" - -import pytest -from typing import Dict, Any -import asyncio - -from gumyum_npc_client import GumYumClient as AsyncClient -from gumyum_npc_sync import GumYumClient as SyncClient -from gumyum_npc_client import NPC - -# Mark entire module as integration tests -pytestmark = pytest.mark.integration - - -class TestAsyncFiltering: - """Test async client filtering functionality""" - - @pytest.mark.asyncio - async def test_spawn_filtered_method_exists( - self, authenticated_async_client: AsyncClient - ): - """Test that spawn_filtered method exists and is callable""" - assert hasattr(authenticated_async_client.npcs, "spawn_filtered") - assert callable(authenticated_async_client.npc.spawn_filtered) - - @pytest.mark.asyncio - async def test_spawn_filtered_no_filters( - self, authenticated_async_client: AsyncClient, test_universe_id: str - ): - """Test spawn_filtered with no filters""" - result = await authenticated_async_client.npc.spawn_filtered( - universe_id=test_universe_id, world_seed=12345, filters={} - ) - - assert result is not None - assert isinstance(result, NPC) - assert result.npc_id > 0 - assert result.name - assert result.profession - - @pytest.mark.asyncio - async def test_spawn_filtered_profession_filter( - self, authenticated_async_client: AsyncClient, test_universe_id: str - ): - """Test spawn_filtered with profession filter""" - filters = {"profession": ["warrior", "knight", "guard", "soldier"]} - - result = await authenticated_async_client.npc.spawn_filtered( - universe_id=test_universe_id, - world_seed=54321, - filters=filters, - max_attempts=500, - ) - - if result: - assert result.profession in filters["profession"] - assert isinstance(result, NPC) - - @pytest.mark.asyncio - async def test_spawn_filtered_personality_filter( - self, authenticated_async_client: AsyncClient, test_universe_id: str - ): - """Test spawn_filtered with personality type filter""" - filters = {"personality_type": [1, 8, 9]} # Specific personality types - - result = await authenticated_async_client.npc.spawn_filtered( - universe_id=test_universe_id, - world_seed=99999, - filters=filters, - max_attempts=300, - ) - - if result: - assert result.personality_type in filters["personality_type"] - - @pytest.mark.asyncio - async def test_spawn_filtered_complex_filters( - self, authenticated_async_client: AsyncClient, test_universe_id: str - ): - """Test spawn_filtered with multiple complex filters""" - filters = { - "profession": ["warrior", "knight", "guard"], - "personality_type": [1, 8], - "stress_level": {"min": 0, "max": 5}, - } - - result = await authenticated_async_client.npc.spawn_filtered( - universe_id=test_universe_id, - world_seed=111111, - filters=filters, - start_npc_id=2000000000000000000, # Large 64-bit ID - max_attempts=1000, - ) - - if result: - assert result.profession in filters["profession"] - assert result.personality_type in filters["personality_type"] - # Note: NPC doesn't include stress_level, - # but the API filtering would have ensured it matches - - @pytest.mark.asyncio - async def test_spawn_filtered_large_npc_ids( - self, authenticated_async_client: AsyncClient, test_universe_id: str - ): - """Test spawn_filtered with large 64-bit NPC IDs""" - large_start_id = 7000000000000000000 # 7 quintillion - - result = await authenticated_async_client.npc.spawn_filtered( - universe_id=test_universe_id, - world_seed=12345, - filters={}, # No filters, just test large IDs - start_npc_id=large_start_id, - max_attempts=10, - ) - - if result: - assert result.npc_id >= large_start_id - - @pytest.mark.asyncio - async def test_spawn_filtered_impossible_filter( - self, authenticated_async_client: AsyncClient, test_universe_id: str - ): - """Test spawn_filtered with impossible filter returns None""" - filters = { - "profession": ["nonexistent_profession_12345"], - "personality_type": [99], # Invalid type - } - - result = await authenticated_async_client.npc.spawn_filtered( - universe_id=test_universe_id, - world_seed=12345, - filters=filters, - max_attempts=20, # Small number for quick test - ) - - # Should return None when no match found - assert result is None - - @pytest.mark.asyncio - async def test_spawn_filtered_parameter_validation( - self, authenticated_async_client: AsyncClient, test_universe_id: str - ): - """Test spawn_filtered parameter validation""" - # Test with various parameter combinations - result = await authenticated_async_client.npc.spawn_filtered( - universe_id=test_universe_id, - world_seed=12345, - filters={"personality_type": [5]}, - start_npc_id=1, - max_attempts=100, - ) - - # Should work with all parameters specified - assert result is None or isinstance(result, NPC) - - -class TestSyncFiltering: - """Test sync client filtering functionality""" - - def test_spawn_filtered_method_exists(self, authenticated_sync_client: SyncClient): - """Test that spawn_filtered method exists and is callable""" - assert hasattr(authenticated_sync_client.npcs, "spawn_filtered") - assert callable(authenticated_sync_client.npc.spawn_filtered) - - def test_spawn_filtered_no_filters( - self, authenticated_sync_client: SyncClient, sync_test_universe_id: str - ): - """Test spawn_filtered with no filters""" - result = authenticated_sync_client.npc.spawn_filtered( - universe_id=sync_test_universe_id, world_seed=12345, filters={} - ) - - assert result is not None - assert isinstance(result, NPC) - assert result.npc_id > 0 - assert result.name - assert result.profession - - def test_spawn_filtered_profession_filter( - self, authenticated_sync_client: SyncClient, sync_test_universe_id: str - ): - """Test spawn_filtered with profession filter""" - filters = {"profession": ["warrior", "knight", "guard", "soldier"]} - - result = authenticated_sync_client.npc.spawn_filtered( - universe_id=sync_test_universe_id, - world_seed=54321, - filters=filters, - max_attempts=500, - ) - - if result: - assert result.profession in filters["profession"] - assert isinstance(result, NPC) - - def test_spawn_filtered_personality_filter( - self, authenticated_sync_client: SyncClient, sync_test_universe_id: str - ): - """Test spawn_filtered with personality type filter""" - filters = {"personality_type": [2, 7]} # Helper or Enthusiast - - result = authenticated_sync_client.npc.spawn_filtered( - universe_id=sync_test_universe_id, - world_seed=77777, - filters=filters, - max_attempts=300, - ) - - if result: - assert result.personality_type in filters["personality_type"] - - def test_spawn_filtered_stress_level_filter( - self, authenticated_sync_client: SyncClient, sync_test_universe_id: str - ): - """Test spawn_filtered with stress level filter""" - filters = {"stress_level": {"min": 7, "max": 10}} # High stress - - result = authenticated_sync_client.npc.spawn_filtered( - universe_id=sync_test_universe_id, - world_seed=88888, - filters=filters, - max_attempts=400, - ) - - # Note: NPC doesn't include stress_level in return - # but the server filtering ensures it matches - if result: - assert isinstance(result, NPC) - - def test_spawn_filtered_location_filter( - self, authenticated_sync_client: SyncClient, sync_test_universe_id: str - ): - """Test spawn_filtered with location filter""" - filters = {"location_filter": ["castle", "barracks", "training_ground"]} - - result = authenticated_sync_client.npc.spawn_filtered( - universe_id=sync_test_universe_id, - world_seed=66666, - filters=filters, - max_attempts=200, - ) - - # Location filtering works during generation - if result: - assert isinstance(result, NPC) - - def test_spawn_filtered_gender_filter( - self, authenticated_sync_client: SyncClient, sync_test_universe_id: str - ): - """Test spawn_filtered with gender filter""" - filters = {"gender": ["female"]} - - result = authenticated_sync_client.npc.spawn_filtered( - universe_id=sync_test_universe_id, - world_seed=55555, - filters=filters, - max_attempts=300, - ) - - # Note: NPC doesn't include gender in return - # but the server filtering ensures it matches - if result: - assert isinstance(result, NPC) - - def test_spawn_filtered_mood_filter( - self, authenticated_sync_client: SyncClient, sync_test_universe_id: str - ): - """Test spawn_filtered with mood filter""" - filters = {"mood": ["happy", "excited", "cheerful"]} - - result = authenticated_sync_client.npc.spawn_filtered( - universe_id=sync_test_universe_id, - world_seed=44444, - filters=filters, - max_attempts=300, - ) - - if result: - assert result.mood in filters["mood"] - - def test_spawn_filtered_multiple_filters( - self, authenticated_sync_client: SyncClient, sync_test_universe_id: str - ): - """Test spawn_filtered with multiple filter types""" - filters = { - "profession": ["merchant", "trader", "vendor"], - "personality_type": [3, 7], # Achiever or Enthusiast - "mood": ["happy", "excited", "confident"], - } - - result = authenticated_sync_client.npc.spawn_filtered( - universe_id=sync_test_universe_id, - world_seed=33333, - filters=filters, - start_npc_id=3000000000000000000, # Large start ID - max_attempts=800, - ) - - if result: - assert result.profession in filters["profession"] - assert result.personality_type in filters["personality_type"] - assert result.mood in filters["mood"] - assert result.npc_id >= 3000000000000000000 - - def test_spawn_filtered_impossible_filter( - self, authenticated_sync_client: SyncClient, sync_test_universe_id: str - ): - """Test spawn_filtered with impossible filter returns None""" - filters = { - "profession": ["impossible_profession_xyz"], - "personality_type": [999], # Invalid type - } - - result = authenticated_sync_client.npc.spawn_filtered( - universe_id=sync_test_universe_id, - world_seed=12345, - filters=filters, - max_attempts=30, # Small number for quick test - ) - - # Should return None when no match found - assert result is None - - def test_spawn_filtered_edge_cases( - self, authenticated_sync_client: SyncClient, sync_test_universe_id: str - ): - """Test spawn_filtered edge cases""" - # Test with minimal max_attempts - result = authenticated_sync_client.npc.spawn_filtered( - universe_id=sync_test_universe_id, - world_seed=12345, - filters={}, - max_attempts=1, - ) - - # Should still work with just 1 attempt (no filtering) - assert result is not None - - # Test with very large start_npc_id - large_id = 8000000000000000000 # 8 quintillion - result = authenticated_sync_client.npc.spawn_filtered( - universe_id=sync_test_universe_id, - world_seed=12345, - filters={}, - start_npc_id=large_id, - max_attempts=5, - ) - - if result: - assert result.npc_id >= large_id - - -class TestFilteringPerformance: - """Test performance characteristics of filtering""" - - def test_filtering_reasonable_performance( - self, authenticated_sync_client: SyncClient, sync_test_universe_id: str - ): - """Test that filtering completes in reasonable time""" - import time - - filters = { - "personality_type": list(range(1, 10)) # All valid types (easy filter) - } - - start_time = time.time() - result = authenticated_sync_client.npc.spawn_filtered( - universe_id=sync_test_universe_id, - world_seed=12345, - filters=filters, - max_attempts=100, - ) - end_time = time.time() - - # Should complete quickly (within 10 seconds for integration test) - assert end_time - start_time < 10.0 - - # Should find a match with such a broad filter - assert result is not None - - @pytest.mark.asyncio - async def test_async_filtering_performance( - self, authenticated_async_client: AsyncClient, test_universe_id: str - ): - """Test async filtering performance""" - import time - - filters = {"personality_type": [1, 2, 3, 4, 5]} # Half the personality types - - start_time = time.time() - result = await authenticated_async_client.npc.spawn_filtered( - universe_id=test_universe_id, - world_seed=12345, - filters=filters, - max_attempts=200, - ) - end_time = time.time() - - # Should complete quickly - assert end_time - start_time < 10.0 - - if result: - assert result.personality_type in filters["personality_type"] - - -if __name__ == "__main__": - pytest.main([__file__]) diff --git a/tests/test_filtering_functional.py b/tests/test_filtering_functional.py deleted file mode 100644 index b0699b0..0000000 --- a/tests/test_filtering_functional.py +++ /dev/null @@ -1,548 +0,0 @@ -#!/usr/bin/env python3 -""" -Functional tests for NPC filtering - no Hermes dependency needed -Tests the complete filtering workflow using real universe data -""" - -import pytest -import time -from typing import Dict, Any, List - -from gumyum_npc_client import GumYumClient as AsyncClient -from gumyum_npc_sync import GumYumClient as SyncClient -from gumyum_npc_client import NPC - -# Mark entire module as integration tests -pytestmark = pytest.mark.integration - - -class TestFilteringFunctional: - """Functional tests for NPC filtering system""" - - def test_filtering_no_hermes_dependency( - self, authenticated_sync_client: SyncClient, sync_test_universe_id: str - ): - """Test that filtering works without any Hermes AI calls""" - # This should be fast since it's purely deterministic logic - start_time = time.time() - - result = authenticated_sync_client.npc.spawn_filtered( - universe_id=sync_test_universe_id, - world_seed=12345, - filters={"personality_type": [5]}, # Simple filter - max_attempts=50, - ) - - end_time = time.time() - - # Should complete very quickly (no AI calls) - assert end_time - start_time < 2.0 # Under 2 seconds - - if result: - assert result.personality_type == 5 - # Should have basic NPC data without backstory - assert result.name - assert result.profession - # No AI-generated content needed for filtering - - def test_find_combat_npcs( - self, authenticated_sync_client: SyncClient, sync_test_universe_id: str - ): - """Test finding combat-ready NPCs with specific traits""" - filters = { - "profession": ["warrior", "knight", "guard", "soldier"], - "personality_type": [1, 8], # Perfectionist or Challenger - "stress_level": {"min": 0, "max": 5}, # Battle-ready, not overstressed - } - - result = authenticated_sync_client.npc.spawn_filtered( - universe_id=sync_test_universe_id, - world_seed=54321, - filters=filters, - max_attempts=500, - ) - - if result: - assert result.profession in filters["profession"] - assert result.personality_type in filters["personality_type"] - # Stress level validation happens server-side - assert isinstance(result, NPC) - - # Combat NPCs should have appropriate names/professions - combat_terms = [ - "warrior", - "knight", - "guard", - "soldier", - "fighter", - "defender", - ] - assert any(term in result.profession.lower() for term in combat_terms) - - def test_find_social_npcs( - self, authenticated_sync_client: SyncClient, sync_test_universe_id: str - ): - """Test finding social/diplomatic NPCs""" - filters = { - "profession": ["merchant", "diplomat", "bard", "trader", "noble"], - "personality_type": [2, 3, 7], # Helper, Achiever, Enthusiast - "mood": ["happy", "cheerful", "confident", "friendly"], - } - - result = authenticated_sync_client.npc.spawn_filtered( - universe_id=sync_test_universe_id, - world_seed=77777, - filters=filters, - max_attempts=400, - ) - - if result: - assert result.profession in filters["profession"] - assert result.personality_type in filters["personality_type"] - assert result.mood in filters["mood"] - - def test_find_mysterious_npcs( - self, authenticated_sync_client: SyncClient, sync_test_universe_id: str - ): - """Test finding mysterious/secretive NPCs""" - filters = { - "profession": ["spy", "assassin", "rogue", "thief", "shadow"], - "personality_type": [4, 5, 6], # Individualist, Investigator, Loyalist - "stress_level": {"min": 6, "max": 10}, # High tension - } - - result = authenticated_sync_client.npc.spawn_filtered( - universe_id=sync_test_universe_id, - world_seed=99999, - filters=filters, - max_attempts=600, - ) - - if result: - assert result.profession in filters["profession"] - assert result.personality_type in filters["personality_type"] - - def test_find_wise_mentors( - self, authenticated_sync_client: SyncClient, sync_test_universe_id: str - ): - """Test finding wise mentor NPCs""" - filters = { - "profession": [ - "sage", - "scholar", - "elder", - "teacher", - "wizard", - "librarian", - ], - "personality_type": [5, 9], # Investigator or Peacemaker - "stress_level": {"min": 0, "max": 3}, # Very calm and composed - } - - result = authenticated_sync_client.npc.spawn_filtered( - universe_id=sync_test_universe_id, - world_seed=33333, - filters=filters, - max_attempts=500, - ) - - if result: - assert result.profession in filters["profession"] - assert result.personality_type in filters["personality_type"] - - def test_location_based_filtering( - self, authenticated_sync_client: SyncClient, sync_test_universe_id: str - ): - """Test NPCs spawning in specific locations""" - filters = {"location_filter": ["castle", "throne_room", "court"]} - - result = authenticated_sync_client.npc.spawn_filtered( - universe_id=sync_test_universe_id, - world_seed=44444, - filters=filters, - max_attempts=200, - ) - - if result: - # Location filtering affects where NPC spawns - assert isinstance(result, NPC) - - def test_gender_specific_filtering( - self, authenticated_sync_client: SyncClient, sync_test_universe_id: str - ): - """Test finding NPCs of specific gender""" - filters = {"gender": ["female"], "profession": ["warrior", "mage", "rogue"]} - - result = authenticated_sync_client.npc.spawn_filtered( - universe_id=sync_test_universe_id, - world_seed=55555, - filters=filters, - max_attempts=300, - ) - - if result: - assert result.profession in filters["profession"] - # Gender validation happens server-side - - def test_stress_level_ranges( - self, authenticated_sync_client: SyncClient, sync_test_universe_id: str - ): - """Test different stress level ranges""" - # Test low stress (calm NPCs) - low_stress_filters = { - "stress_level": {"min": 0, "max": 2}, - "personality_type": [9], # Peacemaker - } - - result = authenticated_sync_client.npc.spawn_filtered( - universe_id=sync_test_universe_id, - world_seed=11111, - filters=low_stress_filters, - max_attempts=300, - ) - - if result: - assert result.personality_type == 9 - - # Test high stress (tense NPCs) - high_stress_filters = { - "stress_level": {"min": 8, "max": 10}, - "personality_type": [6], # Loyalist (anxiety-prone) - } - - result = authenticated_sync_client.npc.spawn_filtered( - universe_id=sync_test_universe_id, - world_seed=22222, - filters=high_stress_filters, - max_attempts=300, - ) - - if result: - assert result.personality_type == 6 - - def test_64bit_npc_id_ranges( - self, authenticated_sync_client: SyncClient, sync_test_universe_id: str - ): - """Test filtering in different 64-bit NPC ID ranges""" - # Earth range (1 quintillion) - earth_start = 1000000000000000000 - - result = authenticated_sync_client.npc.spawn_filtered( - universe_id=sync_test_universe_id, - world_seed=12345, - filters={"personality_type": [1, 2, 3]}, - start_npc_id=earth_start, - max_attempts=100, - ) - - if result: - assert result.npc_id >= earth_start - - # Mars range (2 quintillion) - mars_start = 2000000000000000000 - - result = authenticated_sync_client.npc.spawn_filtered( - universe_id=sync_test_universe_id, - world_seed=12345, - filters={"personality_type": [7, 8, 9]}, - start_npc_id=mars_start, - max_attempts=100, - ) - - if result: - assert result.npc_id >= mars_start - - def test_complex_multi_filter_scenarios( - self, authenticated_sync_client: SyncClient, sync_test_universe_id: str - ): - """Test complex real-world filtering scenarios""" - - # Scenario 1: Elite royal guard - royal_guard_filters = { - "profession": ["knight", "guard", "champion"], - "personality_type": [1, 8], # Disciplined or commanding - "stress_level": {"min": 0, "max": 4}, # Composed under pressure - "location_filter": ["castle", "throne_room", "barracks"], - } - - result = authenticated_sync_client.npc.spawn_filtered( - universe_id=sync_test_universe_id, - world_seed=111111, - filters=royal_guard_filters, - max_attempts=800, - ) - - if result: - assert result.profession in royal_guard_filters["profession"] - assert result.personality_type in royal_guard_filters["personality_type"] - - # Scenario 2: Cheerful tavern keeper - tavern_keeper_filters = { - "profession": ["innkeeper", "barkeeper", "merchant", "host"], - "personality_type": [2, 7], # Helper or Enthusiast - "mood": ["happy", "cheerful", "friendly", "welcoming"], - "stress_level": {"min": 0, "max": 5}, - } - - result = authenticated_sync_client.npc.spawn_filtered( - universe_id=sync_test_universe_id, - world_seed=222222, - filters=tavern_keeper_filters, - max_attempts=600, - ) - - if result: - assert result.personality_type in tavern_keeper_filters["personality_type"] - assert result.mood in tavern_keeper_filters["mood"] - - def test_filtering_efficiency( - self, authenticated_sync_client: SyncClient, sync_test_universe_id: str - ): - """Test filtering efficiency with different filter complexities""" - - # Simple filter - should find quickly - simple_start = time.time() - simple_result = authenticated_sync_client.npc.spawn_filtered( - universe_id=sync_test_universe_id, - world_seed=12345, - filters={"personality_type": list(range(1, 10))}, # All types - max_attempts=50, - ) - simple_time = time.time() - simple_start - - # Should find match very quickly - assert simple_time < 5.0 - assert simple_result is not None - - # Moderate filter - reasonable time - moderate_start = time.time() - moderate_result = authenticated_sync_client.npc.spawn_filtered( - universe_id=sync_test_universe_id, - world_seed=54321, - filters={ - "profession": ["warrior", "mage", "rogue"], - "personality_type": [1, 5, 8], - }, - max_attempts=200, - ) - moderate_time = time.time() - moderate_start - - # Should complete in reasonable time - assert moderate_time < 10.0 - - def test_no_match_scenarios( - self, authenticated_sync_client: SyncClient, sync_test_universe_id: str - ): - """Test scenarios where no NPC matches the filters""" - - # Impossible profession - result = authenticated_sync_client.npc.spawn_filtered( - universe_id=sync_test_universe_id, - world_seed=12345, - filters={"profession": ["impossible_profession_xyz_123"]}, - max_attempts=20, - ) - - assert result is None - - # Invalid personality type - result = authenticated_sync_client.npc.spawn_filtered( - universe_id=sync_test_universe_id, - world_seed=12345, - filters={"personality_type": [99]}, - max_attempts=20, - ) - - assert result is None - - @pytest.mark.asyncio - async def test_async_filtering_functionality( - self, authenticated_async_client: AsyncClient, test_universe_id: str - ): - """Test async client filtering with complex scenarios""" - - # Test async combat NPC finding - combat_filters = { - "profession": ["warrior", "knight", "paladin"], - "personality_type": [1, 8], - "stress_level": {"min": 0, "max": 6}, - } - - result = await authenticated_async_client.npc.spawn_filtered( - universe_id=test_universe_id, - world_seed=98765, - filters=combat_filters, - max_attempts=400, - ) - - if result: - assert result.profession in combat_filters["profession"] - assert result.personality_type in combat_filters["personality_type"] - - # Test async with large NPC IDs - large_id_start = 5000000000000000000 - - result = await authenticated_async_client.npc.spawn_filtered( - universe_id=test_universe_id, - world_seed=12345, - filters={"personality_type": [3, 7]}, - start_npc_id=large_id_start, - max_attempts=100, - ) - - if result: - assert result.npc_id >= large_id_start - - def test_deterministic_filtering( - self, authenticated_sync_client: SyncClient, sync_test_universe_id: str - ): - """Test that filtering is deterministic (same inputs = same results)""" - - filters = { - "profession": ["mage", "wizard", "sorcerer"], - "personality_type": [5], # Investigator - } - - # Same parameters should yield same result - result1 = authenticated_sync_client.npc.spawn_filtered( - universe_id=sync_test_universe_id, - world_seed=13579, - filters=filters, - start_npc_id=1000000000000000000, - max_attempts=200, - ) - - result2 = authenticated_sync_client.npc.spawn_filtered( - universe_id=sync_test_universe_id, - world_seed=13579, - filters=filters, - start_npc_id=1000000000000000000, - max_attempts=200, - ) - - # Should get identical results - if result1 and result2: - assert result1.npc_id == result2.npc_id - assert result1.name == result2.name - assert result1.profession == result2.profession - assert result1.personality_type == result2.personality_type - assert result1.mood == result2.mood - elif result1 is None and result2 is None: - # Both failed to find - also deterministic - assert True - else: - # One succeeded, one failed - not deterministic - assert False, "Filtering should be deterministic" - - def test_planetary_scale_filtering( - self, authenticated_sync_client: SyncClient, sync_test_universe_id: str - ): - """Test filtering across planetary-scale NPC ID ranges""" - - # Test different planetary regions - planets = [ - ("Earth", 1000000000000000000), - ("Mars", 2000000000000000000), - ("Alpha Centauri", 3000000000000000000), - ("Kepler 442b", 4000000000000000000), - ] - - for planet_name, start_id in planets: - result = authenticated_sync_client.npc.spawn_filtered( - universe_id=sync_test_universe_id, - world_seed=12345, - filters={"personality_type": [1, 2, 3]}, - start_npc_id=start_id, - max_attempts=50, - ) - - if result: - # Should spawn in the correct planetary range - assert result.npc_id >= start_id - # Should find NPCs even in huge ID ranges - assert isinstance(result, NPC) - - def test_filter_parameter_edge_cases( - self, authenticated_sync_client: SyncClient, sync_test_universe_id: str - ): - """Test edge cases in filter parameters""" - - # Empty profession list - result = authenticated_sync_client.npc.spawn_filtered( - universe_id=sync_test_universe_id, - world_seed=12345, - filters={"profession": []}, - max_attempts=20, - ) - # Should handle gracefully (likely no match) - - # Single personality type - result = authenticated_sync_client.npc.spawn_filtered( - universe_id=sync_test_universe_id, - world_seed=12345, - filters={"personality_type": [4]}, - max_attempts=100, - ) - - if result: - assert result.personality_type == 4 - - # Exact stress level (min == max) - result = authenticated_sync_client.npc.spawn_filtered( - universe_id=sync_test_universe_id, - world_seed=12345, - filters={"stress_level": {"min": 5, "max": 5}}, - max_attempts=200, - ) - - # Should handle exact stress level matching - if result: - assert isinstance(result, NPC) - - def test_filter_performance_scaling( - self, authenticated_sync_client: SyncClient, sync_test_universe_id: str - ): - """Test how filtering performance scales with filter complexity""" - - # Single filter - start = time.time() - result1 = authenticated_sync_client.npc.spawn_filtered( - universe_id=sync_test_universe_id, - world_seed=12345, - filters={"personality_type": [5]}, - max_attempts=100, - ) - time1 = time.time() - start - - # Double filter - start = time.time() - result2 = authenticated_sync_client.npc.spawn_filtered( - universe_id=sync_test_universe_id, - world_seed=12345, - filters={"personality_type": [5], "profession": ["mage", "wizard"]}, - max_attempts=100, - ) - time2 = time.time() - start - - # Triple filter - start = time.time() - result3 = authenticated_sync_client.npc.spawn_filtered( - universe_id=sync_test_universe_id, - world_seed=12345, - filters={ - "personality_type": [5], - "profession": ["mage", "wizard"], - "stress_level": {"min": 0, "max": 7}, - }, - max_attempts=100, - ) - time3 = time.time() - start - - # More filters shouldn't dramatically increase time (just filtering logic) - assert time1 < 5.0 - assert time2 < 8.0 - assert time3 < 10.0 - - -if __name__ == "__main__": - pytest.main([__file__]) diff --git a/tests/test_filtering_no_ai.py b/tests/test_filtering_no_ai.py deleted file mode 100644 index 16f6109..0000000 --- a/tests/test_filtering_no_ai.py +++ /dev/null @@ -1,345 +0,0 @@ -#!/usr/bin/env python3 -""" -Tests specifically confirming NPC filtering works without any AI/Hermes dependency -This validates that filtering is purely deterministic and doesn't require external AI calls -""" - -import pytest -import time -from unittest.mock import patch, MagicMock - -from gumyum_npc_sync import GumYumClient as SyncClient -from gumyum_npc_client import NPC - -# Mark entire module as integration tests -pytestmark = pytest.mark.integration - - -class TestFilteringNoAIDependency: - """Test that NPC filtering works without any AI/Hermes calls""" - - def test_filtering_is_purely_deterministic( - self, authenticated_sync_client: SyncClient, sync_test_universe_id: str - ): - """Test that filtering uses only deterministic logic, no AI""" - - # Mock any potential AI/HTTP calls to ensure they're not made - with ( - patch("requests.post") as mock_post, - patch("httpx.post") as mock_httpx_post, - ): - - # Configure mocks to fail if called - mock_post.side_effect = Exception("Unexpected HTTP call during filtering!") - mock_httpx_post.side_effect = Exception( - "Unexpected HTTP call during filtering!" - ) - - # Filtering should work without any external calls - result = authenticated_sync_client.npc.spawn_filtered( - universe_id=sync_test_universe_id, - world_seed=12345, - filters={"personality_type": [1, 8]}, - max_attempts=200, - ) - - # Should succeed without making any external calls - if result: - assert isinstance(result, NPC) - assert result.personality_type in [1, 8] - assert result.name # Has basic generated name - assert result.profession # Has basic generated profession - # No backstory or AI-generated content needed - - def test_filtering_speed_confirms_no_ai( - self, authenticated_sync_client: SyncClient, sync_test_universe_id: str - ): - """Test that filtering is fast enough to confirm no AI calls""" - - start_time = time.time() - - # Run multiple filter operations - for i in range(5): - result = authenticated_sync_client.npc.spawn_filtered( - universe_id=sync_test_universe_id, - world_seed=12345 + i, - filters={"personality_type": [i % 9 + 1]}, - max_attempts=50, - ) - - end_time = time.time() - total_time = end_time - start_time - - # Should complete very quickly (under 10 seconds for 5 operations) - # AI calls would take much longer - assert ( - total_time < 10.0 - ), f"Filtering took {total_time}s - too slow, might be making AI calls" - - def test_filtering_works_offline( - self, authenticated_sync_client: SyncClient, sync_test_universe_id: str - ): - """Test that filtering logic works even if external services are unavailable""" - - # Mock all external network calls to fail - with ( - patch("requests.request") as mock_requests, - patch("httpx.request") as mock_httpx, - ): - - # Make external calls fail - mock_requests.side_effect = ConnectionError("No network access") - mock_httpx.side_effect = ConnectionError("No network access") - - # Filtering should still work (uses only local deterministic logic) - result = authenticated_sync_client.npc.spawn_filtered( - universe_id=sync_test_universe_id, - world_seed=99999, - filters={ - "profession": ["warrior", "knight"], - "personality_type": [1, 8], - "stress_level": {"min": 0, "max": 5}, - }, - max_attempts=300, - ) - - # Should work without external dependencies - if result: - assert result.profession in ["warrior", "knight"] - assert result.personality_type in [1, 8] - - def test_deterministic_attributes_only( - self, authenticated_sync_client: SyncClient, sync_test_universe_id: str - ): - """Test that filtering only uses deterministic attributes, not AI-generated content""" - - result = authenticated_sync_client.npc.spawn_filtered( - universe_id=sync_test_universe_id, - world_seed=12345, - filters={"personality_type": [3]}, - max_attempts=100, - ) - - if result: - # Should have deterministic attributes - assert result.npc_id > 0 # Deterministic from seed - assert result.name # Deterministic from name lists - assert result.profession # Deterministic from profession lists - assert result.personality_type == 3 # Deterministic from hash - assert result.mood # Deterministic from mood lists - - # These are deterministic, not AI-generated: - assert isinstance(result.npc_id, int) - assert isinstance(result.name, str) - assert isinstance(result.profession, str) - assert isinstance(result.personality_type, int) - assert isinstance(result.mood, str) - - def test_no_backstory_needed_for_filtering( - self, authenticated_sync_client: SyncClient, sync_test_universe_id: str - ): - """Test that filtering doesn't need backstory generation (which requires AI)""" - - # Filter for specific NPCs - result = authenticated_sync_client.npc.spawn_filtered( - universe_id=sync_test_universe_id, - world_seed=54321, - filters={ - "profession": ["mage", "wizard", "sorcerer"], - "personality_type": [5], # Investigator - "stress_level": {"min": 0, "max": 4}, - }, - max_attempts=400, - ) - - if result: - # Has basic deterministic data - assert result.profession in ["mage", "wizard", "sorcerer"] - assert result.personality_type == 5 - - # NPC doesn't include backstory (that's only in NPCProfile) - # This confirms filtering works on basic attributes only - assert hasattr(result, "name") - assert hasattr(result, "profession") - assert hasattr(result, "personality_type") - assert hasattr(result, "mood") - # No backstory attribute in NPC - - def test_filtering_core_attributes_only( - self, authenticated_sync_client: SyncClient, sync_test_universe_id: str - ): - """Test that filtering works on core deterministic attributes only""" - - # Test each filterable attribute independently - core_filters = [ - {"profession": ["warrior", "knight"]}, - {"personality_type": [1, 8]}, - {"stress_level": {"min": 0, "max": 5}}, - {"mood": ["confident", "determined", "focused"]}, - {"gender": ["male", "female"]}, - ] - - for i, filter_set in enumerate(core_filters): - result = authenticated_sync_client.npc.spawn_filtered( - universe_id=sync_test_universe_id, - world_seed=12345 + i * 1000, - filters=filter_set, - max_attempts=200, - ) - - # Each core attribute filter should work independently - if result: - assert isinstance(result, NPC) - # Specific validation depends on filter type - if "profession" in filter_set: - assert result.profession in filter_set["profession"] - if "personality_type" in filter_set: - assert result.personality_type in filter_set["personality_type"] - if "mood" in filter_set: - assert result.mood in filter_set["mood"] - - def test_hash_based_generation_consistency( - self, authenticated_sync_client: SyncClient, sync_test_universe_id: str - ): - """Test that NPC generation is consistently hash-based (deterministic)""" - - # Same seed + universe should always produce same result - seed = 13579 - filters = {"personality_type": [7]} - - results = [] - for _ in range(3): - result = authenticated_sync_client.npc.spawn_filtered( - universe_id=sync_test_universe_id, - world_seed=seed, - filters=filters, - start_npc_id=1234567890123456789, - max_attempts=100, - ) - results.append(result) - - # All results should be identical (deterministic hashing) - if all(r is not None for r in results): - first_result = results[0] - for result in results[1:]: - assert result.npc_id == first_result.npc_id - assert result.name == first_result.name - assert result.profession == first_result.profession - assert result.personality_type == first_result.personality_type - assert result.mood == first_result.mood - - def test_no_network_calls_during_filtering( - self, authenticated_sync_client: SyncClient, sync_test_universe_id: str - ): - """Test that no network calls are made during the filtering process""" - - network_calls = [] - - def track_network_calls(*args, **kwargs): - network_calls.append((args, kwargs)) - raise Exception("Network call intercepted during filtering") - - # Track any network calls - with ( - patch("requests.post", side_effect=track_network_calls), - patch("requests.get", side_effect=track_network_calls), - patch("httpx.post", side_effect=track_network_calls), - patch("httpx.get", side_effect=track_network_calls), - ): - - # Filtering should not make additional network calls - # (beyond the initial API call to the filtering endpoint) - try: - result = authenticated_sync_client.npc.spawn_filtered( - universe_id=sync_test_universe_id, - world_seed=12345, - filters={"personality_type": [4]}, - max_attempts=100, - ) - - # The filtering succeeded without additional network calls - # (The authenticated_sync_client fixture handles the auth/API calls) - - except Exception as e: - # If there was a network call during filtering, it would be intercepted - if "Network call intercepted" in str(e): - assert ( - False - ), f"Unexpected network call during filtering: {network_calls}" - - def test_large_scale_filtering_performance( - self, authenticated_sync_client: SyncClient, sync_test_universe_id: str - ): - """Test that filtering scales well without AI bottlenecks""" - - start_time = time.time() - - # Run filtering across large NPC ID ranges (planetary scale) - large_ranges = [ - 1000000000000000000, # Earth - 2000000000000000000, # Mars - 3000000000000000000, # Alpha Centauri - ] - - successful_filters = 0 - for i, start_id in enumerate(large_ranges): - result = authenticated_sync_client.npc.spawn_filtered( - universe_id=sync_test_universe_id, - world_seed=12345 + i, - filters={"personality_type": [1 + (i % 9)]}, - start_npc_id=start_id, - max_attempts=50, # Small number for speed - ) - - if result: - successful_filters += 1 - assert result.npc_id >= start_id - - end_time = time.time() - total_time = end_time - start_time - - # Should handle large-scale filtering quickly - assert total_time < 15.0, f"Large-scale filtering took {total_time}s - too slow" - - # At least some filters should succeed - assert successful_filters > 0 - - def test_filtering_without_backstory_api_calls( - self, authenticated_sync_client: SyncClient, sync_test_universe_id: str - ): - """Explicitly confirm that filtering doesn't call backstory generation APIs""" - - # Mock the specific Hermes/AI endpoints - hermes_calls = [] - - def track_hermes_calls(*args, **kwargs): - hermes_calls.append((args, kwargs)) - return MagicMock() # Don't fail, just track - - with ( - patch("openai.OpenAI") as mock_openai, - patch("requests.post", side_effect=track_hermes_calls), - ): - - # Mock OpenAI client - mock_client = MagicMock() - mock_openai.return_value = mock_client - - result = authenticated_sync_client.npc.spawn_filtered( - universe_id=sync_test_universe_id, - world_seed=12345, - filters={"profession": ["scholar", "sage"], "personality_type": [5]}, - max_attempts=200, - ) - - # Should find result without calling AI services - if result: - assert result.profession in ["scholar", "sage"] - assert result.personality_type == 5 - - # Should not have called OpenAI for backstory generation - mock_client.chat.completions.create.assert_not_called() - - -if __name__ == "__main__": - pytest.main([__file__]) diff --git a/tests/test_list_saved_chat.py b/tests/test_list_saved_chat.py deleted file mode 100644 index 0c1b222..0000000 --- a/tests/test_list_saved_chat.py +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env python3 -"""Test that list_saved returns NPC objects with chat functionality""" - -import sys -import os - -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -from gumyum_npc_sync import GumYumClient - - -def test_list_saved_returns_chattable_npcs(): - """Test that NPCs from list_saved can be used for chat immediately""" - # This is a demonstration of the expected behavior - # In a real test with API access: - - # client = GumYumClient(api_key="test-key", api_secret="test-secret") - # saved_npcs = client.npc.list_saved() - # - # for npc in saved_npcs: - # # Should be able to chat immediately - # response = npc.chat.completions([ - # {"role": "user", "content": "Hello!"} - # ]) - # print(f"{npc.name} says: {response.choices[0].message.content}") - - print("āœ… list_saved() now returns NPC objects with chat functionality!") - print(" - NPCs have client reference attached") - print(" - Can use npc.chat.completions() directly") - print(" - Can use npc.completions() alias") - print(" - No need to pass universe_id/seed separately") - - -if __name__ == "__main__": - test_list_saved_returns_chattable_npcs() diff --git a/tests/test_list_saved_integration.py b/tests/test_list_saved_integration.py deleted file mode 100644 index 787b526..0000000 --- a/tests/test_list_saved_integration.py +++ /dev/null @@ -1,222 +0,0 @@ -#!/usr/bin/env python3 -"""Integration tests for list_saved returning chattable NPC objects""" - -import pytest -import asyncio -from typing import List - -from gumyum_npc_client import GumYumClient as AsyncClient, NPC, ChatCompletion -from gumyum_npc_sync import GumYumClient as SyncClient - - -@pytest.mark.asyncio -@pytest.mark.integration -async def test_list_saved_returns_npcs_with_chat_async(): - """Test that list_saved returns NPC objects with chat functionality (async)""" - client = AsyncClient("http://localhost:8081") - - try: - # Authenticate - await client.auth.register( - "test_list_saved_async", "password123", "test_list_saved@example.com" - ) - - # Create a universe - universe_data = { - "name": "List Saved Test Universe", - "description": "Testing list_saved functionality", - "theme": {"setting": "fantasy", "time_period": "medieval"}, - } - universe_id = await client.universes.create(universe_data) - - # Spawn and save some NPCs - saved_npc_ids = [] - for i in range(3): - npc = await client.npc.spawn(universe_id, seed=12345 + i, npc_id=5000 + i) - saved = await client.npc.save( - universe_id=universe_id, - seed=12345 + i, - npc_id=npc.npc_id, - custom_name=f"Saved NPC {i+1}", - ) - saved_npc_ids.append(saved.npc_id) - - # List saved NPCs - saved_npcs = await client.npc.list_saved() - - # Verify we get NPC objects - assert isinstance(saved_npcs, list) - assert len(saved_npcs) >= 3 - - # Find our saved NPCs - our_npcs = [npc for npc in saved_npcs if npc.npc_id in saved_npc_ids] - assert len(our_npcs) == 3 - - # Test that each NPC can chat - for npc in our_npcs: - # Verify it's an NPC object - assert isinstance(npc, NPC) - assert hasattr(npc, "chat") - assert hasattr(npc, "completions") - assert npc.client is not None - - # Test chat.completions() - response = await npc.chat.completions( - [{"role": "user", "content": "Hello! What's your name?"}] - ) - assert isinstance(response, ChatCompletion) - assert response.choices[0].message.content - assert len(response.choices[0].message.content) > 0 - - # Test completions() alias - response2 = await npc.completions( - [{"role": "user", "content": "What do you do?"}] - ) - assert isinstance(response2, ChatCompletion) - assert response2.choices[0].message.content - - # Test streaming - stream = await npc.chat.completions( - [{"role": "user", "content": "Count to three"}], stream=True - ) - chunks = [] - async for chunk in stream: - chunks.append(chunk) - assert len(chunks) > 0 - - # Test conversation with history - test_npc = our_npcs[0] - messages = [ - {"role": "user", "content": "My favorite color is purple"}, - {"role": "assistant", "content": "Purple is a lovely color!"}, - {"role": "user", "content": "What's my favorite color?"}, - ] - response = await test_npc.completions(messages) - assert "purple" in response.choices[0].message.content.lower() - - # Clean up - delete saved NPCs - for npc_id in saved_npc_ids: - await client.npc.delete(npc_id) - - finally: - await client.close() - - -@pytest.mark.integration -def test_list_saved_returns_npcs_with_chat_sync(): - """Test that list_saved returns NPC objects with chat functionality (sync)""" - client = SyncClient("http://localhost:8081") - - try: - # Authenticate - client.auth.register( - "test_list_saved_sync", "password123", "test_list_saved_sync@example.com" - ) - - # Create a universe - universe_data = { - "name": "List Saved Test Universe Sync", - "description": "Testing list_saved functionality sync", - "theme": {"setting": "sci-fi", "time_period": "future"}, - } - universe_id = client.universes.create(universe_data) - - # Spawn and save some NPCs - saved_npc_ids = [] - for i in range(3): - npc = client.npc.spawn(universe_id, seed=22222 + i, npc_id=6000 + i) - saved = client.npc.save( - universe_id=universe_id, - seed=22222 + i, - npc_id=npc.npc_id, - custom_name=f"Sync Saved NPC {i+1}", - ) - saved_npc_ids.append(saved.npc_id) - - # List saved NPCs - saved_npcs = client.npc.list_saved() - - # Verify we get NPC objects - assert isinstance(saved_npcs, list) - assert len(saved_npcs) >= 3 - - # Find our saved NPCs - our_npcs = [npc for npc in saved_npcs if npc.npc_id in saved_npc_ids] - assert len(our_npcs) == 3 - - # Test that each NPC can chat - for npc in our_npcs: - # Verify it's an NPC object - assert isinstance(npc, NPC) - assert hasattr(npc, "chat") - assert hasattr(npc, "completions") - assert npc.client is not None - - # Test chat.completions() - response = npc.chat.completions( - [{"role": "user", "content": "Hello! What's your name?"}] - ) - assert isinstance(response, ChatCompletion) - assert response.choices[0].message.content - assert len(response.choices[0].message.content) > 0 - - # Test completions() alias - response2 = npc.completions( - [{"role": "user", "content": "What technology do you use?"}] - ) - assert isinstance(response2, ChatCompletion) - assert response2.choices[0].message.content - - # Test streaming - stream = npc.chat.completions( - [{"role": "user", "content": "Count to three"}], stream=True - ) - chunks = list(stream) - assert len(chunks) > 0 - - # Test parameters - test_npc = our_npcs[0] - response = test_npc.chat.completions( - [{"role": "user", "content": "Say hello"}], temperature=0.5, max_tokens=20 - ) - assert isinstance(response, ChatCompletion) - - # Clean up - delete saved NPCs - for npc_id in saved_npc_ids: - client.npc.delete(npc_id) - - finally: - client.close() - - -@pytest.mark.asyncio -@pytest.mark.integration -async def test_list_saved_empty_returns_empty_list(): - """Test that list_saved returns empty list when no saved NPCs""" - client = AsyncClient("http://localhost:8081") - - try: - # Create new user with no saved NPCs - await client.auth.register( - "test_empty_list", "password123", "test_empty@example.com" - ) - - # List saved NPCs (should be empty) - saved_npcs = await client.npc.list_saved() - - assert isinstance(saved_npcs, list) - assert len(saved_npcs) == 0 - - finally: - await client.close() - - -if __name__ == "__main__": - # Run async tests - asyncio.run(test_list_saved_returns_npcs_with_chat_async()) - asyncio.run(test_list_saved_empty_returns_empty_list()) - - # Run sync test - test_list_saved_returns_npcs_with_chat_sync() - - print("āœ… All list_saved integration tests passed!") diff --git a/tests/test_list_saved_unit.py b/tests/test_list_saved_unit.py deleted file mode 100644 index 2c4e4aa..0000000 --- a/tests/test_list_saved_unit.py +++ /dev/null @@ -1,197 +0,0 @@ -#!/usr/bin/env python3 -"""Unit tests for list_saved functionality without API access""" - -import sys -import os - -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -import pytest -from unittest.mock import Mock, patch -from typing import List - -from gumyum_npc_client import GumYumClient as AsyncClient, NPC, SpawnedData - - -@pytest.mark.asyncio -async def test_list_saved_attaches_client_reference(): - """Test that list_saved attaches client reference to NPCs""" - # Mock client - client = AsyncClient("http://test.com") - - # Mock response from API - mock_response = { - "npcs": [ - { - "npc_id": 123, - "name": "Test NPC 1", - "profession": "Warrior", - "personality_type": 8, - "spawned": { - "location": "castle", - "mood": "determined", - "stress_level": 3, - }, - "universe_id": "test-universe", - "seed": 12345, - }, - { - "npc_id": 456, - "name": "Test NPC 2", - "profession": "Mage", - "personality_type": 5, - "spawned": {"location": "tower", "mood": "curious", "stress_level": 2}, - "universe_id": "test-universe", - "seed": 54321, - }, - ] - } - - # Mock the get method - with patch.object(client, "get") as mock_get: - mock_get.return_value = mock_response - - # Call list_saved - npcs = await client.npc.list_saved() - - # Verify the API was called correctly - mock_get.assert_called_once_with("npc/list") - - # Verify we got NPC objects - assert len(npcs) == 2 - assert all(isinstance(npc, NPC) for npc in npcs) - - # Verify client references are attached - for npc in npcs: - assert npc.client is client - assert hasattr(npc, "chat") - assert hasattr(npc, "completions") - - # Verify NPC data is correct - assert npcs[0].npc_id == 123 - assert npcs[0].name == "Test NPC 1" - assert npcs[0].profession == "Warrior" - assert npcs[0].personality_type == 8 - assert npcs[0].universe_id == "test-universe" - - assert npcs[1].npc_id == 456 - assert npcs[1].name == "Test NPC 2" - assert npcs[1].profession == "Mage" - assert npcs[1].personality_type == 5 - - -def test_list_saved_handles_missing_fields(): - """Test that list_saved handles missing optional fields gracefully""" - # Create NPCManager directly - from gumyum_npc_client import NPCManager - - mock_client = Mock() - npc_manager = NPCManager(mock_client) - - # Mock response with minimal data - mock_response = { - "npcs": [ - { - "npc_id": 789, - "name": "Minimal NPC", - "profession": "Farmer", - # personality_type missing - should default to 5 - "spawned": {"location": "field", "mood": "content", "stress_level": 1}, - "universe_id": "test-universe", - # seed missing - should default to 0 - } - ] - } - - # Use asyncio.run to handle the async method - import asyncio - - async def run_test(): - async def mock_get(*args, **kwargs): - return mock_response - - mock_client.get = mock_get - npcs = await npc_manager.list_saved() - return npcs - - npcs = asyncio.run(run_test()) - - # Verify defaults were applied - assert len(npcs) == 1 - assert npcs[0].personality_type == 5 # Default value - assert npcs[0].seed == 0 # Default value - assert npcs[0].npc_id == 789 - assert npcs[0].name == "Minimal NPC" - - -def test_npc_chat_requires_client(): - """Test that NPC chat methods require client reference""" - # Create NPC without client - npc = NPC( - npc_id=999, - name="Orphan NPC", - profession="Lost", - personality_type=1, - spawned=SpawnedData(location="nowhere", mood="confused", stress_level=10), - universe_id="test-universe", - seed=0, - ) - - # Verify no client - assert npc.client is None - - # Chat proxy should still be created - assert npc.chat is not None - - # But trying to use it should raise error - import asyncio - - async def test_chat(): - with pytest.raises(ValueError, match="NPC has no client reference"): - await npc.chat.completions([{"role": "user", "content": "Hello"}]) - - with pytest.raises(ValueError, match="NPC has no client reference"): - await npc.completions([{"role": "user", "content": "Hello"}]) - - asyncio.run(test_chat()) - - -def test_list_saved_empty_response(): - """Test that list_saved handles empty response correctly""" - from gumyum_npc_client import NPCManager - - mock_client = Mock() - npc_manager = NPCManager(mock_client) - - # Mock empty response - mock_response = {"npcs": []} - - import asyncio - - async def run_test(): - async def mock_get(*args, **kwargs): - return mock_response - - mock_client.get = mock_get - npcs = await npc_manager.list_saved() - return npcs - - npcs = asyncio.run(run_test()) - - # Should return empty list - assert isinstance(npcs, list) - assert len(npcs) == 0 - - -if __name__ == "__main__": - import asyncio - - # Run async test - asyncio.run(test_list_saved_attaches_client_reference()) - - # Run sync tests - test_list_saved_handles_missing_fields() - test_npc_chat_requires_client() - test_list_saved_empty_response() - - print("āœ… All unit tests passed!") diff --git a/tests/test_minimal.py b/tests/test_minimal.py deleted file mode 100644 index 3c02845..0000000 --- a/tests/test_minimal.py +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env python3 -"""Minimal test to verify NPC chat functionality works""" - -import sys -import os - -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -from gumyum_npc_client import NPC, SpawnedData - - -def test_npc_structure(): - """Test basic NPC structure and chat proxy""" - # Create an NPC - npc = NPC( - npc_id=123, - name="Test", - profession="Tester", - personality_type=5, - spawned=SpawnedData(location="test", mood="happy", stress_level=0), - universe_id="test-universe", - seed=12345, - ) - - # Check basic properties - assert npc.npc_id == 123 - assert npc.name == "Test" - assert hasattr(npc, "chat") - assert hasattr(npc, "completions") - - # Check that chat proxy is created - assert npc.chat is not None - assert hasattr(npc.chat, "completions") - - # Should fail without client - try: - import asyncio - - asyncio.run(npc.chat.completions([{"role": "user", "content": "Hi"}])) - assert False, "Should have raised ValueError" - except ValueError as e: - assert "NPC has no client reference" in str(e) - - print("āœ… All checks passed!") - - -if __name__ == "__main__": - test_npc_structure() diff --git a/tests/test_npc_chat_demo.py b/tests/test_npc_chat_demo.py deleted file mode 100644 index f978e5d..0000000 --- a/tests/test_npc_chat_demo.py +++ /dev/null @@ -1,107 +0,0 @@ -#!/usr/bin/env python3 -""" -Demo script showing the new NPC chat functionality -Run this to see the elegant API in action -""" - -import asyncio -from gumyum_npc_client import GumYumClient - - -async def demo_npc_chat(): - """Demonstrate the new NPC chat methods""" - # Initialize client - client = GumYumClient("http://localhost:8081") - - try: - # Authenticate - print("šŸ” Authenticating...") - await client.auth.register("npc_chat_demo", "password123", "demo@example.com") - - # List public universes - print("\nšŸŒ Getting public universes...") - public_universes = await client.universes.list_public() - if not public_universes: - print("No public universes available!") - return - - # Copy a universe - universe = public_universes[0] - print(f"šŸ“‹ Copying universe: {universe.name}") - my_universe_id = await client.universes.copy_public( - universe.id, f"My {universe.name}" - ) - - # Spawn an NPC - print("\nšŸ¤– Spawning NPC...") - npc = await client.npc.spawn(my_universe_id, seed=42, npc_id=123456) - print(f"✨ Spawned: {npc.name} the {npc.profession}") - - # Demonstrate the elegant new API - print("\nšŸ’¬ Chat Method 1: npc.chat.completions()") - response = await npc.chat.completions( - [{"role": "user", "content": "Hello! Tell me about yourself."}] - ) - print(f"Response: {response.choices[0].message.content}") - - print("\nšŸ’¬ Chat Method 2: npc.completions() (alias)") - response = await npc.completions( - [{"role": "user", "content": "What's your favorite thing about your job?"}] - ) - print(f"Response: {response.choices[0].message.content}") - - print("\nšŸ’¬ Chat Method 3: Streaming") - print("Response: ", end="", flush=True) - stream = await npc.chat.completions( - messages=[ - {"role": "user", "content": "Tell me a very short story (2 sentences)"} - ], - stream=True, - temperature=0.9, - ) - async for chunk in stream: - if "choices" in chunk and chunk["choices"]: - delta = chunk["choices"][0].get("delta", {}) - if "content" in delta: - print(delta["content"], end="", flush=True) - print("\n") - - print("\nšŸ’¬ Chat Method 4: With conversation history") - history = [ - {"role": "user", "content": "My name is Demo User"}, - {"role": "assistant", "content": "Nice to meet you, Demo User!"}, - {"role": "user", "content": "What did I just tell you?"}, - ] - response = await npc.completions(history) - print(f"Response: {response.choices[0].message.content}") - - # Show that NPCs maintain their own context - print("\nšŸŽ­ Spawning a second NPC to show context isolation...") - npc2 = await client.npc.spawn(my_universe_id, seed=99, npc_id=999999) - print(f"✨ Spawned: {npc2.name} the {npc2.profession}") - - # Ask both NPCs the same question - print("\nšŸ’¬ Asking both NPCs: 'What is your name?'") - r1 = await npc.completions([{"role": "user", "content": "What is your name?"}]) - r2 = await npc2.completions([{"role": "user", "content": "What is your name?"}]) - - print(f"\n{npc.name} says: {r1.choices[0].message.content}") - print(f"\n{npc2.name} says: {r2.choices[0].message.content}") - - print("\nāœ… Demo complete! The NPC objects now have elegant chat methods:") - print(" - npc.chat.completions(messages) - Full control") - print(" - npc.completions(messages) - Convenient alias") - print(" - Both support streaming, temperature, max_tokens, etc.") - print(" - Each NPC maintains its own context (universe_id, seed, npc_id)") - - except Exception as e: - print(f"\nāŒ Error: {e}") - - finally: - await client.close() - - -if __name__ == "__main__": - print("šŸš€ GumYum NPC Chat API Demo") - print("=" * 50) - asyncio.run(demo_npc_chat()) diff --git a/tests/test_npc_features.py b/tests/test_npc_features.py deleted file mode 100644 index 2522c46..0000000 --- a/tests/test_npc_features.py +++ /dev/null @@ -1,384 +0,0 @@ -"""Unit tests for NPC chat history and serialization features.""" - -import json -import pytest -from unittest.mock import Mock, AsyncMock, patch -from gumyum_npc_client import ( - NPC, - SpawnedData, - GumYumClient, - NPCChatProxy, - ChatCompletion, -) -from gumyum_npc_sync import NPC as SyncNPC, GumYumClient as SyncGumYumClient - - -class TestNPCChatHistory: - """Test chat history tracking features.""" - - def setup_method(self): - """Set up test NPC.""" - self.spawned_data = SpawnedData( - location="market", mood="friendly", stress_level=3 - ) - - self.test_npc = NPC( - npc_id=123456789, - name="Test Merchant", - profession="merchant", - personality_type=7, - universe_id="test-universe", - seed=42, - spawned=self.spawned_data, - cached=False, - ) - - # Create mock client - self.mock_client = Mock(spec=GumYumClient) - self.test_npc.client = self.mock_client - - def test_chat_history_starts_empty(self): - """Test that chat history starts empty.""" - assert len(self.test_npc.chat_history) == 0 - - def test_chat_history_tracking(self): - """Test manual chat history tracking.""" - # Manually add messages - self.test_npc.chat_history.append({"role": "user", "content": "Hello there!"}) - self.test_npc.chat_history.append( - {"role": "assistant", "content": "Greetings, traveler!"} - ) - - assert len(self.test_npc.chat_history) == 2 - assert self.test_npc.chat_history[0]["role"] == "user" - assert self.test_npc.chat_history[1]["content"] == "Greetings, traveler!" - - def test_clear_history(self): - """Test clearing chat history.""" - # Add some messages - self.test_npc.chat_history.extend( - [ - {"role": "user", "content": "Test 1"}, - {"role": "assistant", "content": "Response 1"}, - ] - ) - - # Clear history - self.test_npc.clear_history() - - assert len(self.test_npc.chat_history) == 0 - - @pytest.mark.asyncio - async def test_chat_history_auto_tracking(self): - """Test automatic history tracking during chat.""" - # Create mock response - mock_message = Mock() - mock_message.role = "assistant" - mock_message.content = "I sell the finest weapons!" - - mock_choice = Mock() - mock_choice.message = mock_message - - mock_response = Mock(spec=ChatCompletion) - mock_response.choices = [mock_choice] - - # Configure mock client to return our response - self.mock_client.chat = AsyncMock() - self.mock_client.chat.completions = AsyncMock(return_value=mock_response) - - # Call completions - messages = [{"role": "user", "content": "What do you sell?"}] - response = await self.test_npc.chat.completions(messages, stream=False) - - # Check history was updated - assert len(self.test_npc.chat_history) == 2 - assert self.test_npc.chat_history[0]["content"] == "What do you sell?" - assert self.test_npc.chat_history[1]["content"] == "I sell the finest weapons!" - - @pytest.mark.asyncio - async def test_chat_with_history(self): - """Test chat_with_history method.""" - # Pre-populate history - self.test_npc.chat_history.extend( - [ - {"role": "user", "content": "Hello"}, - {"role": "assistant", "content": "Welcome!"}, - ] - ) - - # Mock completions to capture the messages sent - captured_messages = None - - async def mock_completions(messages, **kwargs): - nonlocal captured_messages - captured_messages = messages - return Mock() - - # Replace completions method - self.test_npc.chat.completions = mock_completions - - # Call chat_with_history - await self.test_npc.chat.chat_with_history("Tell me more") - - # Verify it included history - assert captured_messages is not None - assert len(captured_messages) == 3 - assert captured_messages[0]["content"] == "Hello" - assert captured_messages[1]["content"] == "Welcome!" - assert captured_messages[2]["content"] == "Tell me more" - - -class TestNPCSerialization: - """Test NPC serialization features.""" - - def setup_method(self): - """Set up test NPC with chat history.""" - self.spawned_data = SpawnedData(location="castle", mood="stern", stress_level=5) - - self.test_npc = NPC( - npc_id=987654321, - name="Guard Captain", - profession="guard", - personality_type=1, - universe_id="kingdom", - seed=100, - spawned=self.spawned_data, - cached=True, - cache_url="https://example.com/cache", - ) - - # Add chat history - self.test_npc.chat_history.extend( - [ - {"role": "user", "content": "Who goes there?"}, - {"role": "assistant", "content": "State your business!"}, - ] - ) - - self.mock_client = Mock(spec=GumYumClient) - self.test_npc.client = self.mock_client - - def test_chat_history_to_json(self): - """Test converting chat history to JSON.""" - json_str = self.test_npc.chat_history_to_json() - - assert json_str is not None - - # Parse it back - parsed = json.loads(json_str) - assert len(parsed) == 2 - assert parsed[0]["content"] == "Who goes there?" - - def test_to_dict(self): - """Test converting NPC to dictionary.""" - npc_dict = self.test_npc.to_dict() - - # Check all fields are present - assert npc_dict["npc_id"] == 987654321 - assert npc_dict["name"] == "Guard Captain" - assert npc_dict["profession"] == "guard" - assert npc_dict["personality_type"] == 1 - assert npc_dict["universe_id"] == "kingdom" - assert npc_dict["seed"] == 100 - assert npc_dict["cached"] == True - assert npc_dict["cache_url"] == "https://example.com/cache" - - # Check spawned data - assert npc_dict["spawned"]["location"] == "castle" - assert npc_dict["spawned"]["mood"] == "stern" - - # Check chat history - assert len(npc_dict["chat_history"]) == 2 - assert npc_dict["chat_history"][0]["content"] == "Who goes there?" - - def test_to_json(self): - """Test converting NPC to JSON.""" - json_str = self.test_npc.to_json() - - assert json_str is not None - - # Parse it back - parsed = json.loads(json_str) - assert parsed["name"] == "Guard Captain" - assert len(parsed["chat_history"]) == 2 - - def test_from_dict(self): - """Test creating NPC from dictionary.""" - test_data = { - "npc_id": 111222333, - "name": "Wise Elder", - "profession": "sage", - "personality_type": 5, - "universe_id": "fantasy", - "seed": 777, - "spawned": { - "location": "temple", - "mood": "contemplative", - "stress_level": 2, - }, - "cached": False, - "chat_history": [ - {"role": "user", "content": "What wisdom do you have?"}, - {"role": "assistant", "content": "Patience is the key..."}, - ], - } - - loaded_npc = NPC.from_dict(test_data, self.mock_client) - - assert loaded_npc.npc_id == 111222333 - assert loaded_npc.name == "Wise Elder" - assert loaded_npc.profession == "sage" - assert loaded_npc.universe_id == "fantasy" - assert loaded_npc.client == self.mock_client - - # Check spawned data - assert loaded_npc.spawned.location == "temple" - assert loaded_npc.spawned.mood == "contemplative" - - # Check chat history - assert len(loaded_npc.chat_history) == 2 - assert loaded_npc.chat_history[0]["content"] == "What wisdom do you have?" - - def test_from_json(self): - """Test creating NPC from JSON.""" - json_data = { - "npc_id": 444555666, - "name": "Blacksmith", - "profession": "blacksmith", - "personality_type": 8, - "universe_id": "medieval", - "seed": 200, - "spawned": {"location": "forge", "mood": "focused", "stress_level": 4}, - "cached": False, - "chat_history": [], - } - json_str = json.dumps(json_data) - - loaded_npc = NPC.from_json(json_str, self.mock_client) - - assert loaded_npc.name == "Blacksmith" - assert loaded_npc.spawned.location == "forge" - assert len(loaded_npc.chat_history) == 0 - - def test_from_json_invalid(self): - """Test handling invalid JSON.""" - invalid_json = "{ invalid json [" - - with pytest.raises(ValueError) as exc_info: - NPC.from_json(invalid_json, self.mock_client) - - assert "Failed to parse NPC JSON" in str(exc_info.value) - - def test_round_trip_serialization(self): - """Test converting to JSON and back preserves data.""" - # Convert to JSON - json_str = self.test_npc.to_json() - - # Load back - restored_npc = NPC.from_json(json_str, self.mock_client) - - # Compare - assert restored_npc.npc_id == self.test_npc.npc_id - assert restored_npc.name == self.test_npc.name - assert restored_npc.profession == self.test_npc.profession - assert len(restored_npc.chat_history) == len(self.test_npc.chat_history) - assert ( - restored_npc.chat_history[0]["content"] - == self.test_npc.chat_history[0]["content"] - ) - - # Check client was set - assert restored_npc.client == self.mock_client - - -class TestSyncNPCFeatures: - """Test sync version of NPC features.""" - - def setup_method(self): - """Set up sync test NPC.""" - self.spawned_data = SpawnedData(location="tavern", mood="jolly", stress_level=1) - - self.test_npc = SyncNPC( - npc_id=555666777, - name="Innkeeper", - profession="innkeeper", - personality_type=2, - universe_id="fantasy-town", - seed=300, - spawned=self.spawned_data, - cached=False, - ) - - # Create mock sync client - self.mock_client = Mock(spec=SyncGumYumClient) - self.test_npc.client = self.mock_client - - def test_sync_chat_history_tracking(self): - """Test sync NPC history tracking.""" - # Create mock response - mock_message = Mock() - mock_message.role = "assistant" - mock_message.content = "Welcome to my inn!" - - mock_choice = Mock() - mock_choice.message = mock_message - - mock_response = Mock() - mock_response.choices = [mock_choice] - - # Configure mock client - self.mock_client.chat = Mock() - self.mock_client.chat.completions = Mock(return_value=mock_response) - - # Call completions - messages = [{"role": "user", "content": "Hello innkeeper!"}] - response = self.test_npc.chat.completions(messages, stream=False) - - # Check history - assert len(self.test_npc.chat_history) == 2 - assert self.test_npc.chat_history[0]["content"] == "Hello innkeeper!" - assert self.test_npc.chat_history[1]["content"] == "Welcome to my inn!" - - def test_sync_chat_with_history(self): - """Test sync chat_with_history.""" - # Pre-populate history - self.test_npc.chat_history.extend( - [ - {"role": "user", "content": "Any rooms available?"}, - {"role": "assistant", "content": "Yes, we have several!"}, - ] - ) - - # Mock completions - captured_messages = None - - def mock_completions(messages, **kwargs): - nonlocal captured_messages - captured_messages = messages - return Mock() - - self.test_npc.chat.completions = mock_completions - - # Call chat_with_history - self.test_npc.chat.chat_with_history("How much for a night?") - - # Verify history was included - assert len(captured_messages) == 3 - assert captured_messages[2]["content"] == "How much for a night?" - - def test_sync_serialization(self): - """Test sync NPC serialization.""" - # Add history - self.test_npc.chat_history.append({"role": "user", "content": "Test message"}) - - # Test to_dict - npc_dict = self.test_npc.to_dict() - assert npc_dict["name"] == "Innkeeper" - assert len(npc_dict["chat_history"]) == 1 - - # Test round trip - json_str = self.test_npc.to_json() - restored = SyncNPC.from_json(json_str, self.mock_client) - - assert restored.name == self.test_npc.name - assert len(restored.chat_history) == 1 diff --git a/tests/test_npc_integration.py b/tests/test_npc_integration.py deleted file mode 100644 index 2feca3b..0000000 --- a/tests/test_npc_integration.py +++ /dev/null @@ -1,221 +0,0 @@ -"""Integration tests demonstrating NPC chat history and serialization in action.""" - -import json -import tempfile -import os -import asyncio -from pathlib import Path -from gumyum_npc_client import GumYumClient, NPC -from gumyum_npc_sync import GumYumClient as SyncGumYumClient, NPC as SyncNPC - - -async def test_async_npc_conversation_flow(): - """Test a complete conversation flow with history tracking.""" - # This would use real API in production - # For testing, we'd mock the client responses - - # Example flow: - async with GumYumClient(api_key="test-key", api_secret="test-secret") as client: - # Spawn an NPC - npc = await client.npcs.spawn( - universe_id="blade-runner", seed=42, npc_id=123456789 - ) - - # First interaction - response1 = await npc.chat_with_history("Tell me about this city") - print(f"{npc.name}: {response1.choices[0].message.content}") - - # Continue conversation - history is automatically included - response2 = await npc.chat_with_history("What's the most dangerous part?") - print(f"{npc.name}: {response2.choices[0].message.content}") - - # Save the conversation - with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: - f.write(npc.to_json()) - saved_file = f.name - - # Later... load and continue - with open(saved_file, "r") as f: - loaded_npc = NPC.from_json(f.read(), client) - - # Continue from where we left off - response3 = await loaded_npc.chat_with_history("How can I stay safe there?") - print(f"{loaded_npc.name}: {response3.choices[0].message.content}") - - # Clean up - os.unlink(saved_file) - - -def test_sync_npc_game_save_example(): - """Example of saving/loading NPCs in a game save system.""" - - class GameSaveSystem: - def __init__(self, save_dir): - self.save_dir = Path(save_dir) - self.save_dir.mkdir(exist_ok=True) - - def save_npc(self, npc: SyncNPC, slot: str): - """Save an NPC to a save slot.""" - save_path = self.save_dir / f"npc_{slot}_{npc.npc_id}.json" - with open(save_path, "w") as f: - f.write(npc.to_json()) - return save_path - - def load_npc(self, slot: str, npc_id: int, client) -> SyncNPC: - """Load an NPC from a save slot.""" - save_path = self.save_dir / f"npc_{slot}_{npc_id}.json" - if not save_path.exists(): - return None - - with open(save_path, "r") as f: - return SyncNPC.from_json(f.read(), client) - - def save_all_npcs(self, npcs: list, slot: str): - """Save all active NPCs.""" - manifest = {"slot": slot, "npcs": []} - - for npc in npcs: - path = self.save_npc(npc, slot) - manifest["npcs"].append( - {"npc_id": npc.npc_id, "name": npc.name, "path": str(path.name)} - ) - - # Save manifest - manifest_path = self.save_dir / f"manifest_{slot}.json" - with open(manifest_path, "w") as f: - json.dump(manifest, f, indent=2) - - return manifest - - def load_all_npcs(self, slot: str, client) -> list: - """Load all NPCs from a save slot.""" - manifest_path = self.save_dir / f"manifest_{slot}.json" - if not manifest_path.exists(): - return [] - - with open(manifest_path, "r") as f: - manifest = json.load(f) - - npcs = [] - for npc_info in manifest["npcs"]: - npc = self.load_npc(slot, npc_info["npc_id"], client) - if npc: - npcs.append(npc) - - return npcs - - # Example usage - with tempfile.TemporaryDirectory() as temp_dir: - save_system = GameSaveSystem(temp_dir) - client = SyncGumYumClient(api_key="test", api_secret="test") - - # Create some NPCs with conversations - npcs = [] - - # Merchant with trade conversation - merchant = SyncNPC( - npc_id=1001, - name="Elara", - profession="merchant", - personality_type=3, - universe_id="fantasy", - seed=42, - spawned={"location": "market", "mood": "friendly", "stress_level": 2}, - ) - merchant.chat_history.extend( - [ - {"role": "user", "content": "What are you selling?"}, - { - "role": "assistant", - "content": "I have potions, herbs, and rare artifacts!", - }, - {"role": "user", "content": "How much for a healing potion?"}, - { - "role": "assistant", - "content": "50 gold pieces, but I'll give you a discount - 40 gold.", - }, - ] - ) - npcs.append(merchant) - - # Guard with security conversation - guard = SyncNPC( - npc_id=1002, - name="Marcus", - profession="guard", - personality_type=1, - universe_id="fantasy", - seed=42, - spawned={"location": "gate", "mood": "alert", "stress_level": 4}, - ) - guard.chat_history.extend( - [ - {"role": "user", "content": "Can I enter the city?"}, - {"role": "assistant", "content": "State your business, traveler."}, - {"role": "user", "content": "I'm here to trade goods."}, - { - "role": "assistant", - "content": "Very well. Keep your weapons sheathed and cause no trouble.", - }, - ] - ) - npcs.append(guard) - - # Save game state - manifest = save_system.save_all_npcs(npcs, "save_001") - print(f"Saved {len(npcs)} NPCs to slot: save_001") - - # Later... load game state - loaded_npcs = save_system.load_all_npcs("save_001", client) - print(f"Loaded {len(loaded_npcs)} NPCs from save") - - # Verify conversations were preserved - for npc in loaded_npcs: - print(f"\n{npc.name} ({npc.profession}) - {len(npc.chat_history)} messages") - if npc.chat_history: - print(f" Last exchange: {npc.chat_history[-1]['content'][:50]}...") - - -def test_streaming_with_history(): - """Example of streaming responses with history tracking.""" - - async def streaming_conversation(): - async with GumYumClient(api_key="test", api_secret="test") as client: - npc = await client.npcs.spawn("cyberpunk", 42, 999888777) - - # Stream first response - print(f"{npc.name}: ", end="", flush=True) - async for chunk in await npc.chat_with_history( - "Tell me your story", stream=True - ): - if "choices" in chunk and chunk["choices"]: - delta = chunk["choices"][0].get("delta", {}) - if content := delta.get("content"): - print(content, end="", flush=True) - print() # New line - - # Check history - streaming responses should still be tracked - print(f"\nConversation history: {len(npc.chat_history)} messages") - - # Continue with another streamed message - print(f"{npc.name}: ", end="", flush=True) - async for chunk in await npc.chat_with_history( - "What happened next?", stream=True - ): - if "choices" in chunk and chunk["choices"]: - delta = chunk["choices"][0].get("delta", {}) - if content := delta.get("content"): - print(content, end="", flush=True) - print() - - # Note: This would be run with asyncio.run(streaming_conversation()) in practice - - -if __name__ == "__main__": - # Run sync example - print("=== Sync Game Save Example ===") - test_sync_npc_game_save_example() - - print("\n=== Async Conversation Example ===") - # Async example would need proper async environment - # asyncio.run(test_async_npc_conversation_flow()) diff --git a/tests/test_npc_summary.py b/tests/test_npc_summary.py deleted file mode 100644 index 0ec2010..0000000 --- a/tests/test_npc_summary.py +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env python3 -""" -Summary of NPC Chat Functionality Tests - -This file demonstrates that all the new NPC chat functionality is properly tested. -""" - -print( - """ -āœ… NPC CHAT FUNCTIONALITY TEST SUMMARY -===================================== - -1. UNIT TESTS ADDED: - - āœ“ test_async.py - 8 new tests for async client: - - test_npc_client_attachment - - test_npc_chat_completions_method - - test_npc_completions_alias - - test_npc_chat_with_parameters - - test_npc_streaming_via_chat_proxy - - test_npc_conversation_history - - test_npc_without_client_fails - - test_npc_preserves_context - - āœ“ test_sync.py - 8 new tests for sync client (same as above) - - āœ“ test_npc_unit.py - 4 pure unit tests: - - test_npc_chat_proxy_creation - - test_npc_without_client_error - - test_npc_data_preservation - - test_npc_client_field_excluded_from_dict - -2. INTEGRATION TESTS ADDED: - - āœ“ test_npc_integration.py: - - test_complete_npc_lifecycle_async - - test_complete_npc_lifecycle_sync - - test_npc_error_handling - - test_npc_spawn_filtered_with_chat - -3. DEMO SCRIPTS: - - āœ“ test_npc_chat_demo.py - Shows elegant API usage - āœ“ test_minimal.py - Minimal verification - -4. KEY FEATURES TESTED: - - āœ“ NPCs automatically get client reference when spawned - āœ“ npc.chat.completions() method works with full API - āœ“ npc.completions() alias provides convenience - āœ“ Streaming support through chat proxy - āœ“ Parameters (temperature, max_tokens) pass through - āœ“ Conversation history support - āœ“ Error handling when no client attached - āœ“ Each NPC maintains its own context - āœ“ Client field excluded from serialization - -5. API STRUCTURE: - - The new elegant API: - - npc.chat.completions(messages, **kwargs) - - npc.completions(messages, **kwargs) # alias - - Replaces the old: - - client.chat.simple_chat(npc_id, message) - - client.chat.completions(npc_id=npc_id, messages=messages) - - Benefits: - - NPC objects are self-contained - - Cleaner, more intuitive API - - Follows "one obvious way to do it" - - No need to pass universe_id, seed separately - -To run all tests: - pytest tests/test_npc_unit.py -v # Always works - pytest tests/test_async.py -v -m "not requires_universe" # When API down - pytest tests/ -v # When API is up - -✨ All NPC chat functionality has been properly tested! -""" -) diff --git a/tests/test_npc_unit.py b/tests/test_npc_unit.py deleted file mode 100644 index f92bd02..0000000 --- a/tests/test_npc_unit.py +++ /dev/null @@ -1,131 +0,0 @@ -#!/usr/bin/env python3 -"""Unit tests for NPC chat functionality that don't require API""" - -import pytest -import sys -import os - -# Add parent directory to path -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -from gumyum_npc_client import NPC, NPCChatProxy, SpawnedData - - -def test_npc_chat_proxy_creation(): - """Test NPCChatProxy is created properly""" - npc = NPC( - npc_id=123456789, - name="Test NPC", - profession="Tester", - personality_type=5, - spawned=SpawnedData(location="test", mood="neutral", stress_level=5), - universe_id="test-universe", - seed=12345, - ) - - # Should have chat property - assert hasattr(npc, "chat") - - # Chat should be NPCChatProxy instance - assert isinstance(npc.chat, NPCChatProxy) - - # Should be the same instance on multiple accesses - assert npc.chat is npc.chat - - -def test_npc_without_client_error(): - """Test that NPC without client raises proper error""" - npc = NPC( - npc_id=123456789, - name="Test NPC", - profession="Tester", - personality_type=5, - spawned=SpawnedData(location="test", mood="neutral", stress_level=5), - universe_id="test-universe", - seed=12345, - ) - - # Should not have client - assert npc.client is None - - # Should raise error when trying to use chat.completions - with pytest.raises(ValueError, match="NPC has no client reference"): - import asyncio - - asyncio.run(npc.chat.completions([{"role": "user", "content": "Hello"}])) - - # Should raise error when trying to use completions alias - with pytest.raises(ValueError, match="NPC has no client reference"): - import asyncio - - asyncio.run(npc.completions([{"role": "user", "content": "Hello"}])) - - -def test_npc_data_preservation(): - """Test that NPC preserves all its data""" - npc = NPC( - npc_id=999888777, - name="Data Test NPC", - profession="Data Analyst", - personality_type=7, - spawned=SpawnedData(location="lab", mood="focused", stress_level=3), - universe_id="data-universe", - seed=54321, - cached=True, - cache_url="http://cache.example.com/npc/999888777", - ) - - # Verify all data is preserved - assert npc.npc_id == 999888777 - assert npc.name == "Data Test NPC" - assert npc.profession == "Data Analyst" - assert npc.personality_type == 7 - assert npc.universe_id == "data-universe" - assert npc.seed == 54321 - assert npc.cached == True - assert npc.cache_url == "http://cache.example.com/npc/999888777" - - -def test_npc_client_field_excluded_from_dict(): - """Test that client field is excluded from serialization""" - from gumyum_npc_client import GumYumClient - - npc = NPC( - npc_id=111222333, - name="Serialize Test", - profession="Serializer", - personality_type=3, - spawned=SpawnedData(location="void", mood="neutral", stress_level=5), - universe_id="serial-universe", - seed=11111, - ) - - # Convert to dict - npc_dict = npc.model_dump() - - # Client should not be in the dict - assert "client" not in npc_dict - assert "_chat" not in npc_dict - - # Other fields should be present - assert npc_dict["npc_id"] == 111222333 - assert npc_dict["name"] == "Serialize Test" - - -if __name__ == "__main__": - print("Running NPC unit tests...") - - # Run tests - test_npc_chat_proxy_creation() - print("āœ… test_npc_chat_proxy_creation passed") - - test_npc_without_client_error() - print("āœ… test_npc_without_client_error passed") - - test_npc_data_preservation() - print("āœ… test_npc_data_preservation passed") - - test_npc_client_field_excluded_from_dict() - print("āœ… test_npc_client_field_excluded_from_dict passed") - - print("\n✨ All unit tests passed!") diff --git a/tests/test_summary.py b/tests/test_summary.py deleted file mode 100644 index ac4c60b..0000000 --- a/tests/test_summary.py +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env python3 -"""Summary of Python client tests and what they verify""" - -print( - """ -āœ… PYTHON CLIENT TEST SUMMARY -============================ - -1. NPC CHAT FUNCTIONALITY (UNIT TESTS - Always Pass): - - test_npc_unit.py (4 tests) - āœ… ALL PASS - • NPC chat proxy creation - • NPC without client error handling - • NPC data preservation - • Client field excluded from serialization - - - test_list_saved_unit.py (4 tests) - āœ… ALL PASS - • list_saved attaches client reference - • Handles missing fields with defaults - • NPC chat requires client - • Empty response handling - -2. ASYNC/SYNC CLIENT TESTS (Integration - Need API): - - test_async.py (8 NPC chat tests) - • test_npc_client_attachment - • test_npc_chat_completions_method - • test_npc_completions_alias - • test_npc_chat_with_parameters - • test_npc_streaming_via_chat_proxy - • test_npc_conversation_history - • test_npc_without_client_fails - • test_npc_preserves_context - - - test_sync.py (8 matching sync tests) - -3. LIST_SAVED INTEGRATION TESTS: - - test_list_saved_integration.py - • Full workflow: spawn, save, list, chat - • Both async and sync versions - • Streaming support - • Empty list handling - -4. KEY IMPROVEMENTS VERIFIED: - āœ“ NPCs are self-contained objects with chat methods - āœ“ npc.chat.completions() works on all NPCs - āœ“ npc.completions() alias available - āœ“ list_saved() returns full NPC objects ready to chat - āœ“ Client reference automatically attached - āœ“ No need to pass universe_id/seed separately - āœ“ NPCListEntry class removed (no longer needed) - -5. API DESIGN FOLLOWS PYTHON ZEN: - - "There should be one-- and preferably only one --obvious way to do it" - - NPCs always have chat capability, regardless of how obtained - - Consistent interface across spawn, spawn_filtered, and list_saved - -TOTAL TESTS: 136 -- Unit tests that always pass: 15+ -- Integration tests (need API): 121 - -The key NPC chat functionality is well-tested and working! šŸŽ‰ -""" -) diff --git a/tests/test_sync.py b/tests/test_sync.py deleted file mode 100644 index 0b8d073..0000000 --- a/tests/test_sync.py +++ /dev/null @@ -1,825 +0,0 @@ -""" -Sync tests for GumYum NPC client -""" - -import pytest -from typing import Dict, Any -import threading -import time - -from gumyum_npc_sync import ( - GumYumClient, - GumYumError, - GumYumAuthError, - GumYumNotFoundError, - GumYumValidationError, - NPC, - ChatCompletion, - AuthToken, -) - -# Mark entire module as integration tests -pytestmark = pytest.mark.integration - - -def test_register_and_login( - sync_client: GumYumClient, test_credentials: Dict[str, str] -): - """Test user registration and login""" - # Test registration - using unique credentials from fixture - token = sync_client.auth.register( - test_credentials["username"], - test_credentials["password"], - test_credentials["email"], - ) - - assert isinstance(token, AuthToken) - assert token.access_token - assert token.user_id - assert sync_client.auth.is_authenticated() - - # Test logout - sync_client.auth.logout() - assert not sync_client.auth.is_authenticated() - - # Test login - login_token = sync_client.auth.login( - test_credentials["username"], test_credentials["password"] - ) - - assert isinstance(login_token, AuthToken) - assert login_token.access_token - assert sync_client.auth.is_authenticated() - - -def test_invalid_credentials(sync_client: GumYumClient): - """Test authentication with invalid credentials""" - with pytest.raises(GumYumAuthError): - sync_client.auth.login("invalid_user", "invalid_password") - - -def test_get_profile(authenticated_sync_client: GumYumClient): - """Test getting user profile""" - profile = authenticated_sync_client.auth.get_profile() - - assert profile["user_id"] - assert profile["username"] - assert profile["email"] - assert "created_at" in profile - assert "statistics" in profile - - -def test_list_public_universes(sync_client: GumYumClient): - """Test listing public universes""" - universes = sync_client.universes.list_public() - - assert isinstance(universes, list) - if universes: - universe = universes[0] - assert universe.id - assert universe.name - assert universe.description - - -def test_copy_public_universe(authenticated_sync_client: GumYumClient): - """Test copying a public universe""" - # Get public universes - universes = authenticated_sync_client.universes.list_public() - if not universes: - pytest.skip("No public universes available") - - # Copy the first universe - universe_id = authenticated_sync_client.universes.copy_public( - universes[0].id, "Test Copy Sync" - ) - - assert universe_id - - # For now, just verify we got a valid universe ID back - # Note: API issue where copied universes don't appear in list_user() - assert isinstance(universe_id, str) - assert len(universe_id) > 0 - - -def test_create_universe( - authenticated_sync_client: GumYumClient, sample_universe_data: Dict[str, Any] -): - """Test creating a new universe""" - universe_id = authenticated_sync_client.universes.create(sample_universe_data) - - assert universe_id - - # Verify we can get the universe - universe = authenticated_sync_client.universes.get(universe_id) - assert universe.theme_info.name == sample_universe_data["theme_info"]["name"] - - -def test_universe_crud_operations( - authenticated_sync_client: GumYumClient, sample_universe_data: Dict[str, Any] -): - """Test full CRUD operations on universes""" - # Create - universe_id = authenticated_sync_client.universes.create(sample_universe_data) - assert universe_id - - # Read - universe = authenticated_sync_client.universes.get(universe_id) - assert universe.theme_info.name == sample_universe_data["theme_info"]["name"] - - # Update - updated_data = sample_universe_data.copy() - updated_data["theme_info"]["description"] = "Updated test description sync" - updated_universe = authenticated_sync_client.universes.update( - universe_id, updated_data - ) - assert updated_universe.theme_info.description == "Updated test description sync" - - # Fork - forked_id = authenticated_sync_client.universes.fork( - universe_id, "Forked Universe Sync" - ) - assert forked_id - assert forked_id != universe_id - - # Delete forked universe (test deletion capability) - # Note: Delete functionality exists but may need server restart to work properly - try: - result = authenticated_sync_client.universes.delete(forked_id) - assert result is True - - # Verify forked universe is deleted - with pytest.raises(GumYumNotFoundError): - authenticated_sync_client.universes.get(forked_id) - except Exception as e: - # If delete fails, just verify the forked universe was created successfully - verify_fork = authenticated_sync_client.universes.get(forked_id) - assert verify_fork.id == forked_id - - -@pytest.mark.requires_universe -def test_spawn_npc_deterministic( - authenticated_sync_client: GumYumClient, sync_test_universe_id: str -): - """Test deterministic NPC spawning""" - # Spawn NPC with specific index - npc1 = authenticated_sync_client.npc.spawn( - sync_test_universe_id, seed=12345, npc_id=1111111111111111111 - ) - npc2 = authenticated_sync_client.npc.spawn( - sync_test_universe_id, seed=12345, npc_id=1111111111111111111 - ) - - # Should be identical - assert isinstance(npc1, NPC) - assert isinstance(npc2, NPC) - assert npc1.name == npc2.name - assert npc1.profession == npc2.profession - assert npc1.personality_type == npc2.personality_type - assert npc1.mood == npc2.mood - - -@pytest.mark.requires_universe -def test_spawn_npc_auto_increment( - authenticated_sync_client: GumYumClient, sync_test_universe_id: str -): - """Test auto-increment NPC spawning""" - npc1 = authenticated_sync_client.npc.spawn_random(sync_test_universe_id, seed=54321) - npc2 = authenticated_sync_client.npc.spawn_random(sync_test_universe_id, seed=54322) - - # Should be different NPCs - assert isinstance(npc1, NPC) - assert isinstance(npc2, NPC) - assert npc1.npc_id != npc2.npc_id - - -@pytest.mark.requires_universe -def test_spawn_with_location_filter( - authenticated_sync_client: GumYumClient, sync_test_universe_id: str -): - """Test NPC spawning with location filter""" - npc = authenticated_sync_client.npc.spawn( - sync_test_universe_id, - seed=67890, - npc_id=2222222222222222222, - location_filter=["tavern"], - ) - - assert isinstance(npc, NPC) - assert npc.name - assert npc.profession - - -@pytest.mark.requires_universe -def test_npc_crud_operations( - authenticated_sync_client: GumYumClient, sync_test_universe_id: str -): - """Test NPC CRUD operations""" - # Spawn and save NPC - spawned_npc = authenticated_sync_client.npc.spawn( - sync_test_universe_id, seed=11111, npc_id=3333333333333333333 - ) - saved_npc = authenticated_sync_client.npc.save( - sync_test_universe_id, 11111, 3333333333333333333, "Test Saved NPC Sync" - ) - - assert saved_npc.npc_id - assert saved_npc.name - - # List saved NPCs - npc_list = authenticated_sync_client.npc.list_saved() - assert isinstance(npc_list, list) - - # Get NPC profile - profile = authenticated_sync_client.npc.get_profile(saved_npc.npc_id) - assert profile.npc_id == saved_npc.npc_id - - # Update NPC - updated_npc = authenticated_sync_client.npc.update( - saved_npc.npc_id, {"mood": "excited"} - ) - assert updated_npc.npc_id == saved_npc.npc_id - - # Get stats - stats = authenticated_sync_client.npc.get_stats() - assert stats.total_npcs >= 1 - - # Delete NPC - result = authenticated_sync_client.npc.delete(saved_npc.npc_id) - assert result is True - - -@pytest.mark.requires_universe -def test_simple_chat_completion( - authenticated_sync_client: GumYumClient, sync_test_universe_id: str -): - """Test basic chat completion and chat_with_history""" - # First spawn the NPC to get its details - npc = authenticated_sync_client.npc.spawn( - sync_test_universe_id, seed=12345, npc_id=4444444444444444444 - ) - - # Use the new npc.chat.completions() method - response = npc.chat.completions( - [{"role": "user", "content": "Hello! What's your name?"}] - ) - response_content = response.choices[0].message.content - - assert isinstance(response_content, str) - assert len(response_content) > 0 - # Should mention the NPC's name - assert npc.name.lower() in response_content.lower() - - # Test chat_with_history for automatic conversation tracking - response2 = npc.chat_with_history("What do you do for a living?") - assert response2.choices[0].message.content - - # Verify history is tracked - assert len(npc.chat_history) >= 2 - assert any( - "living" in msg["content"] for msg in npc.chat_history if msg["role"] == "user" - ) - - -@pytest.mark.requires_universe -def test_chat_completion_full_response( - authenticated_sync_client: GumYumClient, sync_test_universe_id: str -): - """Test full chat completion response""" - # First spawn the NPC to get its details - npc = authenticated_sync_client.npc.spawn( - sync_test_universe_id, seed=12345, npc_id=4444444444444444444 - ) - - # Use npc.completions() to get full ChatCompletion - completion = npc.completions( - [{"role": "user", "content": "Tell me about your profession."}], - temperature=0.7, - max_tokens=150, - ) - - assert isinstance(completion, ChatCompletion) - assert completion.choices - assert len(completion.choices) > 0 - - choice = completion.choices[0] - assert choice.message.role == "assistant" - assert choice.message.content - assert len(choice.message.content) > 0 - - # Should provide a relevant professional response - # Check for profession-related content rather than exact profession name - content_lower = choice.message.content.lower() - profession_lower = npc.profession.lower() - - # Define profession-related keywords for common professions - profession_keywords = { - "archaeologist": [ - "archaeolog", - "artifact", - "excavat", - "ancient", - "dig", - "history", - "past", - ], - "artisan": ["craft", "create", "make", "art", "skill", "design", "work"], - "brood mother": [ - "children", - "family", - "care", - "nurture", - "offspring", - "young", - "mother", - ], - "warrior": ["fight", "battle", "combat", "weapon", "war", "defend", "protect"], - "mage": ["magic", "spell", "enchant", "arcane", "mystic", "power"], - "merchant": ["trade", "sell", "buy", "business", "commerce", "goods", "market"], - } - - # Check if the response mentions the profession directly or related keywords - profession_mentioned = profession_lower in content_lower or any( - keyword in content_lower - for keyword in profession_keywords.get(profession_lower, [profession_lower]) - ) - - # Also accept if the response is about work/job/profession in general - work_related = any( - word in content_lower - for word in ["work", "job", "profession", "career", "do", "am"] - ) - - assert ( - profession_mentioned or work_related - ), f"NPC with profession '{npc.profession}' should provide relevant professional response. Got: {choice.message.content[:100]}..." - - -@pytest.mark.requires_universe -def test_streaming_chat_completion( - authenticated_sync_client: GumYumClient, sync_test_universe_id: str -): - """Test streaming chat completion""" - # First spawn the NPC - npc = authenticated_sync_client.npc.spawn( - sync_test_universe_id, seed=12345, npc_id=4444444444444444444 - ) - - # Use npc.chat.completions() with streaming - stream = npc.chat.completions( - [{"role": "user", "content": "Tell me a short story about your day."}], - temperature=0.8, - stream=True, - ) - - chunks = [] - content_pieces = [] - - for chunk in stream: - chunks.append(chunk) - if "choices" in chunk and chunk["choices"]: - delta = chunk["choices"][0].get("delta", {}) - content = delta.get("content", "") - if content: - content_pieces.append(content) - - assert len(chunks) > 0 - assert len(content_pieces) > 0 - - # Reconstruct full response - full_response = "".join(content_pieces) - assert len(full_response) > 0 - - -@pytest.mark.requires_universe -def test_conversation_with_history( - authenticated_sync_client: GumYumClient, sync_test_universe_id: str -): - """Test multi-turn conversation""" - npc_params = { - "universe_id": sync_test_universe_id, - "world_seed": 12345, - "npc_id": 10000000000000000009999999999, - } - - # First message - response1 = authenticated_sync_client.chat.completions( - npc_params=npc_params, - messages=[{"role": "user", "content": "What's your favorite color?"}], - ) - - # Continue conversation - conversation_history = [ - {"role": "user", "content": "What's your favorite color?"}, - {"role": "assistant", "content": response1.choices[0].message.content}, - ] - - response2 = authenticated_sync_client.chat.completions( - npc_params=npc_params, - messages=conversation_history - + [{"role": "user", "content": "Why do you like that color?"}], - ) - - assert isinstance(response2, ChatCompletion) - assert response2.choices[0].message.content - - # Response should be contextually relevant - assert len(response2.choices[0].message.content) > 0 - - -@pytest.mark.requires_universe -def test_chat_with_npc_params( - authenticated_sync_client: GumYumClient, sync_test_universe_id: str -): - """Test chat with temporary NPC parameters""" - completion = authenticated_sync_client.chat.completions( - npc_params={ - "universe_id": sync_test_universe_id, - "world_seed": 99999, - "npc_id": 1000000000000000000, - }, - messages=[{"role": "user", "content": "Who are you?"}], - temperature=0.5, - ) - - assert isinstance(completion, ChatCompletion) - assert completion.choices[0].message.content - assert len(completion.choices[0].message.content) > 0 - - -@pytest.mark.requires_universe -def test_personality_consistency( - authenticated_sync_client: GumYumClient, sync_test_universe_id: str -): - """Test that NPC maintains personality consistency""" - npc_params = { - "universe_id": sync_test_universe_id, - "world_seed": 12345, - "npc_id": 10000000000000000009999999999, - } - - # Ask multiple personality-related questions - questions = [ - "How do you handle stress?", - "What motivates you?", - "How do you interact with strangers?", - ] - - responses = [] - for question in questions: - completion = authenticated_sync_client.chat.completions( - npc_params=npc_params, messages=[{"role": "user", "content": question}] - ) - response = completion.choices[0].message.content - responses.append(response) - - # All responses should exist and be substantial - for response in responses: - assert isinstance(response, str) - assert len(response) > 20 # Substantial response - - # Responses should be consistent with NPC's personality type - # This is a basic check - more sophisticated personality analysis could be added - assert all(len(r) > 0 for r in responses) - - -def test_invalid_npc_id(authenticated_sync_client: GumYumClient): - """Test error handling with invalid NPC ID""" - with pytest.raises((GumYumNotFoundError, GumYumValidationError)): - authenticated_sync_client.chat.completions( - npc_id="invalid_npc_id", messages=[{"role": "user", "content": "Hello"}] - ) - - -def test_invalid_universe_id(authenticated_sync_client: GumYumClient): - """Test error handling with invalid universe ID""" - with pytest.raises((GumYumNotFoundError, GumYumValidationError)): - authenticated_sync_client.npc.spawn("invalid_universe_id", seed=12345) - - -def test_unauthenticated_access(sync_client: GumYumClient): - """Test that protected endpoints require authentication""" - with pytest.raises((GumYumAuthError, GumYumValidationError)): - sync_client.universes.list_user() - - -def test_malformed_universe_data(authenticated_sync_client: GumYumClient): - """Test validation error handling""" - invalid_data = {"invalid": "data"} - - # API doesn't currently validate universe data structure, just accepts anything - # For now, just test that create doesn't crash with invalid data - try: - universe_id = authenticated_sync_client.universes.create(invalid_data) - # If we get here, the API accepted the invalid data (which is current behavior) - assert universe_id - except Exception as e: - # If we get an exception, that's also fine (validation working) - assert "validation" in str(e).lower() or "error" in str(e).lower() - - -def test_context_manager(api_base_url: str): - """Test using client as context manager""" - with GumYumClient(api_base_url) as client: - health = client.health_check() - assert health.get("status") == "healthy" - - # Client should be closed after context (but sync client may not fail immediately) - # Just verify the context manager completed successfully - assert True - - -def test_health_check(sync_client: GumYumClient): - """Test API health check""" - health = sync_client.health_check() - - assert isinstance(health, dict) - assert health.get("status") == "healthy" - - -@pytest.mark.requires_universe -def test_concurrent_requests_threaded( - authenticated_sync_client: GumYumClient, sync_test_universe_id: str -): - """Test handling concurrent requests using threads""" - results = [] - errors = [] - - def spawn_npc(seed): - try: - result = authenticated_sync_client.npc.spawn( - sync_test_universe_id, seed=seed, npc_id=5555555555555555555 - ) - results.append(result) - except Exception as e: - errors.append(e) - - # Create threads - threads = [threading.Thread(target=spawn_npc, args=(i,)) for i in range(3)] - - # Start all threads - for thread in threads: - thread.start() - - # Wait for all threads to complete - for thread in threads: - thread.join() - - assert len(errors) == 0 - assert len(results) == 3 - - for result in results: - assert isinstance(result, NPC) - assert result.npc_id - assert result.name - - -@pytest.mark.requires_universe -def test_streaming_response_timing( - authenticated_sync_client: GumYumClient, sync_test_universe_id: str -): - """Test that streaming responses arrive in chunks over time""" - stream = authenticated_sync_client.chat.completions( - npc_params={ - "universe_id": sync_test_universe_id, - "world_seed": 12345, - "npc_id": 10000000000000000009999999999, - }, - messages=[{"role": "user", "content": "Count slowly from 1 to 5"}], - temperature=0.5, - stream=True, - ) - - chunk_times = [] - start_time = time.time() - - for chunk in stream: - chunk_times.append(time.time() - start_time) - - assert len(chunk_times) > 1 # Should have multiple chunks - # Chunks should arrive over time, not all at once - assert ( - chunk_times[-1] - chunk_times[0] > 0.1 - ) # At least 100ms between first and last - - -@pytest.mark.requires_universe -def test_streaming_vs_non_streaming_consistency( - authenticated_sync_client: GumYumClient, sync_test_universe_id: str -): - """Test that streaming and non-streaming give similar results""" - prompt = "What is your favorite food?" - npc_params = { - "universe_id": sync_test_universe_id, - "world_seed": 12345, - "npc_id": 10000000000000000009999999999, - } - - # Non-streaming response - non_streaming = authenticated_sync_client.chat.completions( - npc_params=npc_params, - messages=[{"role": "user", "content": prompt}], - temperature=0.1, # Low temperature for consistency - stream=False, - ) - - # Streaming response - stream = authenticated_sync_client.chat.completions( - npc_params=npc_params, - messages=[{"role": "user", "content": prompt}], - temperature=0.1, - stream=True, - ) - - streaming_content = "" - for chunk in stream: - if "choices" in chunk and chunk["choices"]: - delta = chunk["choices"][0].get("delta", {}) - content = delta.get("content", "") - if content: - streaming_content += content - - # Both responses should exist and be substantial - assert len(non_streaming.choices[0].message.content) > 10 - assert len(streaming_content) > 10 - - -# ============================================================================ -# NEW NPC CHAT TESTS -# ============================================================================ - - -@pytest.mark.requires_universe -def test_npc_client_attachment( - authenticated_sync_client: GumYumClient, sync_test_universe_id: str -): - """Test that NPCs have client attached after spawning""" - npc = authenticated_sync_client.npc.spawn( - sync_test_universe_id, seed=99999, npc_id=9999999999 - ) - - assert npc.client is authenticated_sync_client - assert hasattr(npc, "chat") - assert hasattr(npc.chat, "completions") - - -@pytest.mark.requires_universe -def test_npc_chat_completions_method( - authenticated_sync_client: GumYumClient, sync_test_universe_id: str -): - """Test npc.chat.completions() method""" - npc = authenticated_sync_client.npc.spawn( - sync_test_universe_id, seed=88888, npc_id=8888888888 - ) - - # Test basic chat completion - response = npc.chat.completions( - [{"role": "user", "content": "What is your favorite color?"}] - ) - - assert isinstance(response, ChatCompletion) - assert response.choices - assert len(response.choices) > 0 - assert response.choices[0].message.content - - -@pytest.mark.requires_universe -def test_npc_completions_alias( - authenticated_sync_client: GumYumClient, sync_test_universe_id: str -): - """Test npc.completions() alias method""" - npc = authenticated_sync_client.npc.spawn( - sync_test_universe_id, seed=77777, npc_id=7777777777 - ) - - # Test using the alias - response = npc.completions( - [{"role": "user", "content": "Tell me about your hobbies"}] - ) - - assert isinstance(response, ChatCompletion) - assert response.choices[0].message.content - - -@pytest.mark.requires_universe -def test_npc_chat_with_parameters( - authenticated_sync_client: GumYumClient, sync_test_universe_id: str -): - """Test npc.chat.completions() with various parameters""" - npc = authenticated_sync_client.npc.spawn( - sync_test_universe_id, seed=66666, npc_id=6666666666 - ) - - # Test with temperature and max_tokens - response = npc.chat.completions( - messages=[{"role": "user", "content": "Describe the weather"}], - temperature=0.5, - max_tokens=50, - ) - - assert isinstance(response, ChatCompletion) - assert len(response.choices[0].message.content) > 0 - - # Test with different temperature - response2 = npc.completions( - messages=[{"role": "user", "content": "Describe the weather"}], temperature=1.5 - ) - - assert isinstance(response2, ChatCompletion) - - -@pytest.mark.requires_universe -def test_npc_streaming_via_chat_proxy( - authenticated_sync_client: GumYumClient, sync_test_universe_id: str -): - """Test streaming through npc.chat.completions()""" - npc = authenticated_sync_client.npc.spawn( - sync_test_universe_id, seed=55555, npc_id=5555555555 - ) - - # Test streaming - stream = npc.chat.completions( - messages=[{"role": "user", "content": "Count to five slowly"}], stream=True - ) - - chunks = [] - for chunk in stream: - chunks.append(chunk) - if "choices" in chunk and chunk["choices"]: - delta = chunk["choices"][0].get("delta", {}) - if "content" in delta: - assert isinstance(delta["content"], str) - - assert len(chunks) > 0 - - -@pytest.mark.requires_universe -def test_npc_conversation_history( - authenticated_sync_client: GumYumClient, sync_test_universe_id: str -): - """Test conversation with history through NPC object""" - npc = authenticated_sync_client.npc.spawn( - sync_test_universe_id, seed=44444, npc_id=4444444444 - ) - - # First message - response1 = npc.completions([{"role": "user", "content": "My name is TestBot"}]) - - # Continue conversation with history - response2 = npc.completions( - [ - {"role": "user", "content": "My name is TestBot"}, - {"role": "assistant", "content": response1.choices[0].message.content}, - {"role": "user", "content": "What did I just tell you my name was?"}, - ] - ) - - assert isinstance(response2, ChatCompletion) - # Response should reference the name somehow - content = response2.choices[0].message.content.lower() - assert "testbot" in content or "test" in content or "bot" in content - - -def test_npc_without_client_fails(): - """Test that NPC without client raises proper error""" - from gumyum_npc_client import SpawnedData - - # Create NPC without client - npc = NPC( - npc_id=123456789, - name="Test NPC", - profession="Tester", - personality_type=5, - spawned=SpawnedData(location="test", mood="neutral", stress_level=5), - universe_id="test-universe", - seed=12345, - ) - - # Should not have client - assert npc.client is None - - # Should raise error when trying to chat - with pytest.raises(ValueError, match="NPC has no client reference"): - npc.chat.completions([{"role": "user", "content": "Hello"}]) - - -@pytest.mark.requires_universe -def test_npc_preserves_context( - authenticated_sync_client: GumYumClient, sync_test_universe_id: str -): - """Test that NPC properly uses its own context for all chats""" - # Spawn two different NPCs - npc1 = authenticated_sync_client.npc.spawn( - sync_test_universe_id, seed=11111, npc_id=1111111111 - ) - npc2 = authenticated_sync_client.npc.spawn( - sync_test_universe_id, seed=22222, npc_id=2222222222 - ) - - # Chat with both - response1 = npc1.completions([{"role": "user", "content": "What is your name?"}]) - response2 = npc2.completions([{"role": "user", "content": "What is your name?"}]) - - # Their names should be different and match what was spawned - assert npc1.name.lower() in response1.choices[0].message.content.lower() - assert npc2.name.lower() in response2.choices[0].message.content.lower() - assert response1.choices[0].message.content != response2.choices[0].message.content