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>
395 lines
13 KiB
Python
395 lines
13 KiB
Python
"""
|
||
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()
|