All checks were successful
Ruff Lint / lint (pull_request) Successful in 13s
Implements Roadmap 2.5b: new /compare command lets players compare two cards of the same type (batter vs batter or pitcher vs pitcher) in a side-by-side embed with directional delta arrows (▲▼═). - cogs/compare.py: new CompareCog with /compare slash command and player_autocomplete on both params; fetches battingcard/pitchingcard data from API; validates type compatibility; sends public embed - tests/test_compare_command.py: 30 unit tests covering _delta_arrow, _is_pitcher, batter/pitcher embed builders, type mismatch error, and edge cases (None stats, tied values) - paperdynasty.py: registers cogs.compare in COGS list Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
396 lines
14 KiB
Python
396 lines
14 KiB
Python
"""
|
||
Tests for the /compare slash command embed builder (cogs/compare.py).
|
||
|
||
What:
|
||
- build_compare_embed() is a pure function that takes two card-data dicts
|
||
and returns a discord.Embed.
|
||
- Tests verify field count, arrow directions, type-mismatch raises, and
|
||
tied stats.
|
||
|
||
Why:
|
||
- The embed builder has no Discord I/O so it can be tested synchronously
|
||
without a bot or API calls.
|
||
- Correct arrow direction is critical for usability: wrong arrows would
|
||
mislead players making trade/lineup decisions.
|
||
"""
|
||
|
||
import sys
|
||
import os
|
||
|
||
import pytest
|
||
import discord
|
||
|
||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||
|
||
from cogs.compare import (
|
||
build_compare_embed,
|
||
CompareMismatchError,
|
||
_delta_arrow,
|
||
_is_pitcher,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Fixtures
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _make_player(
|
||
name: str = "Test Player",
|
||
pos_1: str = "CF",
|
||
rarity_value: int = 3,
|
||
rarity_name: str = "All-Star",
|
||
cost: int = 300,
|
||
headshot: str = None,
|
||
) -> dict:
|
||
"""Build a minimal player dict that mirrors the API response shape."""
|
||
return {
|
||
"player_id": 1,
|
||
"p_name": name,
|
||
"pos_1": pos_1,
|
||
"cost": cost,
|
||
"rarity": {
|
||
"value": rarity_value,
|
||
"name": rarity_name,
|
||
"color": "FFD700",
|
||
},
|
||
"headshot": headshot,
|
||
}
|
||
|
||
|
||
def _make_batting_card(
|
||
running: int = 12,
|
||
steal_low: int = 7,
|
||
steal_high: int = 12,
|
||
bunting: str = "C",
|
||
hit_and_run: str = "B",
|
||
) -> dict:
|
||
"""Build a minimal batting card dict."""
|
||
return {
|
||
"running": running,
|
||
"steal_low": steal_low,
|
||
"steal_high": steal_high,
|
||
"steal_auto": False,
|
||
"steal_jump": 0.2,
|
||
"bunting": bunting,
|
||
"hit_and_run": hit_and_run,
|
||
"hand": "R",
|
||
"offense_col": 1,
|
||
}
|
||
|
||
|
||
def _make_pitching_card(
|
||
starter_rating: int = 7,
|
||
relief_rating: int = 4,
|
||
closer_rating: int = None,
|
||
balk: int = 2,
|
||
wild_pitch: int = 3,
|
||
) -> dict:
|
||
"""Build a minimal pitching card dict."""
|
||
return {
|
||
"starter_rating": starter_rating,
|
||
"relief_rating": relief_rating,
|
||
"closer_rating": closer_rating,
|
||
"balk": balk,
|
||
"wild_pitch": wild_pitch,
|
||
"hand": "R",
|
||
}
|
||
|
||
|
||
def _batter_card_data(player: dict, batting_card: dict) -> dict:
|
||
return {
|
||
"player": player,
|
||
"battingcard": batting_card,
|
||
"pitchingcard": None,
|
||
}
|
||
|
||
|
||
def _pitcher_card_data(player: dict, pitching_card: dict) -> dict:
|
||
return {
|
||
"player": player,
|
||
"battingcard": None,
|
||
"pitchingcard": pitching_card,
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# _delta_arrow unit tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestDeltaArrow:
|
||
"""_delta_arrow correctly indicates which side wins."""
|
||
|
||
def test_higher_wins_when_card2_greater(self):
|
||
"""▲ when card2 value is higher and higher_is_better=True."""
|
||
assert _delta_arrow(10, 15) == "▲"
|
||
|
||
def test_higher_wins_when_card1_greater(self):
|
||
"""▼ when card1 value is higher and higher_is_better=True."""
|
||
assert _delta_arrow(20, 10) == "▼"
|
||
|
||
def test_tied_returns_equals(self):
|
||
"""═ when both values are equal."""
|
||
assert _delta_arrow(10, 10) == "═"
|
||
|
||
def test_lower_is_better_arrow_flipped(self):
|
||
"""▲ when card2 is lower and lower_is_better (e.g. balk count)."""
|
||
assert _delta_arrow(5, 2, higher_is_better=False) == "▲"
|
||
|
||
def test_lower_is_better_card1_wins(self):
|
||
"""▼ when card1 is lower and lower_is_better."""
|
||
assert _delta_arrow(2, 5, higher_is_better=False) == "▼"
|
||
|
||
def test_grade_field_a_beats_b(self):
|
||
"""▲ when card2 has grade A and card1 has grade B (grade_field=True)."""
|
||
assert _delta_arrow("B", "A", grade_field=True) == "▲"
|
||
|
||
def test_grade_field_b_beats_c(self):
|
||
"""▼ when card1 has grade B and card2 has grade C."""
|
||
assert _delta_arrow("B", "C", grade_field=True) == "▼"
|
||
|
||
def test_grade_field_tie(self):
|
||
"""═ when both cards have the same grade."""
|
||
assert _delta_arrow("C", "C", grade_field=True) == "═"
|
||
|
||
def test_none_returns_equals(self):
|
||
"""═ when either value is None (missing stat)."""
|
||
assert _delta_arrow(None, 10) == "═"
|
||
assert _delta_arrow(10, None) == "═"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# _is_pitcher
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestIsPitcher:
|
||
"""_is_pitcher correctly classifies positions."""
|
||
|
||
def test_sp_is_pitcher(self):
|
||
assert _is_pitcher({"pos_1": "SP"}) is True
|
||
|
||
def test_rp_is_pitcher(self):
|
||
assert _is_pitcher({"pos_1": "RP"}) is True
|
||
|
||
def test_cf_is_batter(self):
|
||
assert _is_pitcher({"pos_1": "CF"}) is False
|
||
|
||
def test_dh_is_batter(self):
|
||
assert _is_pitcher({"pos_1": "DH"}) is False
|
||
|
||
def test_lowercase_sp(self):
|
||
"""pos_1 comparison is case-insensitive."""
|
||
assert _is_pitcher({"pos_1": "sp"}) is True
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# build_compare_embed — batter path
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestBuildCompareEmbedBatters:
|
||
"""build_compare_embed works correctly for two batter cards."""
|
||
|
||
def setup_method(self):
|
||
"""Create two distinct batter cards for comparison."""
|
||
self.player1 = _make_player("Mike Trout", pos_1="CF", cost=500, rarity_value=5)
|
||
self.player2 = _make_player("Joe Batter", pos_1="LF", cost=200, rarity_value=2)
|
||
|
||
self.bc1 = _make_batting_card(
|
||
running=15, steal_low=9, steal_high=14, bunting="B", hit_and_run="A"
|
||
)
|
||
self.bc2 = _make_batting_card(
|
||
running=10, steal_low=5, steal_high=10, bunting="C", hit_and_run="C"
|
||
)
|
||
|
||
self.card1 = _batter_card_data(self.player1, self.bc1)
|
||
self.card2 = _batter_card_data(self.player2, self.bc2)
|
||
|
||
def test_embed_is_discord_embed(self):
|
||
"""Return value is a discord.Embed instance."""
|
||
embed = build_compare_embed(self.card1, self.card2, "Mike Trout", "Joe Batter")
|
||
assert isinstance(embed, discord.Embed)
|
||
|
||
def test_embed_title_contains_comparison(self):
|
||
"""Embed title identifies this as a card comparison."""
|
||
embed = build_compare_embed(self.card1, self.card2, "Mike Trout", "Joe Batter")
|
||
assert "Comparison" in embed.title
|
||
|
||
def test_embed_field_count(self):
|
||
"""
|
||
Batter embed has header row (3 fields) + 7 stat rows × 3 fields = 24
|
||
total inline fields.
|
||
"""
|
||
embed = build_compare_embed(self.card1, self.card2, "Mike Trout", "Joe Batter")
|
||
assert len(embed.fields) == 3 + 7 * 3 # 24
|
||
|
||
def test_higher_cost_gets_down_arrow_in_center_column(self):
|
||
"""
|
||
card1 has higher cost (500 vs 200). The center arrow field for
|
||
'Cost (Overall)' should be '▼' (card1 wins when higher_is_better).
|
||
"""
|
||
embed = build_compare_embed(self.card1, self.card2, "Mike Trout", "Joe Batter")
|
||
# First stat row starts at field index 3; center field of each row is idx+1
|
||
cost_arrow_field = embed.fields[4] # index 3=left, 4=center, 5=right
|
||
assert cost_arrow_field.value == "▼"
|
||
|
||
def test_higher_running_gets_down_arrow(self):
|
||
"""
|
||
card1 running=15 > card2 running=10 → center arrow for Running is ▼.
|
||
Running is the 3rd stat (header row at 0..2, cost at 3..5, rarity at
|
||
6..8, running at 9..11 → center = index 10).
|
||
"""
|
||
embed = build_compare_embed(self.card1, self.card2, "Mike Trout", "Joe Batter")
|
||
running_arrow = embed.fields[10]
|
||
assert running_arrow.value == "▼"
|
||
|
||
def test_better_grade_card1_bunting_b_beats_c(self):
|
||
"""
|
||
card1 bunting='B' beats card2 bunting='C'.
|
||
Bunting is the 6th stat, center field at index 3 + 5*3 + 1 = 19.
|
||
Arrow should be ▼ (card1 wins).
|
||
"""
|
||
embed = build_compare_embed(self.card1, self.card2, "Mike Trout", "Joe Batter")
|
||
bunt_arrow = embed.fields[19]
|
||
assert bunt_arrow.value == "▼"
|
||
|
||
def test_type_mismatch_raises(self):
|
||
"""CompareMismatchError raised when one card is batter and other pitcher."""
|
||
pitcher_player = _make_player("Max Scherzer", pos_1="SP", cost=400)
|
||
pitcher_card = _make_pitching_card(starter_rating=9)
|
||
card_p = _pitcher_card_data(pitcher_player, pitcher_card)
|
||
|
||
with pytest.raises(CompareMismatchError, match="Card types differ"):
|
||
build_compare_embed(self.card1, card_p, "Mike Trout", "Max Scherzer")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# build_compare_embed — pitcher path
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestBuildCompareEmbedPitchers:
|
||
"""build_compare_embed works correctly for two pitcher cards."""
|
||
|
||
def setup_method(self):
|
||
self.player1 = _make_player(
|
||
"Max Scherzer", pos_1="SP", cost=450, rarity_value=4
|
||
)
|
||
self.player2 = _make_player("Bullpen Bob", pos_1="RP", cost=150, rarity_value=2)
|
||
|
||
self.pc1 = _make_pitching_card(
|
||
starter_rating=9, relief_rating=5, closer_rating=None, balk=1, wild_pitch=2
|
||
)
|
||
self.pc2 = _make_pitching_card(
|
||
starter_rating=3, relief_rating=8, closer_rating=6, balk=3, wild_pitch=5
|
||
)
|
||
|
||
self.card1 = _pitcher_card_data(self.player1, self.pc1)
|
||
self.card2 = _pitcher_card_data(self.player2, self.pc2)
|
||
|
||
def test_embed_field_count_pitchers(self):
|
||
"""
|
||
Pitcher embed has header row (3 fields) + 7 stat rows × 3 fields = 24.
|
||
"""
|
||
embed = build_compare_embed(
|
||
self.card1, self.card2, "Max Scherzer", "Bullpen Bob"
|
||
)
|
||
assert len(embed.fields) == 3 + 7 * 3 # 24
|
||
|
||
def test_description_labels_pitchers(self):
|
||
"""Embed description identifies card type as Pitchers."""
|
||
embed = build_compare_embed(
|
||
self.card1, self.card2, "Max Scherzer", "Bullpen Bob"
|
||
)
|
||
assert "Pitchers" in embed.description
|
||
|
||
def test_starter_rating_card1_wins(self):
|
||
"""
|
||
card1 starter_rating=9 > card2 starter_rating=3 → arrow ▼ (card1 wins).
|
||
Starter Rating is 3rd stat, center field at index 3 + 2*3 + 1 = 10.
|
||
"""
|
||
embed = build_compare_embed(
|
||
self.card1, self.card2, "Max Scherzer", "Bullpen Bob"
|
||
)
|
||
starter_arrow = embed.fields[10]
|
||
assert starter_arrow.value == "▼"
|
||
|
||
def test_relief_rating_card2_wins(self):
|
||
"""
|
||
card2 relief_rating=8 > card1 relief_rating=5 → arrow ▲ (card2 wins).
|
||
Relief Rating is 4th stat, center field at index 3 + 3*3 + 1 = 13.
|
||
"""
|
||
embed = build_compare_embed(
|
||
self.card1, self.card2, "Max Scherzer", "Bullpen Bob"
|
||
)
|
||
relief_arrow = embed.fields[13]
|
||
assert relief_arrow.value == "▲"
|
||
|
||
def test_lower_balk_is_better(self):
|
||
"""
|
||
card1 balk=1 < card2 balk=3 → lower is better → arrow ▼ (card1 wins).
|
||
Balk is 6th stat, center field at index 3 + 5*3 + 1 = 19.
|
||
"""
|
||
embed = build_compare_embed(
|
||
self.card1, self.card2, "Max Scherzer", "Bullpen Bob"
|
||
)
|
||
balk_arrow = embed.fields[19]
|
||
assert balk_arrow.value == "▼"
|
||
|
||
def test_tied_stat_shows_equals(self):
|
||
"""═ when both pitchers have the same starter_rating."""
|
||
pc_tied = _make_pitching_card(starter_rating=9)
|
||
card_tied = _pitcher_card_data(self.player2, pc_tied)
|
||
embed = build_compare_embed(
|
||
self.card1, card_tied, "Max Scherzer", "Bullpen Bob"
|
||
)
|
||
starter_arrow = embed.fields[10]
|
||
assert starter_arrow.value == "═"
|
||
|
||
def test_type_mismatch_pitcher_vs_batter_raises(self):
|
||
"""CompareMismatchError raised when pitcher compared to batter."""
|
||
batter_player = _make_player("Speedy Guy", pos_1="CF")
|
||
batter_card_data = _batter_card_data(batter_player, _make_batting_card())
|
||
|
||
with pytest.raises(CompareMismatchError):
|
||
build_compare_embed(
|
||
self.card1, batter_card_data, "Max Scherzer", "Speedy Guy"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# build_compare_embed — edge cases
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestBuildCompareEmbedEdgeCases:
|
||
"""Edge cases: missing data, None stats, same card compared to itself."""
|
||
|
||
def test_missing_batting_card_graceful(self):
|
||
"""
|
||
When battingcard is None, stat values display as '—' and arrows show ═.
|
||
No exception should be raised.
|
||
"""
|
||
player = _make_player("Player A", pos_1="1B")
|
||
card1 = {"player": player, "battingcard": None, "pitchingcard": None}
|
||
card2 = {"player": player, "battingcard": None, "pitchingcard": None}
|
||
# Should not raise
|
||
embed = build_compare_embed(card1, card2, "Player A", "Player A")
|
||
assert isinstance(embed, discord.Embed)
|
||
|
||
def test_same_card_all_tied(self):
|
||
"""Comparing a card against itself should show ═ on every arrow field."""
|
||
player = _make_player("Clone", pos_1="CF", cost=300)
|
||
bc = _make_batting_card(running=12)
|
||
card_data = _batter_card_data(player, bc)
|
||
|
||
embed = build_compare_embed(card_data, card_data, "Clone", "Clone")
|
||
# Arrow fields are at positions 4, 7, 10, 13, 16, 19, 22 (center of each row)
|
||
arrow_indices = [4, 7, 10, 13, 16, 19, 22]
|
||
for idx in arrow_indices:
|
||
assert embed.fields[idx].value == "═", (
|
||
f"Expected ═ at field index {idx}, "
|
||
f"got {embed.fields[idx].value!r} (name={embed.fields[idx].name!r})"
|
||
)
|