455 lines
13 KiB
Python
455 lines
13 KiB
Python
"""
|
|
Unit tests for Character dataclass.
|
|
|
|
Tests the critical get_effective_stats() method which combines all stat modifiers,
|
|
as well as inventory, equipment, experience, and serialization.
|
|
"""
|
|
|
|
import pytest
|
|
from app.models.character import Character
|
|
from app.models.stats import Stats
|
|
from app.models.items import Item
|
|
from app.models.effects import Effect
|
|
from app.models.skills import PlayerClass, SkillTree, SkillNode
|
|
from app.models.origins import Origin, StartingLocation, StartingBonus
|
|
from app.models.enums import ItemType, EffectType, StatType, DamageType
|
|
|
|
|
|
@pytest.fixture
|
|
def basic_player_class():
|
|
"""Create a basic player class for testing."""
|
|
base_stats = Stats(strength=12, dexterity=10, constitution=14, intelligence=8, wisdom=10, charisma=11)
|
|
|
|
# Create a simple skill tree
|
|
skill_tree = SkillTree(
|
|
tree_id="warrior_offense",
|
|
name="Warrior Offense",
|
|
description="Offensive combat skills",
|
|
nodes=[
|
|
SkillNode(
|
|
skill_id="power_strike",
|
|
name="Power Strike",
|
|
description="+5 Strength",
|
|
tier=1,
|
|
effects={"strength": 5},
|
|
),
|
|
],
|
|
)
|
|
|
|
return PlayerClass(
|
|
class_id="warrior",
|
|
name="Warrior",
|
|
description="Strong melee fighter",
|
|
base_stats=base_stats,
|
|
skill_trees=[skill_tree],
|
|
starting_equipment=["basic_sword"],
|
|
starting_abilities=["basic_attack"],
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def basic_origin():
|
|
"""Create a basic origin for testing."""
|
|
starting_location = StartingLocation(
|
|
id="test_location",
|
|
name="Test Village",
|
|
region="Test Region",
|
|
description="A simple test location"
|
|
)
|
|
|
|
starting_bonus = StartingBonus(
|
|
trait="Test Trait",
|
|
description="A test trait for testing",
|
|
effect="+1 to all stats"
|
|
)
|
|
|
|
return Origin(
|
|
id="test_origin",
|
|
name="Test Origin",
|
|
description="A test origin for character testing",
|
|
starting_location=starting_location,
|
|
narrative_hooks=["Test hook 1", "Test hook 2"],
|
|
starting_bonus=starting_bonus
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def basic_character(basic_player_class, basic_origin):
|
|
"""Create a basic character for testing."""
|
|
return Character(
|
|
character_id="char_001",
|
|
user_id="user_001",
|
|
name="Test Hero",
|
|
player_class=basic_player_class,
|
|
origin=basic_origin,
|
|
level=1,
|
|
experience=0,
|
|
base_stats=basic_player_class.base_stats.copy(),
|
|
)
|
|
|
|
|
|
def test_character_creation(basic_character):
|
|
"""Test creating a Character instance."""
|
|
assert basic_character.character_id == "char_001"
|
|
assert basic_character.user_id == "user_001"
|
|
assert basic_character.name == "Test Hero"
|
|
assert basic_character.level == 1
|
|
assert basic_character.experience == 0
|
|
assert basic_character.gold == 0
|
|
|
|
|
|
def test_get_effective_stats_base_only(basic_character):
|
|
"""Test get_effective_stats() with only base stats (no modifiers)."""
|
|
effective = basic_character.get_effective_stats()
|
|
|
|
# Should match base stats exactly
|
|
assert effective.strength == 12
|
|
assert effective.dexterity == 10
|
|
assert effective.constitution == 14
|
|
assert effective.intelligence == 8
|
|
assert effective.wisdom == 10
|
|
assert effective.charisma == 11
|
|
|
|
|
|
def test_get_effective_stats_with_equipment(basic_character):
|
|
"""Test get_effective_stats() with equipped items."""
|
|
# Create a weapon with +5 strength
|
|
weapon = Item(
|
|
item_id="iron_sword",
|
|
name="Iron Sword",
|
|
item_type=ItemType.WEAPON,
|
|
description="A sturdy iron sword",
|
|
stat_bonuses={"strength": 5},
|
|
damage=10,
|
|
)
|
|
|
|
basic_character.equipped["weapon"] = weapon
|
|
effective = basic_character.get_effective_stats()
|
|
|
|
# Strength should be base (12) + weapon (5) = 17
|
|
assert effective.strength == 17
|
|
assert effective.dexterity == 10 # Unchanged
|
|
|
|
|
|
def test_get_effective_stats_with_skill_bonuses(basic_character):
|
|
"""Test get_effective_stats() with skill tree bonuses."""
|
|
# Unlock the "power_strike" skill which gives +5 strength
|
|
basic_character.unlocked_skills.append("power_strike")
|
|
|
|
effective = basic_character.get_effective_stats()
|
|
|
|
# Strength should be base (12) + skill (5) = 17
|
|
assert effective.strength == 17
|
|
|
|
|
|
def test_get_effective_stats_with_all_modifiers(basic_character):
|
|
"""Test get_effective_stats() with equipment + skills + active effects."""
|
|
# Add equipment: +5 strength
|
|
weapon = Item(
|
|
item_id="iron_sword",
|
|
name="Iron Sword",
|
|
item_type=ItemType.WEAPON,
|
|
description="A sturdy iron sword",
|
|
stat_bonuses={"strength": 5},
|
|
damage=10,
|
|
)
|
|
basic_character.equipped["weapon"] = weapon
|
|
|
|
# Unlock skill: +5 strength
|
|
basic_character.unlocked_skills.append("power_strike")
|
|
|
|
# Add buff effect: +3 strength
|
|
buff = Effect(
|
|
effect_id="str_buff",
|
|
name="Strength Boost",
|
|
effect_type=EffectType.BUFF,
|
|
duration=3,
|
|
power=3,
|
|
stat_affected=StatType.STRENGTH,
|
|
)
|
|
|
|
effective = basic_character.get_effective_stats([buff])
|
|
|
|
# Total strength: 12 (base) + 5 (weapon) + 5 (skill) + 3 (buff) = 25
|
|
assert effective.strength == 25
|
|
|
|
|
|
def test_get_effective_stats_with_debuff(basic_character):
|
|
"""Test get_effective_stats() with a debuff."""
|
|
debuff = Effect(
|
|
effect_id="weakened",
|
|
name="Weakened",
|
|
effect_type=EffectType.DEBUFF,
|
|
duration=2,
|
|
power=5,
|
|
stat_affected=StatType.STRENGTH,
|
|
)
|
|
|
|
effective = basic_character.get_effective_stats([debuff])
|
|
|
|
# Strength: 12 (base) - 5 (debuff) = 7
|
|
assert effective.strength == 7
|
|
|
|
|
|
def test_get_effective_stats_debuff_minimum(basic_character):
|
|
"""Test that debuffs cannot reduce stats below 1."""
|
|
# Massive debuff
|
|
debuff = Effect(
|
|
effect_id="weakened",
|
|
name="Weakened",
|
|
effect_type=EffectType.DEBUFF,
|
|
duration=2,
|
|
power=20, # More than base strength
|
|
stat_affected=StatType.STRENGTH,
|
|
)
|
|
|
|
effective = basic_character.get_effective_stats([debuff])
|
|
|
|
# Should be clamped at 1, not 0 or negative
|
|
assert effective.strength == 1
|
|
|
|
|
|
def test_inventory_management(basic_character):
|
|
"""Test adding and removing items from inventory."""
|
|
item = Item(
|
|
item_id="potion",
|
|
name="Health Potion",
|
|
item_type=ItemType.CONSUMABLE,
|
|
description="Restores 50 HP",
|
|
value=25,
|
|
)
|
|
|
|
# Add item
|
|
basic_character.add_item(item)
|
|
assert len(basic_character.inventory) == 1
|
|
assert basic_character.inventory[0].item_id == "potion"
|
|
|
|
# Remove item
|
|
removed = basic_character.remove_item("potion")
|
|
assert removed.item_id == "potion"
|
|
assert len(basic_character.inventory) == 0
|
|
|
|
|
|
def test_equip_and_unequip(basic_character):
|
|
"""Test equipping and unequipping items."""
|
|
weapon = Item(
|
|
item_id="iron_sword",
|
|
name="Iron Sword",
|
|
item_type=ItemType.WEAPON,
|
|
description="A sturdy sword",
|
|
stat_bonuses={"strength": 5},
|
|
damage=10,
|
|
)
|
|
|
|
# Add to inventory first
|
|
basic_character.add_item(weapon)
|
|
assert len(basic_character.inventory) == 1
|
|
|
|
# Equip weapon
|
|
basic_character.equip_item(weapon, "weapon")
|
|
assert "weapon" in basic_character.equipped
|
|
assert basic_character.equipped["weapon"].item_id == "iron_sword"
|
|
assert len(basic_character.inventory) == 0 # Removed from inventory
|
|
|
|
# Unequip weapon
|
|
unequipped = basic_character.unequip_item("weapon")
|
|
assert unequipped.item_id == "iron_sword"
|
|
assert "weapon" not in basic_character.equipped
|
|
assert len(basic_character.inventory) == 1 # Back in inventory
|
|
|
|
|
|
def test_equip_replaces_existing(basic_character):
|
|
"""Test that equipping a new item replaces the old one."""
|
|
weapon1 = Item(
|
|
item_id="iron_sword",
|
|
name="Iron Sword",
|
|
item_type=ItemType.WEAPON,
|
|
description="A sturdy sword",
|
|
damage=10,
|
|
)
|
|
|
|
weapon2 = Item(
|
|
item_id="steel_sword",
|
|
name="Steel Sword",
|
|
item_type=ItemType.WEAPON,
|
|
description="A better sword",
|
|
damage=15,
|
|
)
|
|
|
|
# Equip first weapon
|
|
basic_character.add_item(weapon1)
|
|
basic_character.equip_item(weapon1, "weapon")
|
|
|
|
# Equip second weapon
|
|
basic_character.add_item(weapon2)
|
|
previous = basic_character.equip_item(weapon2, "weapon")
|
|
|
|
assert previous.item_id == "iron_sword" # Old weapon returned
|
|
assert basic_character.equipped["weapon"].item_id == "steel_sword"
|
|
assert len(basic_character.inventory) == 1 # Old weapon back in inventory
|
|
|
|
|
|
def test_gold_management(basic_character):
|
|
"""Test adding and removing gold."""
|
|
assert basic_character.gold == 0
|
|
|
|
# Add gold
|
|
basic_character.add_gold(100)
|
|
assert basic_character.gold == 100
|
|
|
|
# Remove gold
|
|
success = basic_character.remove_gold(50)
|
|
assert success == True
|
|
assert basic_character.gold == 50
|
|
|
|
# Try to remove more than available
|
|
success = basic_character.remove_gold(100)
|
|
assert success == False
|
|
assert basic_character.gold == 50 # Unchanged
|
|
|
|
|
|
def test_can_afford(basic_character):
|
|
"""Test can_afford() method."""
|
|
basic_character.gold = 100
|
|
|
|
assert basic_character.can_afford(50) == True
|
|
assert basic_character.can_afford(100) == True
|
|
assert basic_character.can_afford(101) == False
|
|
|
|
|
|
def test_add_experience_no_level_up(basic_character):
|
|
"""Test adding experience without leveling up."""
|
|
leveled_up = basic_character.add_experience(50)
|
|
|
|
assert leveled_up == False
|
|
assert basic_character.level == 1
|
|
assert basic_character.experience == 50
|
|
|
|
|
|
def test_add_experience_with_level_up(basic_character):
|
|
"""Test adding enough experience to level up."""
|
|
# Level 1 requires 100 XP for level 2
|
|
leveled_up = basic_character.add_experience(100)
|
|
|
|
assert leveled_up == True
|
|
assert basic_character.level == 2
|
|
assert basic_character.experience == 0 # Reset
|
|
|
|
|
|
def test_add_experience_with_overflow(basic_character):
|
|
"""Test leveling up with overflow experience."""
|
|
# Level 1 requires 100 XP, give 150
|
|
leveled_up = basic_character.add_experience(150)
|
|
|
|
assert leveled_up == True
|
|
assert basic_character.level == 2
|
|
assert basic_character.experience == 50 # Overflow
|
|
|
|
|
|
def test_xp_calculation(basic_origin):
|
|
"""Test XP required for each level."""
|
|
char = Character(
|
|
character_id="test",
|
|
user_id="user",
|
|
name="Test",
|
|
player_class=PlayerClass(
|
|
class_id="test",
|
|
name="Test",
|
|
description="Test",
|
|
base_stats=Stats(),
|
|
),
|
|
origin=basic_origin,
|
|
)
|
|
|
|
# Formula: 100 * (level ^ 1.5)
|
|
assert char._calculate_xp_for_next_level() == 100 # Level 1→2
|
|
|
|
char.level = 2
|
|
assert char._calculate_xp_for_next_level() == 282 # Level 2→3
|
|
|
|
char.level = 3
|
|
assert char._calculate_xp_for_next_level() == 519 # Level 3→4
|
|
|
|
|
|
def test_get_unlocked_abilities(basic_character):
|
|
"""Test getting abilities from class + unlocked skills."""
|
|
# Should have starting abilities
|
|
abilities = basic_character.get_unlocked_abilities()
|
|
assert "basic_attack" in abilities
|
|
|
|
# TODO: When skills unlock abilities, test that here
|
|
|
|
|
|
def test_character_serialization(basic_character):
|
|
"""Test character to_dict() serialization."""
|
|
# Add some data
|
|
basic_character.gold = 500
|
|
basic_character.level = 3
|
|
basic_character.experience = 100
|
|
|
|
data = basic_character.to_dict()
|
|
|
|
assert data["character_id"] == "char_001"
|
|
assert data["user_id"] == "user_001"
|
|
assert data["name"] == "Test Hero"
|
|
assert data["level"] == 3
|
|
assert data["experience"] == 100
|
|
assert data["gold"] == 500
|
|
|
|
|
|
def test_character_deserialization(basic_player_class, basic_origin):
|
|
"""Test character from_dict() deserialization."""
|
|
data = {
|
|
"character_id": "char_002",
|
|
"user_id": "user_002",
|
|
"name": "Restored Hero",
|
|
"player_class": basic_player_class.to_dict(),
|
|
"origin": basic_origin.to_dict(),
|
|
"level": 5,
|
|
"experience": 200,
|
|
"base_stats": Stats(strength=15).to_dict(),
|
|
"unlocked_skills": ["power_strike"],
|
|
"inventory": [],
|
|
"equipped": {},
|
|
"gold": 1000,
|
|
"active_quests": ["quest_1"],
|
|
"discovered_locations": ["town_1"],
|
|
}
|
|
|
|
char = Character.from_dict(data)
|
|
|
|
assert char.character_id == "char_002"
|
|
assert char.name == "Restored Hero"
|
|
assert char.level == 5
|
|
assert char.gold == 1000
|
|
assert "power_strike" in char.unlocked_skills
|
|
|
|
|
|
def test_character_round_trip_serialization(basic_character):
|
|
"""Test that serialization and deserialization preserve all data."""
|
|
# Add complex state
|
|
basic_character.gold = 500
|
|
basic_character.level = 3
|
|
basic_character.unlocked_skills = ["power_strike"]
|
|
|
|
weapon = Item(
|
|
item_id="sword",
|
|
name="Sword",
|
|
item_type=ItemType.WEAPON,
|
|
description="A sword",
|
|
damage=10,
|
|
)
|
|
basic_character.equipped["weapon"] = weapon
|
|
|
|
# Serialize and deserialize
|
|
data = basic_character.to_dict()
|
|
restored = Character.from_dict(data)
|
|
|
|
assert restored.character_id == basic_character.character_id
|
|
assert restored.name == basic_character.name
|
|
assert restored.level == basic_character.level
|
|
assert restored.gold == basic_character.gold
|
|
assert restored.unlocked_skills == basic_character.unlocked_skills
|
|
assert "weapon" in restored.equipped
|
|
assert restored.equipped["weapon"].item_id == "sword"
|