Merge pull request 'feat: /refractor status slash command (WP-11)' (#87) from ai/paper-dynasty-database#76 into main
This commit is contained in:
commit
d2410ab374
213
cogs/refractor.py
Normal file
213
cogs/refractor.py
Normal file
@ -0,0 +1,213 @@
|
||||
"""
|
||||
Refractor cog — /refractor status slash command.
|
||||
|
||||
Displays a team's refractor progress: formula value vs next threshold
|
||||
with a progress bar, paginated 10 cards per page.
|
||||
|
||||
Tier names: Base Card (T0) / Base Chrome (T1) / Refractor (T2) /
|
||||
Gold Refractor (T3) / Superfractor (T4).
|
||||
|
||||
Depends on WP-07 (refractor/cards API endpoint).
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import discord
|
||||
from discord import app_commands
|
||||
from discord.ext import commands
|
||||
|
||||
from api_calls import db_get
|
||||
from helpers.main import get_team_by_owner
|
||||
|
||||
logger = logging.getLogger("discord_app")
|
||||
|
||||
PAGE_SIZE = 10
|
||||
|
||||
TIER_NAMES = {
|
||||
0: "Base Card",
|
||||
1: "Base Chrome",
|
||||
2: "Refractor",
|
||||
3: "Gold Refractor",
|
||||
4: "Superfractor",
|
||||
}
|
||||
|
||||
FORMULA_LABELS = {
|
||||
"batter": "PA+TB×2",
|
||||
"sp": "IP+K",
|
||||
"rp": "IP+K",
|
||||
}
|
||||
|
||||
TIER_BADGES = {1: "[BC]", 2: "[R]", 3: "[GR]", 4: "[SF]"}
|
||||
|
||||
|
||||
def render_progress_bar(current: int, threshold: int, width: int = 10) -> str:
|
||||
"""
|
||||
Render a fixed-width ASCII progress bar.
|
||||
|
||||
Examples:
|
||||
render_progress_bar(120, 149) -> '[========--]'
|
||||
render_progress_bar(0, 100) -> '[----------]'
|
||||
render_progress_bar(100, 100) -> '[==========]'
|
||||
"""
|
||||
if threshold <= 0:
|
||||
filled = width
|
||||
else:
|
||||
ratio = min(current / threshold, 1.0)
|
||||
filled = round(ratio * width)
|
||||
empty = width - filled
|
||||
return f"[{'=' * filled}{'-' * empty}]"
|
||||
|
||||
|
||||
def format_refractor_entry(card_state: dict) -> str:
|
||||
"""
|
||||
Format a single card state dict as a display string.
|
||||
|
||||
Expected keys: player_name, card_type, current_tier, formula_value,
|
||||
next_threshold (None if fully evolved).
|
||||
|
||||
A tier badge prefix (e.g. [BC], [R], [GR], [SF]) is prepended to the
|
||||
player name for tiers 1-4. T0 cards have no badge.
|
||||
|
||||
Output example:
|
||||
**[BC] Mike Trout** (Base Chrome)
|
||||
[========--] 120/149 (PA+TB×2) — T1 → T2
|
||||
"""
|
||||
player_name = card_state.get("player_name", "Unknown")
|
||||
card_type = card_state.get("card_type", "batter")
|
||||
current_tier = card_state.get("current_tier", 0)
|
||||
formula_value = card_state.get("formula_value", 0)
|
||||
next_threshold = card_state.get("next_threshold")
|
||||
|
||||
tier_label = TIER_NAMES.get(current_tier, f"T{current_tier}")
|
||||
formula_label = FORMULA_LABELS.get(card_type, card_type)
|
||||
|
||||
badge = TIER_BADGES.get(current_tier, "")
|
||||
display_name = f"{badge} {player_name}" if badge else player_name
|
||||
|
||||
if current_tier >= 4 or next_threshold is None:
|
||||
bar = "[==========]"
|
||||
detail = "FULLY EVOLVED ★"
|
||||
else:
|
||||
bar = render_progress_bar(formula_value, next_threshold)
|
||||
detail = f"{formula_value}/{next_threshold} ({formula_label}) — T{current_tier} → T{current_tier + 1}"
|
||||
|
||||
first_line = f"**{display_name}** ({tier_label})"
|
||||
second_line = f"{bar} {detail}"
|
||||
return f"{first_line}\n{second_line}"
|
||||
|
||||
|
||||
def apply_close_filter(card_states: list) -> list:
|
||||
"""
|
||||
Return only cards within 80% of their next tier threshold.
|
||||
|
||||
Fully evolved cards (T4 or no next_threshold) are excluded.
|
||||
"""
|
||||
result = []
|
||||
for state in card_states:
|
||||
current_tier = state.get("current_tier", 0)
|
||||
formula_value = state.get("formula_value", 0)
|
||||
next_threshold = state.get("next_threshold")
|
||||
if current_tier >= 4 or not next_threshold:
|
||||
continue
|
||||
if formula_value >= 0.8 * next_threshold:
|
||||
result.append(state)
|
||||
return result
|
||||
|
||||
|
||||
def paginate(items: list, page: int, page_size: int = PAGE_SIZE) -> tuple:
|
||||
"""
|
||||
Slice items for the given 1-indexed page.
|
||||
|
||||
Returns (page_items, total_pages). Page is clamped to valid range.
|
||||
"""
|
||||
total_pages = max(1, (len(items) + page_size - 1) // page_size)
|
||||
page = max(1, min(page, total_pages))
|
||||
start = (page - 1) * page_size
|
||||
return items[start : start + page_size], total_pages
|
||||
|
||||
|
||||
class Refractor(commands.Cog):
|
||||
"""Refractor progress tracking slash commands."""
|
||||
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
|
||||
group_refractor = app_commands.Group(
|
||||
name="refractor", description="Refractor tracking commands"
|
||||
)
|
||||
|
||||
@group_refractor.command(
|
||||
name="status", description="Show your team's refractor progress"
|
||||
)
|
||||
@app_commands.describe(
|
||||
card_type="Card type filter (batter, sp, rp)",
|
||||
season="Season number (default: current)",
|
||||
tier="Filter by current tier (0-4)",
|
||||
progress='Use "close" to show cards within 80% of their next tier',
|
||||
page="Page number (default: 1, 10 cards per page)",
|
||||
)
|
||||
async def refractor_status(
|
||||
self,
|
||||
interaction: discord.Interaction,
|
||||
card_type: Optional[str] = None,
|
||||
season: Optional[int] = None,
|
||||
tier: Optional[int] = None,
|
||||
progress: Optional[str] = None,
|
||||
page: int = 1,
|
||||
):
|
||||
"""Show a paginated view of the invoking user's team refractor progress."""
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
team = await get_team_by_owner(interaction.user.id)
|
||||
if not team:
|
||||
await interaction.edit_original_response(
|
||||
content="You don't have a team. Sign up with /newteam first."
|
||||
)
|
||||
return
|
||||
|
||||
params = [("team_id", team["id"])]
|
||||
if card_type:
|
||||
params.append(("card_type", card_type))
|
||||
if season is not None:
|
||||
params.append(("season", season))
|
||||
if tier is not None:
|
||||
params.append(("tier", tier))
|
||||
|
||||
data = await db_get("refractor/cards", params=params)
|
||||
if not data:
|
||||
await interaction.edit_original_response(
|
||||
content="No refractor data found for your team."
|
||||
)
|
||||
return
|
||||
|
||||
items = data if isinstance(data, list) else data.get("cards", [])
|
||||
if not items:
|
||||
await interaction.edit_original_response(
|
||||
content="No refractor data found for your team."
|
||||
)
|
||||
return
|
||||
|
||||
if progress == "close":
|
||||
items = apply_close_filter(items)
|
||||
if not items:
|
||||
await interaction.edit_original_response(
|
||||
content="No cards are currently close to a tier advancement."
|
||||
)
|
||||
return
|
||||
|
||||
page_items, total_pages = paginate(items, page)
|
||||
lines = [format_refractor_entry(state) for state in page_items]
|
||||
|
||||
embed = discord.Embed(
|
||||
title=f"{team['sname']} Refractor Status",
|
||||
description="\n\n".join(lines),
|
||||
color=0x6F42C1,
|
||||
)
|
||||
embed.set_footer(text=f"Page {page}/{total_pages} · {len(items)} card(s) total")
|
||||
|
||||
await interaction.edit_original_response(embed=embed)
|
||||
|
||||
|
||||
async def setup(bot):
|
||||
await bot.add_cog(Refractor(bot))
|
||||
@ -1,5 +1,4 @@
|
||||
import discord
|
||||
import datetime
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
import asyncio
|
||||
@ -54,6 +53,7 @@ COGS = [
|
||||
"cogs.players",
|
||||
"cogs.gameplay",
|
||||
"cogs.economy_new.scouting",
|
||||
"cogs.refractor",
|
||||
]
|
||||
|
||||
intents = discord.Intents.default()
|
||||
|
||||
467
tests/test_refractor_commands.py
Normal file
467
tests/test_refractor_commands.py
Normal file
@ -0,0 +1,467 @@
|
||||
"""
|
||||
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()
|
||||
Loading…
Reference in New Issue
Block a user