feat(compare): add /compare slash command for side-by-side card comparison
All checks were successful
Ruff Lint / lint (pull_request) Successful in 13s
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>
This commit is contained in:
parent
9228d2e66c
commit
0b8beda8b5
425
cogs/compare.py
Normal file
425
cogs/compare.py
Normal file
@ -0,0 +1,425 @@
|
||||
"""
|
||||
Compare cog — /compare slash command.
|
||||
|
||||
Displays a side-by-side stat embed for two cards of the same type (batter
|
||||
vs batter, pitcher vs pitcher) with directional delta arrows.
|
||||
|
||||
Card stats are derived from the battingcards / pitchingcards API endpoints
|
||||
which carry the actual card data (running, steal range, pitcher ratings, etc.)
|
||||
alongside the player's rarity and cost.
|
||||
|
||||
Batter stats shown:
|
||||
Cost (Overall proxy), Rarity, Running, Steal Low, Steal High, Bunting,
|
||||
Hit & Run
|
||||
|
||||
Pitcher stats shown:
|
||||
Cost (Overall proxy), Rarity, Starter Rating, Relief Rating,
|
||||
Closer Rating, Balk, Wild Pitch
|
||||
|
||||
Arrow semantics:
|
||||
▲ card2 is higher (better for ↑-better stats)
|
||||
▼ card1 is higher (better for ↑-better stats)
|
||||
═ tied
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
import discord
|
||||
from discord import app_commands
|
||||
from discord.ext import commands
|
||||
|
||||
from api_calls import db_get
|
||||
from constants import PD_PLAYERS_ROLE_NAME
|
||||
from utilities.autocomplete import player_autocomplete
|
||||
|
||||
logger = logging.getLogger("discord_app")
|
||||
|
||||
# ----- helpers ----------------------------------------------------------------
|
||||
|
||||
GRADE_ORDER = ["A", "B", "C", "D", "E", "F"]
|
||||
|
||||
|
||||
def _grade_to_int(grade: Optional[str]) -> Optional[int]:
|
||||
"""Convert a letter grade (A-F) to a numeric rank for comparison.
|
||||
|
||||
Lower rank = better grade. Returns None when grade is None/empty.
|
||||
"""
|
||||
if grade is None:
|
||||
return None
|
||||
upper = grade.upper().strip()
|
||||
try:
|
||||
return GRADE_ORDER.index(upper)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _delta_arrow(
|
||||
val1,
|
||||
val2,
|
||||
higher_is_better: bool = True,
|
||||
grade_field: bool = False,
|
||||
) -> str:
|
||||
"""Return a directional arrow showing which card has the better value.
|
||||
|
||||
Args:
|
||||
val1: stat value for card1
|
||||
val2: stat value for card2
|
||||
higher_is_better: when True, a larger numeric value is preferred.
|
||||
When False (e.g. balk, wild_pitch), a smaller value is preferred.
|
||||
grade_field: when True, val1/val2 are letter grades (A-F) where A > B.
|
||||
|
||||
Returns:
|
||||
'▲' if card2 wins, '▼' if card1 wins, '═' if tied / not comparable.
|
||||
"""
|
||||
if val1 is None or val2 is None:
|
||||
return "═"
|
||||
|
||||
if grade_field:
|
||||
n1 = _grade_to_int(val1)
|
||||
n2 = _grade_to_int(val2)
|
||||
if n1 is None or n2 is None or n1 == n2:
|
||||
return "═"
|
||||
# Lower index = better grade; card2 wins when n2 < n1
|
||||
return "▲" if n2 < n1 else "▼"
|
||||
|
||||
try:
|
||||
n1 = float(val1)
|
||||
n2 = float(val2)
|
||||
except (TypeError, ValueError):
|
||||
return "═"
|
||||
|
||||
if n1 == n2:
|
||||
return "═"
|
||||
|
||||
if higher_is_better:
|
||||
return "▲" if n2 > n1 else "▼"
|
||||
else:
|
||||
# lower is better (e.g. balk count)
|
||||
return "▲" if n2 < n1 else "▼"
|
||||
|
||||
|
||||
def _fmt(val) -> str:
|
||||
"""Format a stat value for display. Falls back to '—' when None."""
|
||||
if val is None:
|
||||
return "—"
|
||||
return str(val)
|
||||
|
||||
|
||||
def _is_pitcher(player: dict) -> bool:
|
||||
"""Return True if the player is a pitcher (pos_1 in SP, RP)."""
|
||||
return player.get("pos_1", "").upper() in ("SP", "RP")
|
||||
|
||||
|
||||
def _card_type_label(player: dict) -> str:
|
||||
return "pitcher" if _is_pitcher(player) else "batter"
|
||||
|
||||
|
||||
# ----- embed builder (pure function, testable without Discord state) ---------
|
||||
|
||||
_BATTER_STATS: List[Tuple[str, str, str, bool]] = [
|
||||
# (label, key_in_card, key_in_player, higher_is_better)
|
||||
("Cost (Overall)", "cost", "player", True),
|
||||
("Rarity", "rarity_value", "player", True),
|
||||
("Running", "running", "battingcard", True),
|
||||
("Steal Low", "steal_low", "battingcard", True),
|
||||
("Steal High", "steal_high", "battingcard", True),
|
||||
("Bunting", "bunting", "battingcard", False), # grade: A>B>C...
|
||||
("Hit & Run", "hit_and_run", "battingcard", False), # grade
|
||||
]
|
||||
|
||||
_PITCHER_STATS: List[Tuple[str, str, str, bool]] = [
|
||||
("Cost (Overall)", "cost", "player", True),
|
||||
("Rarity", "rarity_value", "player", True),
|
||||
("Starter Rating", "starter_rating", "pitchingcard", True),
|
||||
("Relief Rating", "relief_rating", "pitchingcard", True),
|
||||
("Closer Rating", "closer_rating", "pitchingcard", True),
|
||||
("Balk", "balk", "pitchingcard", False), # lower is better
|
||||
("Wild Pitch", "wild_pitch", "pitchingcard", False), # lower is better
|
||||
]
|
||||
|
||||
_GRADE_FIELDS = {"bunting", "hit_and_run"}
|
||||
|
||||
|
||||
class CompareMismatchError(ValueError):
|
||||
"""Raised when two cards are not of the same type."""
|
||||
|
||||
|
||||
def build_compare_embed(
|
||||
card1: dict,
|
||||
card2: dict,
|
||||
card1_name: str,
|
||||
card2_name: str,
|
||||
) -> discord.Embed:
|
||||
"""Build a side-by-side comparison embed for two cards.
|
||||
|
||||
Args:
|
||||
card1: card data dict (player + battingcard OR pitchingcard).
|
||||
Expects 'player', 'battingcard' or 'pitchingcard' keys.
|
||||
card2: same shape as card1
|
||||
card1_name: display name override (falls back to player p_name)
|
||||
card2_name: display name override
|
||||
|
||||
Returns:
|
||||
discord.Embed with inline stat rows
|
||||
|
||||
Raises:
|
||||
CompareMismatchError: if card types differ (batter vs pitcher)
|
||||
"""
|
||||
p1 = card1.get("player", {})
|
||||
p2 = card2.get("player", {})
|
||||
|
||||
type1 = _card_type_label(p1)
|
||||
type2 = _card_type_label(p2)
|
||||
|
||||
if type1 != type2:
|
||||
raise CompareMismatchError(
|
||||
f"Card types differ: '{card1_name}' is a {type1}, "
|
||||
f"'{card2_name}' is a {type2}."
|
||||
)
|
||||
|
||||
color_hex = p1.get("rarity", {}).get("color", "3498DB")
|
||||
try:
|
||||
color = int(color_hex, 16)
|
||||
except (TypeError, ValueError):
|
||||
color = 0x3498DB
|
||||
|
||||
# Embed header
|
||||
embed = discord.Embed(
|
||||
title="Card Comparison",
|
||||
description=(
|
||||
f"**{card1_name}** vs **{card2_name}** — "
|
||||
f"{'Pitchers' if type1 == 'pitcher' else 'Batters'}"
|
||||
),
|
||||
color=color,
|
||||
)
|
||||
|
||||
# Thumbnail from card1 headshot if available
|
||||
thumbnail = p1.get("headshot") or p2.get("headshot")
|
||||
if thumbnail:
|
||||
embed.set_thumbnail(url=thumbnail)
|
||||
|
||||
# Card name headers (inline, side-by-side feel)
|
||||
embed.add_field(name="Card 1", value=f"**{card1_name}**", inline=True)
|
||||
embed.add_field(name="Stat", value="\u200b", inline=True)
|
||||
embed.add_field(name="Card 2", value=f"**{card2_name}**", inline=True)
|
||||
|
||||
# Choose stat spec
|
||||
stats = _PITCHER_STATS if type1 == "pitcher" else _BATTER_STATS
|
||||
|
||||
for label, key, source, higher_is_better in stats:
|
||||
# Extract values
|
||||
if source == "player":
|
||||
if key == "rarity_value":
|
||||
v1 = p1.get("rarity", {}).get("value")
|
||||
v2 = p2.get("rarity", {}).get("value")
|
||||
# Display as rarity name + value
|
||||
display1 = p1.get("rarity", {}).get("name", _fmt(v1))
|
||||
display2 = p2.get("rarity", {}).get("name", _fmt(v2))
|
||||
else:
|
||||
v1 = p1.get(key)
|
||||
v2 = p2.get(key)
|
||||
display1 = _fmt(v1)
|
||||
display2 = _fmt(v2)
|
||||
elif source == "battingcard":
|
||||
bc1 = card1.get("battingcard", {}) or {}
|
||||
bc2 = card2.get("battingcard", {}) or {}
|
||||
v1 = bc1.get(key)
|
||||
v2 = bc2.get(key)
|
||||
display1 = _fmt(v1)
|
||||
display2 = _fmt(v2)
|
||||
elif source == "pitchingcard":
|
||||
pc1 = card1.get("pitchingcard", {}) or {}
|
||||
pc2 = card2.get("pitchingcard", {}) or {}
|
||||
v1 = pc1.get(key)
|
||||
v2 = pc2.get(key)
|
||||
display1 = _fmt(v1)
|
||||
display2 = _fmt(v2)
|
||||
else:
|
||||
continue
|
||||
|
||||
is_grade = key in _GRADE_FIELDS
|
||||
arrow = _delta_arrow(
|
||||
v1,
|
||||
v2,
|
||||
higher_is_better=higher_is_better,
|
||||
grade_field=is_grade,
|
||||
)
|
||||
|
||||
embed.add_field(name="\u200b", value=display1, inline=True)
|
||||
embed.add_field(name=label, value=arrow, inline=True)
|
||||
embed.add_field(name="\u200b", value=display2, inline=True)
|
||||
|
||||
embed.set_footer(text="Paper Dynasty — /compare")
|
||||
return embed
|
||||
|
||||
|
||||
# ----- card fetch helpers -----------------------------------------------------
|
||||
|
||||
|
||||
async def _fetch_player_by_name(name: str) -> Optional[dict]:
|
||||
"""Search for a player by name and return the first match."""
|
||||
result = await db_get(
|
||||
"players/search",
|
||||
params=[("q", name), ("limit", 1)],
|
||||
timeout=5,
|
||||
)
|
||||
if not result or not result.get("players"):
|
||||
return None
|
||||
return result["players"][0]
|
||||
|
||||
|
||||
async def _fetch_batting_card(player_id: int) -> Optional[dict]:
|
||||
"""Fetch the variant-0 batting card for a player."""
|
||||
result = await db_get(
|
||||
"battingcards",
|
||||
params=[("player_id", player_id), ("variant", 0)],
|
||||
timeout=5,
|
||||
)
|
||||
if not result or not result.get("cards"):
|
||||
# Fall back to any variant
|
||||
result = await db_get(
|
||||
"battingcards",
|
||||
params=[("player_id", player_id)],
|
||||
timeout=5,
|
||||
)
|
||||
if not result or not result.get("cards"):
|
||||
return None
|
||||
return result["cards"][0]
|
||||
|
||||
|
||||
async def _fetch_pitching_card(player_id: int) -> Optional[dict]:
|
||||
"""Fetch the variant-0 pitching card for a player."""
|
||||
result = await db_get(
|
||||
"pitchingcards",
|
||||
params=[("player_id", player_id), ("variant", 0)],
|
||||
timeout=5,
|
||||
)
|
||||
if not result or not result.get("cards"):
|
||||
result = await db_get(
|
||||
"pitchingcards",
|
||||
params=[("player_id", player_id)],
|
||||
timeout=5,
|
||||
)
|
||||
if not result or not result.get("cards"):
|
||||
return None
|
||||
return result["cards"][0]
|
||||
|
||||
|
||||
async def _build_card_data(player: dict) -> dict:
|
||||
"""Build a unified card data dict for use with build_compare_embed.
|
||||
|
||||
Returns a dict with 'player', 'battingcard', and 'pitchingcard' keys.
|
||||
"""
|
||||
pid = player.get("player_id") or player.get("id")
|
||||
batting_card = None
|
||||
pitching_card = None
|
||||
|
||||
if _is_pitcher(player):
|
||||
pitching_card = await _fetch_pitching_card(pid)
|
||||
else:
|
||||
batting_card = await _fetch_batting_card(pid)
|
||||
|
||||
return {
|
||||
"player": player,
|
||||
"battingcard": batting_card,
|
||||
"pitchingcard": pitching_card,
|
||||
}
|
||||
|
||||
|
||||
# ----- Cog --------------------------------------------------------------------
|
||||
|
||||
|
||||
class CompareCog(commands.Cog, name="Compare"):
|
||||
"""Slash command cog providing /compare for side-by-side card comparison."""
|
||||
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
|
||||
@app_commands.command(
|
||||
name="compare",
|
||||
description="Side-by-side stat comparison for two cards",
|
||||
)
|
||||
@app_commands.checks.has_any_role(PD_PLAYERS_ROLE_NAME)
|
||||
@app_commands.describe(
|
||||
card1="First player's card (type a name to search)",
|
||||
card2="Second player's card (type a name to search)",
|
||||
)
|
||||
@app_commands.autocomplete(card1=player_autocomplete, card2=player_autocomplete)
|
||||
async def compare_command(
|
||||
self,
|
||||
interaction: discord.Interaction,
|
||||
card1: str,
|
||||
card2: str,
|
||||
):
|
||||
"""Compare two cards side-by-side.
|
||||
|
||||
Fetches both players by name, validates that they are the same card
|
||||
type (both batters or both pitchers), then builds and sends the
|
||||
comparison embed.
|
||||
"""
|
||||
await interaction.response.defer()
|
||||
|
||||
# --- fetch player 1 ---------------------------------------------------
|
||||
player1 = await _fetch_player_by_name(card1)
|
||||
if not player1:
|
||||
await interaction.edit_original_response(
|
||||
content=f"Could not find a card for **{card1}**."
|
||||
)
|
||||
return
|
||||
|
||||
# --- fetch player 2 ---------------------------------------------------
|
||||
player2 = await _fetch_player_by_name(card2)
|
||||
if not player2:
|
||||
await interaction.edit_original_response(
|
||||
content=f"Could not find a card for **{card2}**."
|
||||
)
|
||||
return
|
||||
|
||||
# --- type-gate --------------------------------------------------------
|
||||
type1 = _card_type_label(player1)
|
||||
type2 = _card_type_label(player2)
|
||||
if type1 != type2:
|
||||
await interaction.edit_original_response(
|
||||
content=(
|
||||
"Can only compare cards of the same type "
|
||||
"(batter vs batter, pitcher vs pitcher)."
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
# --- build card data --------------------------------------------------
|
||||
card_data1 = await _build_card_data(player1)
|
||||
card_data2 = await _build_card_data(player2)
|
||||
|
||||
name1 = player1.get("p_name", card1)
|
||||
name2 = player2.get("p_name", card2)
|
||||
|
||||
# --- build embed ------------------------------------------------------
|
||||
try:
|
||||
embed = build_compare_embed(card_data1, card_data2, name1, name2)
|
||||
except CompareMismatchError as exc:
|
||||
logger.warning("CompareMismatchError (should not reach here): %s", exc)
|
||||
await interaction.edit_original_response(
|
||||
content=(
|
||||
"Can only compare cards of the same type "
|
||||
"(batter vs batter, pitcher vs pitcher)."
|
||||
),
|
||||
)
|
||||
return
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"compare_command build_compare_embed error: %s", exc, exc_info=True
|
||||
)
|
||||
await interaction.edit_original_response(
|
||||
content="Something went wrong building the comparison. Please contact Cal."
|
||||
)
|
||||
return
|
||||
|
||||
# Send publicly so players can share the result
|
||||
await interaction.edit_original_response(embed=embed)
|
||||
|
||||
|
||||
async def setup(bot: commands.Bot) -> None:
|
||||
"""Discord.py cog loader entry point."""
|
||||
await bot.add_cog(CompareCog(bot))
|
||||
@ -54,6 +54,7 @@ COGS = [
|
||||
"cogs.gameplay",
|
||||
"cogs.economy_new.scouting",
|
||||
"cogs.refractor",
|
||||
"cogs.compare",
|
||||
]
|
||||
|
||||
intents = discord.Intents.default()
|
||||
|
||||
395
tests/test_compare_command.py
Normal file
395
tests/test_compare_command.py
Normal file
@ -0,0 +1,395 @@
|
||||
"""
|
||||
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})"
|
||||
)
|
||||
Loading…
Reference in New Issue
Block a user