paper-dynasty-discord/tests/test_compare_command.py
Cal Corum 0b8beda8b5
All checks were successful
Ruff Lint / lint (pull_request) Successful in 13s
feat(compare): add /compare slash command for side-by-side card comparison
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>
2026-04-10 10:35:17 -05:00

396 lines
14 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.

"""
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})"
)