""" 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"