diff --git a/cogs/evolution.py b/cogs/evolution.py new file mode 100644 index 0000000..99999ae --- /dev/null +++ b/cogs/evolution.py @@ -0,0 +1,202 @@ +""" +Evolution cog — /evo status slash command. + +Displays a team's evolution progress: formula value vs next threshold +with a progress bar, paginated 10 cards per page. + +Depends on WP-07 (evolution/cards API endpoint). +""" + +import logging +from typing import Optional + +import discord +from discord import app_commands +from discord.ext import commands + +from api_calls import db_get +from helpers import get_team_by_owner + +logger = logging.getLogger("discord_app") + +PAGE_SIZE = 10 + +TIER_NAMES = { + 0: "Unranked", + 1: "Initiate", + 2: "Rising", + 3: "Ascendant", + 4: "Evolved", +} + +FORMULA_LABELS = { + "batter": "PA+TB\u00d72", + "sp": "IP+K", + "rp": "IP+K", +} + + +def render_progress_bar(current: int, threshold: int, width: int = 10) -> str: + """ + Render a fixed-width ASCII progress bar. + + Examples: + render_progress_bar(120, 149) -> '[========--]' + render_progress_bar(0, 100) -> '[----------]' + render_progress_bar(100, 100) -> '[==========]' + """ + if threshold <= 0: + filled = width + else: + ratio = min(current / threshold, 1.0) + filled = round(ratio * width) + empty = width - filled + return f"[{'=' * filled}{'-' * empty}]" + + +def format_evo_entry(card_state: dict) -> str: + """ + Format a single card state dict as a display string. + + Expected keys: player_name, card_type, current_tier, formula_value, + next_threshold (None if fully evolved). + + Output example: + **Mike Trout** (Initiate) + [========--] 120/149 (PA+TB×2) — T1 → T2 + """ + player_name = card_state.get("player_name", "Unknown") + card_type = card_state.get("card_type", "batter") + current_tier = card_state.get("current_tier", 0) + formula_value = card_state.get("formula_value", 0) + next_threshold = card_state.get("next_threshold") + + tier_label = TIER_NAMES.get(current_tier, f"T{current_tier}") + formula_label = FORMULA_LABELS.get(card_type, card_type) + + if current_tier >= 4 or next_threshold is None: + bar = "[==========]" + detail = "FULLY EVOLVED \u2605" + else: + bar = render_progress_bar(formula_value, next_threshold) + detail = f"{formula_value}/{next_threshold} ({formula_label}) \u2014 T{current_tier} \u2192 T{current_tier + 1}" + + first_line = f"**{player_name}** ({tier_label})" + second_line = f"{bar} {detail}" + return f"{first_line}\n{second_line}" + + +def apply_close_filter(card_states: list) -> list: + """ + Return only cards within 80% of their next tier threshold. + + Fully evolved cards (T4 or no next_threshold) are excluded. + """ + result = [] + for state in card_states: + current_tier = state.get("current_tier", 0) + formula_value = state.get("formula_value", 0) + next_threshold = state.get("next_threshold") + if current_tier >= 4 or not next_threshold: + continue + if formula_value >= 0.8 * next_threshold: + result.append(state) + return result + + +def paginate(items: list, page: int, page_size: int = PAGE_SIZE) -> tuple: + """ + Slice items for the given 1-indexed page. + + Returns (page_items, total_pages). Page is clamped to valid range. + """ + total_pages = max(1, (len(items) + page_size - 1) // page_size) + page = max(1, min(page, total_pages)) + start = (page - 1) * page_size + return items[start : start + page_size], total_pages + + +class Evolution(commands.Cog): + """Evolution progress tracking slash commands.""" + + def __init__(self, bot): + self.bot = bot + + evo_group = app_commands.Group( + name="evo", description="Evolution tracking commands" + ) + + @evo_group.command(name="status", description="Show your team's evolution progress") + @app_commands.describe( + type="Card type filter (batter, sp, rp)", + season="Season number (default: current)", + tier="Filter by current tier (0-4)", + progress='Use "close" to show cards within 80% of their next tier', + page="Page number (default: 1, 10 cards per page)", + ) + async def evo_status( + self, + interaction: discord.Interaction, + type: Optional[str] = None, + season: Optional[int] = None, + tier: Optional[int] = None, + progress: Optional[str] = None, + page: int = 1, + ): + """Show a paginated view of the invoking user's team evolution progress.""" + await interaction.response.defer(ephemeral=True) + + team = await get_team_by_owner(interaction.user.id) + if not team: + await interaction.edit_original_response( + content="You don't have a team. Sign up with /newteam first." + ) + return + + params = [("team_id", team["id"])] + if type: + params.append(("card_type", type)) + if season is not None: + params.append(("season", season)) + if tier is not None: + params.append(("tier", tier)) + + data = await db_get("evolution/cards", params=params) + if not data: + await interaction.edit_original_response( + content="No evolution data found for your team." + ) + return + + items = data if isinstance(data, list) else data.get("cards", []) + if not items: + await interaction.edit_original_response( + content="No evolution data found for your team." + ) + return + + if progress == "close": + items = apply_close_filter(items) + if not items: + await interaction.edit_original_response( + content="No cards are currently close to a tier advancement." + ) + return + + page_items, total_pages = paginate(items, page) + lines = [format_evo_entry(state) for state in page_items] + + embed = discord.Embed( + title=f"{team['sname']} Evolution Status", + description="\n\n".join(lines), + color=0x6F42C1, + ) + embed.set_footer( + text=f"Page {page}/{total_pages} \u00b7 {len(items)} card(s) total" + ) + + await interaction.edit_original_response(embed=embed) + + +async def setup(bot): + await bot.add_cog(Evolution(bot)) diff --git a/paperdynasty.py b/paperdynasty.py index 951654a..203703a 100644 --- a/paperdynasty.py +++ b/paperdynasty.py @@ -1,5 +1,4 @@ import discord -import datetime import logging from logging.handlers import RotatingFileHandler import asyncio @@ -54,6 +53,7 @@ COGS = [ "cogs.players", "cogs.gameplay", "cogs.economy_new.scouting", + "cogs.evolution", ] intents = discord.Intents.default() diff --git a/tests/test_evolution_commands.py b/tests/test_evolution_commands.py new file mode 100644 index 0000000..8aab128 --- /dev/null +++ b/tests/test_evolution_commands.py @@ -0,0 +1,394 @@ +""" +Unit tests for evolution command helper functions (WP-11). + +Tests cover: +- render_progress_bar: ASCII bar rendering at various fill levels +- format_evo_entry: Full card state formatting including fully evolved case +- apply_close_filter: 80% proximity filter logic +- paginate: 1-indexed page slicing and total-page calculation +- TIER_NAMES: Display names for all tiers +- Slash command: empty roster and no-team responses (async, uses mocks) + +All tests are pure-unit unless marked otherwise; no network calls are made. +""" + +import sys +import os + +import pytest +from unittest.mock import AsyncMock, Mock, patch +import discord +from discord.ext import commands + +# Make the repo root importable +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from cogs.evolution import ( + render_progress_bar, + format_evo_entry, + apply_close_filter, + paginate, + TIER_NAMES, + PAGE_SIZE, +) + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def batter_state(): + """A mid-progress batter card state.""" + return { + "player_name": "Mike Trout", + "card_type": "batter", + "current_tier": 1, + "formula_value": 120, + "next_threshold": 149, + } + + +@pytest.fixture +def evolved_state(): + """A fully evolved card state (T4).""" + return { + "player_name": "Shohei Ohtani", + "card_type": "batter", + "current_tier": 4, + "formula_value": 300, + "next_threshold": None, + } + + +@pytest.fixture +def sp_state(): + """A starting pitcher card state at T2.""" + return { + "player_name": "Sandy Alcantara", + "card_type": "sp", + "current_tier": 2, + "formula_value": 95, + "next_threshold": 120, + } + + +# --------------------------------------------------------------------------- +# render_progress_bar +# --------------------------------------------------------------------------- + + +class TestRenderProgressBar: + """ + Tests for render_progress_bar(). + + Verifies width, fill character, empty character, boundary conditions, + and clamping when current exceeds threshold. + """ + + def test_empty_bar(self): + """current=0 → all dashes.""" + assert render_progress_bar(0, 100) == "[----------]" + + def test_full_bar(self): + """current == threshold → all equals.""" + assert render_progress_bar(100, 100) == "[==========]" + + def test_partial_fill(self): + """120/149 ≈ 80.5% → 8 filled of 10.""" + bar = render_progress_bar(120, 149) + assert bar == "[========--]" + + def test_half_fill(self): + """50/100 = 50% → 5 filled.""" + assert render_progress_bar(50, 100) == "[=====-----]" + + def test_over_threshold_clamps_to_full(self): + """current > threshold should not overflow the bar.""" + assert render_progress_bar(200, 100) == "[==========]" + + def test_zero_threshold_returns_full_bar(self): + """threshold=0 avoids division by zero and returns full bar.""" + assert render_progress_bar(0, 0) == "[==========]" + + def test_custom_width(self): + """Width parameter controls bar length.""" + bar = render_progress_bar(5, 10, width=4) + assert bar == "[==--]" + + +# --------------------------------------------------------------------------- +# format_evo_entry +# --------------------------------------------------------------------------- + + +class TestFormatEvoEntry: + """ + Tests for format_evo_entry(). + + Verifies player name, tier label, progress bar, formula label, + and the special fully-evolved formatting. + """ + + def test_player_name_in_output(self, batter_state): + """Player name is bold in the first line.""" + result = format_evo_entry(batter_state) + assert "**Mike Trout**" in result + + def test_tier_label_in_output(self, batter_state): + """Current tier name (Initiate for T1) appears in output.""" + result = format_evo_entry(batter_state) + assert "(Initiate)" in result + + def test_progress_values_in_output(self, batter_state): + """current/threshold values appear in output.""" + result = format_evo_entry(batter_state) + assert "120/149" in result + + def test_formula_label_batter(self, batter_state): + """Batter formula label PA+TB×2 appears in output.""" + result = format_evo_entry(batter_state) + assert "PA+TB\u00d72" in result + + def test_tier_progression_arrow(self, batter_state): + """T1 → T2 arrow progression appears for non-evolved cards.""" + result = format_evo_entry(batter_state) + assert "T1 \u2192 T2" in result + + def test_sp_formula_label(self, sp_state): + """SP formula label IP+K appears for starting pitchers.""" + result = format_evo_entry(sp_state) + assert "IP+K" in result + + def test_fully_evolved_no_threshold(self, evolved_state): + """T4 card with next_threshold=None shows FULLY EVOLVED.""" + result = format_evo_entry(evolved_state) + assert "FULLY EVOLVED" in result + + def test_fully_evolved_by_tier(self, batter_state): + """current_tier=4 triggers fully evolved display even with a threshold.""" + batter_state["current_tier"] = 4 + batter_state["next_threshold"] = 200 + result = format_evo_entry(batter_state) + assert "FULLY EVOLVED" in result + + def test_fully_evolved_no_arrow(self, evolved_state): + """Fully evolved cards don't show a tier arrow.""" + result = format_evo_entry(evolved_state) + assert "\u2192" not in result + + def test_two_line_output(self, batter_state): + """Output always has exactly two lines (name line + bar line).""" + result = format_evo_entry(batter_state) + lines = result.split("\n") + assert len(lines) == 2 + + +# --------------------------------------------------------------------------- +# apply_close_filter +# --------------------------------------------------------------------------- + + +class TestApplyCloseFilter: + """ + Tests for apply_close_filter(). + + 'Close' means formula_value >= 80% of next_threshold. + Fully evolved (T4 or no threshold) cards are excluded from results. + """ + + def test_close_card_included(self): + """Card at exactly 80% is included.""" + state = {"current_tier": 1, "formula_value": 80, "next_threshold": 100} + assert apply_close_filter([state]) == [state] + + def test_above_80_percent_included(self): + """Card above 80% is included.""" + state = {"current_tier": 0, "formula_value": 95, "next_threshold": 100} + assert apply_close_filter([state]) == [state] + + def test_below_80_percent_excluded(self): + """Card below 80% threshold is excluded.""" + state = {"current_tier": 1, "formula_value": 79, "next_threshold": 100} + assert apply_close_filter([state]) == [] + + def test_fully_evolved_excluded(self): + """T4 cards are never returned by close filter.""" + state = {"current_tier": 4, "formula_value": 300, "next_threshold": None} + assert apply_close_filter([state]) == [] + + def test_none_threshold_excluded(self): + """Cards with no next_threshold (regardless of tier) are excluded.""" + state = {"current_tier": 3, "formula_value": 200, "next_threshold": None} + assert apply_close_filter([state]) == [] + + def test_mixed_list(self): + """Only qualifying cards are returned from a mixed list.""" + close = {"current_tier": 1, "formula_value": 90, "next_threshold": 100} + not_close = {"current_tier": 1, "formula_value": 50, "next_threshold": 100} + evolved = {"current_tier": 4, "formula_value": 300, "next_threshold": None} + result = apply_close_filter([close, not_close, evolved]) + assert result == [close] + + def test_empty_list(self): + """Empty input returns empty list.""" + assert apply_close_filter([]) == [] + + +# --------------------------------------------------------------------------- +# paginate +# --------------------------------------------------------------------------- + + +class TestPaginate: + """ + Tests for paginate(). + + Verifies 1-indexed page slicing, total page count calculation, + page clamping, and PAGE_SIZE default. + """ + + def _items(self, n): + return list(range(n)) + + def test_single_page_all_items(self): + """Fewer items than page size returns all on page 1.""" + items, total = paginate(self._items(5), page=1) + assert items == [0, 1, 2, 3, 4] + assert total == 1 + + def test_first_page(self): + """Page 1 returns first PAGE_SIZE items.""" + items, total = paginate(self._items(25), page=1) + assert items == list(range(10)) + assert total == 3 + + def test_second_page(self): + """Page 2 returns next PAGE_SIZE items.""" + items, total = paginate(self._items(25), page=2) + assert items == list(range(10, 20)) + + def test_last_page_partial(self): + """Last page returns remaining items (fewer than PAGE_SIZE).""" + items, total = paginate(self._items(25), page=3) + assert items == [20, 21, 22, 23, 24] + assert total == 3 + + def test_page_clamp_low(self): + """Page 0 or negative is clamped to page 1.""" + items, _ = paginate(self._items(15), page=0) + assert items == list(range(10)) + + def test_page_clamp_high(self): + """Page beyond total is clamped to last page.""" + items, total = paginate(self._items(15), page=99) + assert items == [10, 11, 12, 13, 14] + assert total == 2 + + def test_empty_list_returns_empty_page(self): + """Empty input returns empty page with total_pages=1.""" + items, total = paginate([], page=1) + assert items == [] + assert total == 1 + + def test_exact_page_boundary(self): + """Exactly PAGE_SIZE items → 1 full page.""" + items, total = paginate(self._items(PAGE_SIZE), page=1) + assert len(items) == PAGE_SIZE + assert total == 1 + + +# --------------------------------------------------------------------------- +# TIER_NAMES +# --------------------------------------------------------------------------- + + +class TestTierNames: + """ + Verify all tier display names are correctly defined. + + T0=Unranked, T1=Initiate, T2=Rising, T3=Ascendant, T4=Evolved + """ + + def test_t0_unranked(self): + assert TIER_NAMES[0] == "Unranked" + + def test_t1_initiate(self): + assert TIER_NAMES[1] == "Initiate" + + def test_t2_rising(self): + assert TIER_NAMES[2] == "Rising" + + def test_t3_ascendant(self): + assert TIER_NAMES[3] == "Ascendant" + + def test_t4_evolved(self): + assert TIER_NAMES[4] == "Evolved" + + +# --------------------------------------------------------------------------- +# Slash command: empty roster / no-team scenarios +# --------------------------------------------------------------------------- + + +@pytest.fixture +def mock_bot(): + bot = AsyncMock(spec=commands.Bot) + return bot + + +@pytest.fixture +def mock_interaction(): + interaction = AsyncMock(spec=discord.Interaction) + interaction.response = AsyncMock() + interaction.response.defer = AsyncMock() + interaction.edit_original_response = AsyncMock() + interaction.user = Mock() + interaction.user.id = 12345 + return interaction + + +@pytest.mark.asyncio +async def test_evo_status_no_team(mock_bot, mock_interaction): + """ + When the user has no team, the command replies with a signup prompt + and does not call db_get. + + Why: get_team_by_owner returning None means the user is unregistered; + the command must short-circuit before hitting the API. + """ + from cogs.evolution import Evolution + + cog = Evolution(mock_bot) + + with patch("cogs.evolution.get_team_by_owner", new=AsyncMock(return_value=None)): + with patch("cogs.evolution.db_get", new=AsyncMock()) as mock_db: + await cog.evo_status.callback(cog, mock_interaction) + mock_db.assert_not_called() + + call_kwargs = mock_interaction.edit_original_response.call_args + content = call_kwargs.kwargs.get("content", "") + assert "newteam" in content.lower() or "team" in content.lower() + + +@pytest.mark.asyncio +async def test_evo_status_empty_roster(mock_bot, mock_interaction): + """ + When the API returns an empty card list, the command sends an + informative 'no data' message rather than an empty embed. + + Why: An empty list is valid (team has no evolved cards yet); + the command should not crash or send a blank embed. + """ + from cogs.evolution import Evolution + + cog = Evolution(mock_bot) + team = {"id": 1, "sname": "Test"} + + with patch("cogs.evolution.get_team_by_owner", new=AsyncMock(return_value=team)): + with patch("cogs.evolution.db_get", new=AsyncMock(return_value={"cards": []})): + await cog.evo_status.callback(cog, mock_interaction) + + call_kwargs = mock_interaction.edit_original_response.call_args + content = call_kwargs.kwargs.get("content", "") + assert "no evolution data" in content.lower()