From 0b8beda8b54762adca0b94ec101e3d19fd86f89f Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 10 Apr 2026 10:35:17 -0500 Subject: [PATCH] feat(compare): add /compare slash command for side-by-side card comparison MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cogs/compare.py | 425 ++++++++++++++++++++++++++++++++++ paperdynasty.py | 1 + tests/test_compare_command.py | 395 +++++++++++++++++++++++++++++++ 3 files changed, 821 insertions(+) create mode 100644 cogs/compare.py create mode 100644 tests/test_compare_command.py diff --git a/cogs/compare.py b/cogs/compare.py new file mode 100644 index 0000000..e663443 --- /dev/null +++ b/cogs/compare.py @@ -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)) diff --git a/paperdynasty.py b/paperdynasty.py index 65f3845..20beac3 100644 --- a/paperdynasty.py +++ b/paperdynasty.py @@ -54,6 +54,7 @@ COGS = [ "cogs.gameplay", "cogs.economy_new.scouting", "cogs.refractor", + "cogs.compare", ] intents = discord.Intents.default() diff --git a/tests/test_compare_command.py b/tests/test_compare_command.py new file mode 100644 index 0000000..1a408e9 --- /dev/null +++ b/tests/test_compare_command.py @@ -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})" + )