Add .gitignore files and clean up tracked files
This commit is contained in:
parent
b9a3ba56ca
commit
4ad5a78b20
43 changed files with 326 additions and 4959 deletions
72
.gitignore
vendored
Normal file
72
.gitignore
vendored
Normal file
|
|
@ -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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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}")
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"])
|
||||
|
|
@ -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__])
|
||||
|
|
@ -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__])
|
||||
|
|
@ -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__])
|
||||
|
|
@ -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()
|
||||
|
|
@ -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!")
|
||||
|
|
@ -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!")
|
||||
|
|
@ -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()
|
||||
|
|
@ -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())
|
||||
|
|
@ -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
|
||||
|
|
@ -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())
|
||||
|
|
@ -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!
|
||||
"""
|
||||
)
|
||||
|
|
@ -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!")
|
||||
|
|
@ -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! 🎉
|
||||
"""
|
||||
)
|
||||
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue