feat: /evo status slash command and tests (WP-11) (#76)
All checks were successful
Build Docker Image / build (pull_request) Successful in 1m37s

Closes #76

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-03-13 09:07:28 -05:00
parent ce894cfa64
commit d12cdb8d97
3 changed files with 597 additions and 1 deletions

202
cogs/evolution.py Normal file
View File

@ -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))

View File

@ -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()

View File

@ -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()