paper-dynasty-discord/tests/test_refractor_commands.py
Cal Corum 45d71c61e3
All checks were successful
Build Docker Image / build (pull_request) Successful in 1m32s
fix: address reviewer issues — rename evolution endpoints, add TIER_BADGES
- Update module docstring: replace evolution/cards with refractor/cards,
  drop old tier names (Unranked/Initiate/Rising/Ascendant/Evolved), add
  correct tier names (Base Card/Base Chrome/Refractor/Gold Refractor/
  Superfractor)
- Fix API call: db_get("evolution/cards") → db_get("refractor/cards")
- Add TIER_BADGES dict {1:"[BC]", 2:"[R]", 3:"[GR]", 4:"[SF]"}
- Update format_refractor_entry to prepend badge label for T1-T4 (T0 has
  no badge)
- Add TestTierBadges test class (11 tests) asserting badge values and
  presence in formatted output
- Update test_player_name_in_output to accommodate badge-prefixed bold name

Dead utilities/evolution_notifications.py has no source file on this branch
(WP-14/PR #112 already delivered the replacement).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 15:08:39 -05:00

468 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Unit tests for refractor command helper functions (WP-11).
Tests cover:
- render_progress_bar: ASCII bar rendering at various fill levels
- format_refractor_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.refractor import (
render_progress_bar,
format_refractor_entry,
apply_close_filter,
paginate,
TIER_NAMES,
TIER_BADGES,
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_refractor_entry
# ---------------------------------------------------------------------------
class TestFormatRefractorEntry:
"""
Tests for format_refractor_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 appears bold in the first line (badge may prefix it)."""
result = format_refractor_entry(batter_state)
assert "Mike Trout" in result
assert "**" in result
def test_tier_label_in_output(self, batter_state):
"""Current tier name (Base Chrome for T1) appears in output."""
result = format_refractor_entry(batter_state)
assert "(Base Chrome)" in result
def test_progress_values_in_output(self, batter_state):
"""current/threshold values appear in output."""
result = format_refractor_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_refractor_entry(batter_state)
assert "PA+TB×2" in result
def test_tier_progression_arrow(self, batter_state):
"""T1 → T2 arrow progression appears for non-evolved cards."""
result = format_refractor_entry(batter_state)
assert "T1 → T2" in result
def test_sp_formula_label(self, sp_state):
"""SP formula label IP+K appears for starting pitchers."""
result = format_refractor_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_refractor_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_refractor_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_refractor_entry(evolved_state)
assert "" not in result
def test_two_line_output(self, batter_state):
"""Output always has exactly two lines (name line + bar line)."""
result = format_refractor_entry(batter_state)
lines = result.split("\n")
assert len(lines) == 2
# ---------------------------------------------------------------------------
# TIER_BADGES
# ---------------------------------------------------------------------------
class TestTierBadges:
"""
Verify TIER_BADGES values and that format_refractor_entry prepends badges
correctly for T1-T4. T0 cards should have no badge prefix.
"""
def test_t1_badge_value(self):
"""T1 badge is [BC] (Base Chrome)."""
assert TIER_BADGES[1] == "[BC]"
def test_t2_badge_value(self):
"""T2 badge is [R] (Refractor)."""
assert TIER_BADGES[2] == "[R]"
def test_t3_badge_value(self):
"""T3 badge is [GR] (Gold Refractor)."""
assert TIER_BADGES[3] == "[GR]"
def test_t4_badge_value(self):
"""T4 badge is [SF] (Superfractor)."""
assert TIER_BADGES[4] == "[SF]"
def test_t0_no_badge(self):
"""T0 has no badge entry in TIER_BADGES."""
assert 0 not in TIER_BADGES
def test_format_entry_t1_badge_present(self, batter_state):
"""format_refractor_entry prepends [BC] badge for T1 cards."""
result = format_refractor_entry(batter_state)
assert "[BC]" in result
def test_format_entry_t2_badge_present(self, sp_state):
"""format_refractor_entry prepends [R] badge for T2 cards."""
result = format_refractor_entry(sp_state)
assert "[R]" in result
def test_format_entry_t4_badge_present(self, evolved_state):
"""format_refractor_entry prepends [SF] badge for T4 cards."""
result = format_refractor_entry(evolved_state)
assert "[SF]" in result
def test_format_entry_t0_no_badge(self):
"""format_refractor_entry does not prepend any badge for T0 cards."""
state = {
"player_name": "Rookie Player",
"card_type": "batter",
"current_tier": 0,
"formula_value": 10,
"next_threshold": 50,
}
result = format_refractor_entry(state)
assert "[BC]" not in result
assert "[R]" not in result
assert "[GR]" not in result
assert "[SF]" not in result
def test_format_entry_badge_before_name(self, batter_state):
"""Badge appears before the player name in the bold section."""
result = format_refractor_entry(batter_state)
first_line = result.split("\n")[0]
badge_pos = first_line.find("[BC]")
name_pos = first_line.find("Mike Trout")
assert badge_pos < name_pos
# ---------------------------------------------------------------------------
# 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=Base Card, T1=Base Chrome, T2=Refractor, T3=Gold Refractor, T4=Superfractor
"""
def test_t0_base_card(self):
assert TIER_NAMES[0] == "Base Card"
def test_t1_base_chrome(self):
assert TIER_NAMES[1] == "Base Chrome"
def test_t2_refractor(self):
assert TIER_NAMES[2] == "Refractor"
def test_t3_gold_refractor(self):
assert TIER_NAMES[3] == "Gold Refractor"
def test_t4_superfractor(self):
assert TIER_NAMES[4] == "Superfractor"
# ---------------------------------------------------------------------------
# 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_refractor_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.refractor import Refractor
cog = Refractor(mock_bot)
with patch("cogs.refractor.get_team_by_owner", new=AsyncMock(return_value=None)):
with patch("cogs.refractor.db_get", new=AsyncMock()) as mock_db:
await cog.refractor_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_refractor_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 refractor cards yet);
the command should not crash or send a blank embed.
"""
from cogs.refractor import Refractor
cog = Refractor(mock_bot)
team = {"id": 1, "sname": "Test"}
with patch("cogs.refractor.get_team_by_owner", new=AsyncMock(return_value=team)):
with patch("cogs.refractor.db_get", new=AsyncMock(return_value={"cards": []})):
await cog.refractor_status.callback(cog, mock_interaction)
call_kwargs = mock_interaction.edit_original_response.call_args
content = call_kwargs.kwargs.get("content", "")
assert "no refractor data" in content.lower()