feat(api): add ItemRarity enum to item system
- Add ItemRarity enum with 5 tiers (common, uncommon, rare, epic, legendary) - Add rarity field to Item dataclass with COMMON default - Update Item serialization (to_dict/from_dict) for rarity - Export ItemRarity from models package - Add 24 comprehensive unit tests for Item and ItemRarity Part of Phase 4 Week 2: Inventory & Equipment System (Task 2.1)
This commit is contained in:
@@ -9,6 +9,7 @@ from app.models.enums import (
|
|||||||
EffectType,
|
EffectType,
|
||||||
DamageType,
|
DamageType,
|
||||||
ItemType,
|
ItemType,
|
||||||
|
ItemRarity,
|
||||||
StatType,
|
StatType,
|
||||||
AbilityType,
|
AbilityType,
|
||||||
CombatStatus,
|
CombatStatus,
|
||||||
@@ -53,6 +54,7 @@ __all__ = [
|
|||||||
"EffectType",
|
"EffectType",
|
||||||
"DamageType",
|
"DamageType",
|
||||||
"ItemType",
|
"ItemType",
|
||||||
|
"ItemRarity",
|
||||||
"StatType",
|
"StatType",
|
||||||
"AbilityType",
|
"AbilityType",
|
||||||
"CombatStatus",
|
"CombatStatus",
|
||||||
|
|||||||
@@ -40,6 +40,16 @@ class ItemType(Enum):
|
|||||||
QUEST_ITEM = "quest_item" # Story-related, non-tradeable
|
QUEST_ITEM = "quest_item" # Story-related, non-tradeable
|
||||||
|
|
||||||
|
|
||||||
|
class ItemRarity(Enum):
|
||||||
|
"""Item rarity tiers affecting drop rates, value, and visual styling."""
|
||||||
|
|
||||||
|
COMMON = "common" # White/gray - basic items
|
||||||
|
UNCOMMON = "uncommon" # Green - slightly better
|
||||||
|
RARE = "rare" # Blue - noticeably better
|
||||||
|
EPIC = "epic" # Purple - powerful items
|
||||||
|
LEGENDARY = "legendary" # Orange/gold - best items
|
||||||
|
|
||||||
|
|
||||||
class StatType(Enum):
|
class StatType(Enum):
|
||||||
"""Character attribute types."""
|
"""Character attribute types."""
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ including weapons, armor, consumables, and quest items.
|
|||||||
from dataclasses import dataclass, field, asdict
|
from dataclasses import dataclass, field, asdict
|
||||||
from typing import Dict, Any, List, Optional
|
from typing import Dict, Any, List, Optional
|
||||||
|
|
||||||
from app.models.enums import ItemType, DamageType
|
from app.models.enums import ItemType, ItemRarity, DamageType
|
||||||
from app.models.effects import Effect
|
from app.models.effects import Effect
|
||||||
|
|
||||||
|
|
||||||
@@ -24,6 +24,7 @@ class Item:
|
|||||||
item_id: Unique identifier
|
item_id: Unique identifier
|
||||||
name: Display name
|
name: Display name
|
||||||
item_type: Category (weapon, armor, consumable, quest_item)
|
item_type: Category (weapon, armor, consumable, quest_item)
|
||||||
|
rarity: Rarity tier (common, uncommon, rare, epic, legendary)
|
||||||
description: Item lore and information
|
description: Item lore and information
|
||||||
value: Gold value for buying/selling
|
value: Gold value for buying/selling
|
||||||
is_tradeable: Whether item can be sold on marketplace
|
is_tradeable: Whether item can be sold on marketplace
|
||||||
@@ -49,7 +50,8 @@ class Item:
|
|||||||
item_id: str
|
item_id: str
|
||||||
name: str
|
name: str
|
||||||
item_type: ItemType
|
item_type: ItemType
|
||||||
description: str
|
rarity: ItemRarity = ItemRarity.COMMON
|
||||||
|
description: str = ""
|
||||||
value: int = 0
|
value: int = 0
|
||||||
is_tradeable: bool = True
|
is_tradeable: bool = True
|
||||||
|
|
||||||
@@ -158,6 +160,7 @@ class Item:
|
|||||||
"""
|
"""
|
||||||
data = asdict(self)
|
data = asdict(self)
|
||||||
data["item_type"] = self.item_type.value
|
data["item_type"] = self.item_type.value
|
||||||
|
data["rarity"] = self.rarity.value
|
||||||
if self.damage_type:
|
if self.damage_type:
|
||||||
data["damage_type"] = self.damage_type.value
|
data["damage_type"] = self.damage_type.value
|
||||||
if self.elemental_damage_type:
|
if self.elemental_damage_type:
|
||||||
@@ -178,6 +181,7 @@ class Item:
|
|||||||
"""
|
"""
|
||||||
# Convert string values back to enums
|
# Convert string values back to enums
|
||||||
item_type = ItemType(data["item_type"])
|
item_type = ItemType(data["item_type"])
|
||||||
|
rarity = ItemRarity(data.get("rarity", "common"))
|
||||||
damage_type = DamageType(data["damage_type"]) if data.get("damage_type") else None
|
damage_type = DamageType(data["damage_type"]) if data.get("damage_type") else None
|
||||||
elemental_damage_type = (
|
elemental_damage_type = (
|
||||||
DamageType(data["elemental_damage_type"])
|
DamageType(data["elemental_damage_type"])
|
||||||
@@ -194,7 +198,8 @@ class Item:
|
|||||||
item_id=data["item_id"],
|
item_id=data["item_id"],
|
||||||
name=data["name"],
|
name=data["name"],
|
||||||
item_type=item_type,
|
item_type=item_type,
|
||||||
description=data["description"],
|
rarity=rarity,
|
||||||
|
description=data.get("description", ""),
|
||||||
value=data.get("value", 0),
|
value=data.get("value", 0),
|
||||||
is_tradeable=data.get("is_tradeable", True),
|
is_tradeable=data.get("is_tradeable", True),
|
||||||
stat_bonuses=data.get("stat_bonuses", {}),
|
stat_bonuses=data.get("stat_bonuses", {}),
|
||||||
|
|||||||
387
api/tests/test_items.py
Normal file
387
api/tests/test_items.py
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for Item dataclass and ItemRarity enum.
|
||||||
|
|
||||||
|
Tests item creation, rarity, type checking, and serialization.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from app.models.items import Item
|
||||||
|
from app.models.enums import ItemType, ItemRarity, DamageType
|
||||||
|
|
||||||
|
|
||||||
|
class TestItemRarityEnum:
|
||||||
|
"""Tests for ItemRarity enum."""
|
||||||
|
|
||||||
|
def test_rarity_values(self):
|
||||||
|
"""Test all rarity values exist and have correct string values."""
|
||||||
|
assert ItemRarity.COMMON.value == "common"
|
||||||
|
assert ItemRarity.UNCOMMON.value == "uncommon"
|
||||||
|
assert ItemRarity.RARE.value == "rare"
|
||||||
|
assert ItemRarity.EPIC.value == "epic"
|
||||||
|
assert ItemRarity.LEGENDARY.value == "legendary"
|
||||||
|
|
||||||
|
def test_rarity_from_string(self):
|
||||||
|
"""Test creating rarity from string value."""
|
||||||
|
assert ItemRarity("common") == ItemRarity.COMMON
|
||||||
|
assert ItemRarity("uncommon") == ItemRarity.UNCOMMON
|
||||||
|
assert ItemRarity("rare") == ItemRarity.RARE
|
||||||
|
assert ItemRarity("epic") == ItemRarity.EPIC
|
||||||
|
assert ItemRarity("legendary") == ItemRarity.LEGENDARY
|
||||||
|
|
||||||
|
def test_rarity_count(self):
|
||||||
|
"""Test that there are exactly 5 rarity tiers."""
|
||||||
|
assert len(ItemRarity) == 5
|
||||||
|
|
||||||
|
|
||||||
|
class TestItemCreation:
|
||||||
|
"""Tests for creating Item instances."""
|
||||||
|
|
||||||
|
def test_create_basic_item(self):
|
||||||
|
"""Test creating a basic item with minimal fields."""
|
||||||
|
item = Item(
|
||||||
|
item_id="test_item",
|
||||||
|
name="Test Item",
|
||||||
|
item_type=ItemType.QUEST_ITEM,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert item.item_id == "test_item"
|
||||||
|
assert item.name == "Test Item"
|
||||||
|
assert item.item_type == ItemType.QUEST_ITEM
|
||||||
|
assert item.rarity == ItemRarity.COMMON # Default
|
||||||
|
assert item.description == ""
|
||||||
|
assert item.value == 0
|
||||||
|
assert item.is_tradeable == True
|
||||||
|
|
||||||
|
def test_item_default_rarity_is_common(self):
|
||||||
|
"""Test that items default to COMMON rarity."""
|
||||||
|
item = Item(
|
||||||
|
item_id="sword_1",
|
||||||
|
name="Iron Sword",
|
||||||
|
item_type=ItemType.WEAPON,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert item.rarity == ItemRarity.COMMON
|
||||||
|
|
||||||
|
def test_create_item_with_rarity(self):
|
||||||
|
"""Test creating items with different rarity levels."""
|
||||||
|
uncommon = Item(
|
||||||
|
item_id="sword_2",
|
||||||
|
name="Steel Sword",
|
||||||
|
item_type=ItemType.WEAPON,
|
||||||
|
rarity=ItemRarity.UNCOMMON,
|
||||||
|
)
|
||||||
|
assert uncommon.rarity == ItemRarity.UNCOMMON
|
||||||
|
|
||||||
|
rare = Item(
|
||||||
|
item_id="sword_3",
|
||||||
|
name="Mithril Sword",
|
||||||
|
item_type=ItemType.WEAPON,
|
||||||
|
rarity=ItemRarity.RARE,
|
||||||
|
)
|
||||||
|
assert rare.rarity == ItemRarity.RARE
|
||||||
|
|
||||||
|
epic = Item(
|
||||||
|
item_id="sword_4",
|
||||||
|
name="Dragon Sword",
|
||||||
|
item_type=ItemType.WEAPON,
|
||||||
|
rarity=ItemRarity.EPIC,
|
||||||
|
)
|
||||||
|
assert epic.rarity == ItemRarity.EPIC
|
||||||
|
|
||||||
|
legendary = Item(
|
||||||
|
item_id="sword_5",
|
||||||
|
name="Excalibur",
|
||||||
|
item_type=ItemType.WEAPON,
|
||||||
|
rarity=ItemRarity.LEGENDARY,
|
||||||
|
)
|
||||||
|
assert legendary.rarity == ItemRarity.LEGENDARY
|
||||||
|
|
||||||
|
def test_create_weapon(self):
|
||||||
|
"""Test creating a weapon with all weapon-specific fields."""
|
||||||
|
weapon = Item(
|
||||||
|
item_id="fire_sword",
|
||||||
|
name="Flame Blade",
|
||||||
|
item_type=ItemType.WEAPON,
|
||||||
|
rarity=ItemRarity.RARE,
|
||||||
|
description="A sword wreathed in flames.",
|
||||||
|
value=500,
|
||||||
|
damage=25,
|
||||||
|
damage_type=DamageType.PHYSICAL,
|
||||||
|
crit_chance=0.15,
|
||||||
|
crit_multiplier=2.5,
|
||||||
|
elemental_damage_type=DamageType.FIRE,
|
||||||
|
physical_ratio=0.7,
|
||||||
|
elemental_ratio=0.3,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert weapon.is_weapon() == True
|
||||||
|
assert weapon.is_elemental_weapon() == True
|
||||||
|
assert weapon.damage == 25
|
||||||
|
assert weapon.crit_chance == 0.15
|
||||||
|
assert weapon.crit_multiplier == 2.5
|
||||||
|
assert weapon.elemental_damage_type == DamageType.FIRE
|
||||||
|
assert weapon.physical_ratio == 0.7
|
||||||
|
assert weapon.elemental_ratio == 0.3
|
||||||
|
|
||||||
|
def test_create_armor(self):
|
||||||
|
"""Test creating armor with defense/resistance."""
|
||||||
|
armor = Item(
|
||||||
|
item_id="plate_armor",
|
||||||
|
name="Steel Plate Armor",
|
||||||
|
item_type=ItemType.ARMOR,
|
||||||
|
rarity=ItemRarity.UNCOMMON,
|
||||||
|
description="Heavy steel armor.",
|
||||||
|
value=300,
|
||||||
|
defense=15,
|
||||||
|
resistance=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert armor.is_armor() == True
|
||||||
|
assert armor.defense == 15
|
||||||
|
assert armor.resistance == 5
|
||||||
|
|
||||||
|
def test_create_consumable(self):
|
||||||
|
"""Test creating a consumable item."""
|
||||||
|
potion = Item(
|
||||||
|
item_id="health_potion",
|
||||||
|
name="Health Potion",
|
||||||
|
item_type=ItemType.CONSUMABLE,
|
||||||
|
rarity=ItemRarity.COMMON,
|
||||||
|
description="Restores 50 HP.",
|
||||||
|
value=25,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert potion.is_consumable() == True
|
||||||
|
assert potion.is_tradeable == True
|
||||||
|
|
||||||
|
|
||||||
|
class TestItemTypeMethods:
|
||||||
|
"""Tests for item type checking methods."""
|
||||||
|
|
||||||
|
def test_is_weapon(self):
|
||||||
|
"""Test is_weapon() method."""
|
||||||
|
weapon = Item(item_id="w", name="W", item_type=ItemType.WEAPON)
|
||||||
|
armor = Item(item_id="a", name="A", item_type=ItemType.ARMOR)
|
||||||
|
|
||||||
|
assert weapon.is_weapon() == True
|
||||||
|
assert armor.is_weapon() == False
|
||||||
|
|
||||||
|
def test_is_armor(self):
|
||||||
|
"""Test is_armor() method."""
|
||||||
|
weapon = Item(item_id="w", name="W", item_type=ItemType.WEAPON)
|
||||||
|
armor = Item(item_id="a", name="A", item_type=ItemType.ARMOR)
|
||||||
|
|
||||||
|
assert armor.is_armor() == True
|
||||||
|
assert weapon.is_armor() == False
|
||||||
|
|
||||||
|
def test_is_consumable(self):
|
||||||
|
"""Test is_consumable() method."""
|
||||||
|
consumable = Item(item_id="c", name="C", item_type=ItemType.CONSUMABLE)
|
||||||
|
weapon = Item(item_id="w", name="W", item_type=ItemType.WEAPON)
|
||||||
|
|
||||||
|
assert consumable.is_consumable() == True
|
||||||
|
assert weapon.is_consumable() == False
|
||||||
|
|
||||||
|
def test_is_quest_item(self):
|
||||||
|
"""Test is_quest_item() method."""
|
||||||
|
quest = Item(item_id="q", name="Q", item_type=ItemType.QUEST_ITEM)
|
||||||
|
weapon = Item(item_id="w", name="W", item_type=ItemType.WEAPON)
|
||||||
|
|
||||||
|
assert quest.is_quest_item() == True
|
||||||
|
assert weapon.is_quest_item() == False
|
||||||
|
|
||||||
|
|
||||||
|
class TestItemSerialization:
|
||||||
|
"""Tests for Item serialization and deserialization."""
|
||||||
|
|
||||||
|
def test_to_dict_includes_rarity(self):
|
||||||
|
"""Test that to_dict() includes rarity as string."""
|
||||||
|
item = Item(
|
||||||
|
item_id="test",
|
||||||
|
name="Test",
|
||||||
|
item_type=ItemType.WEAPON,
|
||||||
|
rarity=ItemRarity.EPIC,
|
||||||
|
description="Test item",
|
||||||
|
)
|
||||||
|
|
||||||
|
data = item.to_dict()
|
||||||
|
|
||||||
|
assert data["rarity"] == "epic"
|
||||||
|
assert data["item_type"] == "weapon"
|
||||||
|
|
||||||
|
def test_from_dict_parses_rarity(self):
|
||||||
|
"""Test that from_dict() parses rarity correctly."""
|
||||||
|
data = {
|
||||||
|
"item_id": "test",
|
||||||
|
"name": "Test",
|
||||||
|
"item_type": "weapon",
|
||||||
|
"rarity": "legendary",
|
||||||
|
"description": "Test item",
|
||||||
|
}
|
||||||
|
|
||||||
|
item = Item.from_dict(data)
|
||||||
|
|
||||||
|
assert item.rarity == ItemRarity.LEGENDARY
|
||||||
|
assert item.item_type == ItemType.WEAPON
|
||||||
|
|
||||||
|
def test_from_dict_defaults_to_common_rarity(self):
|
||||||
|
"""Test that from_dict() defaults to COMMON if rarity missing."""
|
||||||
|
data = {
|
||||||
|
"item_id": "test",
|
||||||
|
"name": "Test",
|
||||||
|
"item_type": "weapon",
|
||||||
|
"description": "Test item",
|
||||||
|
# No rarity field
|
||||||
|
}
|
||||||
|
|
||||||
|
item = Item.from_dict(data)
|
||||||
|
|
||||||
|
assert item.rarity == ItemRarity.COMMON
|
||||||
|
|
||||||
|
def test_round_trip_serialization(self):
|
||||||
|
"""Test serialization and deserialization preserve all data."""
|
||||||
|
original = Item(
|
||||||
|
item_id="flame_sword",
|
||||||
|
name="Flame Blade",
|
||||||
|
item_type=ItemType.WEAPON,
|
||||||
|
rarity=ItemRarity.RARE,
|
||||||
|
description="A fiery blade.",
|
||||||
|
value=500,
|
||||||
|
damage=25,
|
||||||
|
damage_type=DamageType.PHYSICAL,
|
||||||
|
crit_chance=0.12,
|
||||||
|
crit_multiplier=2.5,
|
||||||
|
elemental_damage_type=DamageType.FIRE,
|
||||||
|
physical_ratio=0.7,
|
||||||
|
elemental_ratio=0.3,
|
||||||
|
defense=0,
|
||||||
|
resistance=0,
|
||||||
|
required_level=5,
|
||||||
|
stat_bonuses={"strength": 3},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Serialize then deserialize
|
||||||
|
data = original.to_dict()
|
||||||
|
restored = Item.from_dict(data)
|
||||||
|
|
||||||
|
assert restored.item_id == original.item_id
|
||||||
|
assert restored.name == original.name
|
||||||
|
assert restored.item_type == original.item_type
|
||||||
|
assert restored.rarity == original.rarity
|
||||||
|
assert restored.description == original.description
|
||||||
|
assert restored.value == original.value
|
||||||
|
assert restored.damage == original.damage
|
||||||
|
assert restored.damage_type == original.damage_type
|
||||||
|
assert restored.crit_chance == original.crit_chance
|
||||||
|
assert restored.crit_multiplier == original.crit_multiplier
|
||||||
|
assert restored.elemental_damage_type == original.elemental_damage_type
|
||||||
|
assert restored.physical_ratio == original.physical_ratio
|
||||||
|
assert restored.elemental_ratio == original.elemental_ratio
|
||||||
|
assert restored.required_level == original.required_level
|
||||||
|
assert restored.stat_bonuses == original.stat_bonuses
|
||||||
|
|
||||||
|
def test_round_trip_all_rarities(self):
|
||||||
|
"""Test round-trip serialization for all rarity levels."""
|
||||||
|
for rarity in ItemRarity:
|
||||||
|
original = Item(
|
||||||
|
item_id=f"item_{rarity.value}",
|
||||||
|
name=f"{rarity.value.title()} Item",
|
||||||
|
item_type=ItemType.CONSUMABLE,
|
||||||
|
rarity=rarity,
|
||||||
|
)
|
||||||
|
|
||||||
|
data = original.to_dict()
|
||||||
|
restored = Item.from_dict(data)
|
||||||
|
|
||||||
|
assert restored.rarity == rarity
|
||||||
|
|
||||||
|
|
||||||
|
class TestItemEquippability:
|
||||||
|
"""Tests for item equip requirements."""
|
||||||
|
|
||||||
|
def test_can_equip_level_requirement(self):
|
||||||
|
"""Test level requirement checking."""
|
||||||
|
item = Item(
|
||||||
|
item_id="high_level_sword",
|
||||||
|
name="Epic Sword",
|
||||||
|
item_type=ItemType.WEAPON,
|
||||||
|
required_level=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert item.can_equip(character_level=5) == False
|
||||||
|
assert item.can_equip(character_level=10) == True
|
||||||
|
assert item.can_equip(character_level=15) == True
|
||||||
|
|
||||||
|
def test_can_equip_class_requirement(self):
|
||||||
|
"""Test class requirement checking."""
|
||||||
|
item = Item(
|
||||||
|
item_id="mage_staff",
|
||||||
|
name="Mage Staff",
|
||||||
|
item_type=ItemType.WEAPON,
|
||||||
|
required_class="mage",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert item.can_equip(character_level=1, character_class="warrior") == False
|
||||||
|
assert item.can_equip(character_level=1, character_class="mage") == True
|
||||||
|
|
||||||
|
|
||||||
|
class TestItemStatBonuses:
|
||||||
|
"""Tests for item stat bonus methods."""
|
||||||
|
|
||||||
|
def test_get_total_stat_bonus(self):
|
||||||
|
"""Test getting stat bonuses from items."""
|
||||||
|
item = Item(
|
||||||
|
item_id="ring_of_power",
|
||||||
|
name="Ring of Power",
|
||||||
|
item_type=ItemType.ARMOR,
|
||||||
|
stat_bonuses={"strength": 5, "constitution": 3},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert item.get_total_stat_bonus("strength") == 5
|
||||||
|
assert item.get_total_stat_bonus("constitution") == 3
|
||||||
|
assert item.get_total_stat_bonus("dexterity") == 0 # Not in bonuses
|
||||||
|
|
||||||
|
|
||||||
|
class TestItemRepr:
|
||||||
|
"""Tests for item string representation."""
|
||||||
|
|
||||||
|
def test_weapon_repr(self):
|
||||||
|
"""Test weapon __repr__ output."""
|
||||||
|
weapon = Item(
|
||||||
|
item_id="sword",
|
||||||
|
name="Iron Sword",
|
||||||
|
item_type=ItemType.WEAPON,
|
||||||
|
damage=10,
|
||||||
|
value=50,
|
||||||
|
)
|
||||||
|
|
||||||
|
repr_str = repr(weapon)
|
||||||
|
assert "Iron Sword" in repr_str
|
||||||
|
assert "weapon" in repr_str
|
||||||
|
|
||||||
|
def test_armor_repr(self):
|
||||||
|
"""Test armor __repr__ output."""
|
||||||
|
armor = Item(
|
||||||
|
item_id="armor",
|
||||||
|
name="Leather Armor",
|
||||||
|
item_type=ItemType.ARMOR,
|
||||||
|
defense=5,
|
||||||
|
value=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
repr_str = repr(armor)
|
||||||
|
assert "Leather Armor" in repr_str
|
||||||
|
assert "armor" in repr_str
|
||||||
|
|
||||||
|
def test_consumable_repr(self):
|
||||||
|
"""Test consumable __repr__ output."""
|
||||||
|
potion = Item(
|
||||||
|
item_id="potion",
|
||||||
|
name="Health Potion",
|
||||||
|
item_type=ItemType.CONSUMABLE,
|
||||||
|
value=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
repr_str = repr(potion)
|
||||||
|
assert "Health Potion" in repr_str
|
||||||
|
assert "consumable" in repr_str
|
||||||
Reference in New Issue
Block a user