feat: redesign /refractor status with rich Unicode display and team branding
All checks were successful
Ruff Lint / lint (pull_request) Successful in 21s

Replace plain ASCII progress bars and text badges with a polished embed:
- Unicode block progress bars (▰▱) replacing ASCII [===---]
- Tier-specific symbols (○ ◈ ◆ ✦ ★) instead of [BC]/[R]/[GR]/[SF] badges
- Team-branded embeds via get_team_embed (color, logo, season footer)
- Tier distribution summary header in code block
- Percentage display and backtick-wrapped values
- Tier-specific accent colors for single-tier filtered views
- Sparkle treatment for fully evolved cards (✧ FULLY EVOLVED ✧)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-03-25 22:46:38 -05:00
parent 34774290b8
commit cd822857bf
4 changed files with 265 additions and 214 deletions

View File

@ -19,6 +19,7 @@ from discord.app_commands import Choice
from discord.ext import commands from discord.ext import commands
from api_calls import db_get from api_calls import db_get
from helpers.discord_utils import get_team_embed
from helpers.main import get_team_by_owner from helpers.main import get_team_by_owner
logger = logging.getLogger("discord_app") logger = logging.getLogger("discord_app")
@ -39,40 +40,61 @@ FORMULA_LABELS = {
"rp": "IP+K", "rp": "IP+K",
} }
TIER_BADGES = {1: "[BC]", 2: "[R]", 3: "[GR]", 4: "[SF]"} # Tier-specific symbols for visual hierarchy in the status display.
TIER_SYMBOLS = {
0: "", # Base Card — hollow circle
1: "", # Base Chrome — diamond with dot
2: "", # Refractor — filled diamond
3: "", # Gold Refractor — four-pointed star
4: "", # Superfractor — filled star
}
# Embed accent colors per tier (used for single-tier filtered views).
TIER_COLORS = {
0: 0x95A5A6, # slate grey
1: 0xBDC3C7, # silver/chrome
2: 0x3498DB, # refractor blue
3: 0xF1C40F, # gold
4: 0x1ABC9C, # teal superfractor
}
def render_progress_bar(current: int, threshold: int, width: int = 10) -> str: def render_progress_bar(current: int, threshold: int, width: int = 12) -> str:
""" """
Render a fixed-width ASCII progress bar. Render a Unicode block progress bar.
Examples: Examples:
render_progress_bar(120, 149) -> '[========--]' render_progress_bar(120, 149) -> '▰▰▰▰▰▰▰▰▰▰▱▱'
render_progress_bar(0, 100) -> '[----------]' render_progress_bar(0, 100) -> '▱▱▱▱▱▱▱▱▱▱▱▱'
render_progress_bar(100, 100) -> '[==========]' render_progress_bar(100, 100) -> '▰▰▰▰▰▰▰▰▰▰▰▰'
""" """
if threshold <= 0: if threshold <= 0:
filled = width filled = width
else: else:
ratio = min(current / threshold, 1.0) ratio = max(0.0, min(current / threshold, 1.0))
filled = round(ratio * width) filled = round(ratio * width)
empty = width - filled empty = width - filled
return f"[{'=' * filled}{'-' * empty}]" return f"{'' * filled}{'' * empty}"
def _pct_label(current: int, threshold: int) -> str:
"""Return a percentage string like '80%'."""
if threshold <= 0:
return "100%"
return f"{min(current / threshold, 1.0):.0%}"
def format_refractor_entry(card_state: dict) -> str: def format_refractor_entry(card_state: dict) -> str:
""" """
Format a single card state dict as a display string. Format a single card state dict as a rich display string.
Expected keys: player_name, card_type, current_tier, formula_value, Output example (in-progress):
next_threshold (None if fully evolved). **Mike Trout** Base Chrome
`120/149` 80% · PA+TB×2 · T1 T2
A tier badge prefix (e.g. [BC], [R], [GR], [SF]) is prepended to the Output example (fully evolved):
player name for tiers 1-4. T0 cards have no badge. **Barry Bonds** Superfractor
FULLY EVOLVED
Output example:
**[BC] Mike Trout** (Base Chrome)
[========--] 120/149 (PA+TB×2) T1 T2
""" """
player_name = card_state.get("player_name", "Unknown") player_name = card_state.get("player_name", "Unknown")
track = card_state.get("track", {}) track = card_state.get("track", {})
@ -83,22 +105,85 @@ def format_refractor_entry(card_state: dict) -> str:
tier_label = TIER_NAMES.get(current_tier, f"T{current_tier}") tier_label = TIER_NAMES.get(current_tier, f"T{current_tier}")
formula_label = FORMULA_LABELS.get(card_type, card_type) formula_label = FORMULA_LABELS.get(card_type, card_type)
symbol = TIER_SYMBOLS.get(current_tier, "·")
badge = TIER_BADGES.get(current_tier, "") first_line = f"{symbol} **{player_name}** — {tier_label}"
display_name = f"{badge} {player_name}" if badge else player_name
if current_tier >= 4 or next_threshold is None: if current_tier >= 4 or next_threshold is None:
bar = "[==========]" bar = render_progress_bar(1, 1)
detail = "FULLY EVOLVED ★" second_line = f"{bar} ✧ FULLY EVOLVED ✧"
else: else:
bar = render_progress_bar(formula_value, next_threshold) bar = render_progress_bar(formula_value, next_threshold)
detail = f"{formula_value}/{next_threshold} ({formula_label}) — T{current_tier} → T{current_tier + 1}" pct = _pct_label(formula_value, next_threshold)
second_line = (
f"{bar} `{formula_value}/{next_threshold}` {pct}"
f" · {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}" return f"{first_line}\n{second_line}"
def build_tier_summary(items: list, total_count: int) -> str:
"""
Build a one-line summary of tier distribution from the current page items.
Returns something like: '○ 3 ◈ 12 ◆ 8 ✦ 5 ★ 2 — 30 cards'
"""
counts = {t: 0 for t in range(5)}
for item in items:
t = item.get("current_tier", 0)
if t in counts:
counts[t] += 1
parts = []
for t in range(5):
if counts[t] > 0:
parts.append(f"{TIER_SYMBOLS[t]} {counts[t]}")
summary = " ".join(parts) if parts else "No cards"
return f"{summary}{total_count} total"
def build_status_embed(
team: dict,
items: list,
page: int,
total_pages: int,
total_count: int,
tier_filter: Optional[int] = None,
) -> discord.Embed:
"""
Build the refractor status embed with team branding.
Uses get_team_embed for consistent team color/logo/footer, then layers
on the refractor-specific content.
"""
embed = get_team_embed(f"{team['sname']} — Refractor Status", team=team)
# Override color for single-tier views to match the tier's identity.
if tier_filter is not None and tier_filter in TIER_COLORS:
embed.color = TIER_COLORS[tier_filter]
# Header: tier distribution summary
header = build_tier_summary(items, total_count)
# Card entries
lines = [format_refractor_entry(state) for state in items]
body = "\n\n".join(lines) if lines else "*No cards found.*"
# Separator between header and cards
embed.description = f"```{header}```\n{body}"
# Page indicator in footer (append to existing footer text)
existing_footer = embed.footer.text or ""
page_text = f"Page {page}/{total_pages}"
embed.set_footer(
text=f"{page_text} · {existing_footer}" if existing_footer else page_text,
icon_url=embed.footer.icon_url,
)
return embed
def apply_close_filter(card_states: list) -> list: def apply_close_filter(card_states: list) -> list:
""" """
Return only cards within 80% of their next tier threshold. Return only cards within 80% of their next tier threshold.
@ -140,6 +225,7 @@ class RefractorPaginationView(discord.ui.View):
total_count: int, total_count: int,
params: list, params: list,
owner_id: int, owner_id: int,
tier_filter: Optional[int] = None,
timeout: float = 120.0, timeout: float = 120.0,
): ):
super().__init__(timeout=timeout) super().__init__(timeout=timeout)
@ -149,6 +235,7 @@ class RefractorPaginationView(discord.ui.View):
self.total_count = total_count self.total_count = total_count
self.base_params = params self.base_params = params
self.owner_id = owner_id self.owner_id = owner_id
self.tier_filter = tier_filter
self._update_buttons() self._update_buttons()
def _update_buttons(self): def _update_buttons(self):
@ -170,19 +257,18 @@ class RefractorPaginationView(discord.ui.View):
self.total_pages = max(1, (self.total_count + PAGE_SIZE - 1) // PAGE_SIZE) self.total_pages = max(1, (self.total_count + PAGE_SIZE - 1) // PAGE_SIZE)
self.page = min(self.page, self.total_pages) self.page = min(self.page, self.total_pages)
lines = [format_refractor_entry(state) for state in items] embed = build_status_embed(
embed = discord.Embed( self.team,
title=f"{self.team['sname']} Refractor Status", items,
description="\n\n".join(lines) if lines else "No cards found.", self.page,
color=0x6F42C1, self.total_pages,
) self.total_count,
embed.set_footer( tier_filter=self.tier_filter,
text=f"Page {self.page}/{self.total_pages} · {self.total_count} card(s) total"
) )
self._update_buttons() self._update_buttons()
await interaction.response.edit_message(embed=embed, view=self) await interaction.response.edit_message(embed=embed, view=self)
@discord.ui.button(label="◀ Prev", style=discord.ButtonStyle.blurple) @discord.ui.button(label="◀ Prev", style=discord.ButtonStyle.grey)
async def prev_btn( async def prev_btn(
self, interaction: discord.Interaction, button: discord.ui.Button self, interaction: discord.Interaction, button: discord.ui.Button
): ):
@ -191,7 +277,7 @@ class RefractorPaginationView(discord.ui.View):
self.page = max(1, self.page - 1) self.page = max(1, self.page - 1)
await self._fetch_and_update(interaction) await self._fetch_and_update(interaction)
@discord.ui.button(label="Next ▶", style=discord.ButtonStyle.blurple) @discord.ui.button(label="Next ▶", style=discord.ButtonStyle.grey)
async def next_btn( async def next_btn(
self, interaction: discord.Interaction, button: discord.ui.Button self, interaction: discord.Interaction, button: discord.ui.Button
): ):
@ -269,6 +355,8 @@ class Refractor(commands.Cog):
if progress: if progress:
params.append(("progress", progress.value)) params.append(("progress", progress.value))
tier_filter = int(tier.value) if tier is not None else None
data = await db_get("refractor/cards", params=params) data = await db_get("refractor/cards", params=params)
if not data: if not data:
logger.error( logger.error(
@ -322,15 +410,9 @@ class Refractor(commands.Cog):
total_pages = max(1, (total_count + PAGE_SIZE - 1) // PAGE_SIZE) total_pages = max(1, (total_count + PAGE_SIZE - 1) // PAGE_SIZE)
page = min(page, total_pages) page = min(page, total_pages)
lines = [format_refractor_entry(state) for state in items]
embed = discord.Embed( embed = build_status_embed(
title=f"{team['sname']} Refractor Status", team, items, page, total_pages, total_count, tier_filter=tier_filter
description="\n\n".join(lines),
color=0x6F42C1,
)
embed.set_footer(
text=f"Page {page}/{total_pages} · {total_count} card(s) total"
) )
if total_pages > 1: if total_pages > 1:
@ -341,6 +423,7 @@ class Refractor(commands.Cog):
total_count=total_count, total_count=total_count,
params=params, params=params,
owner_id=interaction.user.id, owner_id=interaction.user.id,
tier_filter=tier_filter,
) )
await interaction.edit_original_response(embed=embed, view=view) await interaction.edit_original_response(embed=embed, view=view)
else: else:

View File

@ -665,16 +665,28 @@ design but means tier-up notifications are best-effort.
Run order for Playwright automation: Run order for Playwright automation:
1. [ ] Execute REF-API-01 through REF-API-10 (API health + list endpoint) 1. [~] Execute REF-API-01 through REF-API-10 (API health + list endpoint)
2. [ ] Execute REF-01 through REF-06 (basic /refractor status) - Tested 2026-03-25: REF-API-03 (single card ✓), REF-API-06 (list ✓), REF-API-07 (card_type filter ✓), REF-API-10 (pagination ✓)
3. [ ] Execute REF-10 through REF-19 (filters) - Not yet tested: REF-API-01, REF-API-02, REF-API-04, REF-API-05, REF-API-08, REF-API-09
4. [ ] Execute REF-20 through REF-23 (pagination) 2. [~] Execute REF-01 through REF-06 (basic /refractor status)
- Tested 2026-03-25: REF-01 (embed appears ✓), REF-02 (batter entry format ✓), REF-05 (tier badges [BC] ✓)
- Bugs found and fixed: wrong response key ("cards" vs "items"), wrong field names (formula_value vs current_value, card_type nesting), limit=500 exceeding API max, floating point display
- Not yet tested: REF-03 (SP format), REF-04 (RP format), REF-06 (fully evolved)
3. [~] Execute REF-10 through REF-19 (filters)
- Tested 2026-03-25: REF-10 (card_type=batter ✓ after fix)
- Choice dropdown menus added for all filter params (PR #126)
- Not yet tested: REF-11 through REF-19
4. [~] Execute REF-20 through REF-23 (pagination)
- Tested 2026-03-25: REF-20 (page 1 footer ✓), pagination buttons added (PR #127)
- Not yet tested: REF-21 (page 2), REF-22 (beyond total), REF-23 (page 0)
5. [ ] Execute REF-30 through REF-34 (edge cases) 5. [ ] Execute REF-30 through REF-34 (edge cases)
6. [ ] Execute REF-40 through REF-45 (tier badges on card embeds) 6. [ ] Execute REF-40 through REF-45 (tier badges on card embeds)
7. [ ] Execute REF-50 through REF-55 (post-game hook -- requires live game) 7. [ ] Execute REF-50 through REF-55 (post-game hook -- requires live game)
8. [ ] Execute REF-60 through REF-64 (tier-up notifications -- requires threshold crossing) 8. [ ] Execute REF-60 through REF-64 (tier-up notifications -- requires threshold crossing)
9. [ ] Execute REF-70 through REF-72 (cross-command badge propagation) 9. [ ] Execute REF-70 through REF-72 (cross-command badge propagation)
10. [ ] Execute REF-80 through REF-82 (force-evaluate API) 10. [~] Execute REF-80 through REF-82 (force-evaluate API)
- Tested 2026-03-25: REF-80 (force evaluate ✓ — used to seed 100 cards for team 31)
- Not yet tested: REF-81, REF-82
### Approximate Time Estimates ### Approximate Time Estimates
- API health checks + list endpoint (REF-API-01 through REF-API-10): 2-3 minutes - API health checks + list endpoint (REF-API-01 through REF-API-10): 2-3 minutes

View File

@ -251,78 +251,36 @@ class TestEmbedColorUnchanged:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestTierBadgesFormatConsistency: class TestTierSymbolsCompleteness:
""" """
T1-7: Assert that TIER_BADGES in cogs.refractor (format: "[BC]") and T1-7: Assert that TIER_SYMBOLS in cogs.refractor covers all tiers 0-4
helpers.main (format: "BC") are consistent wrapping the helpers.main and that helpers.main TIER_BADGES still covers tiers 1-4 for card embeds.
value in brackets must produce the cogs.refractor value.
Why: The two modules intentionally use different formats for different Why: The refractor status command uses Unicode TIER_SYMBOLS for display,
rendering contexts: while card embed titles use helpers.main TIER_BADGES in bracket format.
- helpers.main uses bare strings ("BC") because get_card_embeds Both must cover the full tier range for their respective contexts.
wraps them in brackets when building the embed title.
- cogs.refractor uses bracket strings ("[BC]") because
format_refractor_entry inlines them directly into the display string.
If either definition is updated without updating the other, embed titles
and /refractor status output will display inconsistent badges. This test
acts as an explicit contract check so any future change to either dict
is immediately surfaced here.
""" """
def test_cogs_badge_equals_bracketed_helpers_badge_for_all_tiers(self): def test_tier_symbols_covers_all_tiers(self):
""" """TIER_SYMBOLS must have entries for T0 through T4."""
For every tier in cogs.refractor TIER_BADGES, wrapping the from cogs.refractor import TIER_SYMBOLS
helpers.main TIER_BADGES value in square brackets must produce
the cogs.refractor value.
i.e., f"[{helpers_badge}]" == cog_badge for all tiers. for tier in range(5):
""" assert tier in TIER_SYMBOLS, f"TIER_SYMBOLS missing tier {tier}"
from cogs.refractor import TIER_BADGES as cog_badges
from helpers.main import TIER_BADGES as helpers_badges
assert set(cog_badges.keys()) == set(helpers_badges.keys()), ( def test_tier_badges_covers_evolved_tiers(self):
"TIER_BADGES key sets differ between cogs.refractor and helpers.main. " """helpers.main TIER_BADGES must have entries for T1 through T4."""
f"cogs keys: {set(cog_badges.keys())}, helpers keys: {set(helpers_badges.keys())}" from helpers.main import TIER_BADGES
)
for tier, cog_badge in cog_badges.items(): for tier in range(1, 5):
helpers_badge = helpers_badges[tier] assert tier in TIER_BADGES, f"TIER_BADGES missing tier {tier}"
expected = f"[{helpers_badge}]"
assert cog_badge == expected, (
f"Tier {tier} badge mismatch: "
f"cogs.refractor={cog_badge!r}, "
f"helpers.main={helpers_badge!r} "
f"(expected cog badge to equal '[{helpers_badge}]')"
)
def test_t1_badge_relationship(self): def test_tier_symbols_are_unique(self):
"""T1: helpers.main 'BC' wrapped in brackets equals cogs.refractor '[BC]'.""" """Each tier must have a distinct symbol."""
from cogs.refractor import TIER_BADGES as cog_badges from cogs.refractor import TIER_SYMBOLS
from helpers.main import TIER_BADGES as helpers_badges
assert f"[{helpers_badges[1]}]" == cog_badges[1] values = list(TIER_SYMBOLS.values())
assert len(values) == len(set(values)), f"Duplicate symbols found: {values}"
def test_t2_badge_relationship(self):
"""T2: helpers.main 'R' wrapped in brackets equals cogs.refractor '[R]'."""
from cogs.refractor import TIER_BADGES as cog_badges
from helpers.main import TIER_BADGES as helpers_badges
assert f"[{helpers_badges[2]}]" == cog_badges[2]
def test_t3_badge_relationship(self):
"""T3: helpers.main 'GR' wrapped in brackets equals cogs.refractor '[GR]'."""
from cogs.refractor import TIER_BADGES as cog_badges
from helpers.main import TIER_BADGES as helpers_badges
assert f"[{helpers_badges[3]}]" == cog_badges[3]
def test_t4_badge_relationship(self):
"""T4: helpers.main 'SF' wrapped in brackets equals cogs.refractor '[SF]'."""
from cogs.refractor import TIER_BADGES as cog_badges
from helpers.main import TIER_BADGES as helpers_badges
assert f"[{helpers_badges[4]}]" == cog_badges[4]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@ -29,7 +29,7 @@ from cogs.refractor import (
apply_close_filter, apply_close_filter,
paginate, paginate,
TIER_NAMES, TIER_NAMES,
TIER_BADGES, TIER_SYMBOLS,
PAGE_SIZE, PAGE_SIZE,
) )
@ -40,12 +40,12 @@ from cogs.refractor import (
@pytest.fixture @pytest.fixture
def batter_state(): def batter_state():
"""A mid-progress batter card state.""" """A mid-progress batter card state (API response shape)."""
return { return {
"player_name": "Mike Trout", "player_name": "Mike Trout",
"card_type": "batter", "track": {"card_type": "batter", "formula": "pa + tb * 2"},
"current_tier": 1, "current_tier": 1,
"formula_value": 120, "current_value": 120,
"next_threshold": 149, "next_threshold": 149,
} }
@ -55,9 +55,9 @@ def evolved_state():
"""A fully evolved card state (T4).""" """A fully evolved card state (T4)."""
return { return {
"player_name": "Shohei Ohtani", "player_name": "Shohei Ohtani",
"card_type": "batter", "track": {"card_type": "batter", "formula": "pa + tb * 2"},
"current_tier": 4, "current_tier": 4,
"formula_value": 300, "current_value": 300,
"next_threshold": None, "next_threshold": None,
} }
@ -67,9 +67,9 @@ def sp_state():
"""A starting pitcher card state at T2.""" """A starting pitcher card state at T2."""
return { return {
"player_name": "Sandy Alcantara", "player_name": "Sandy Alcantara",
"card_type": "sp", "track": {"card_type": "sp", "formula": "ip + k"},
"current_tier": 2, "current_tier": 2,
"formula_value": 95, "current_value": 95,
"next_threshold": 120, "next_threshold": 120,
} }
@ -84,38 +84,44 @@ class TestRenderProgressBar:
Tests for render_progress_bar(). Tests for render_progress_bar().
Verifies width, fill character, empty character, boundary conditions, Verifies width, fill character, empty character, boundary conditions,
and clamping when current exceeds threshold. and clamping when current exceeds threshold. Default width is 12.
Uses Unicode block chars: (filled) and (empty).
""" """
def test_empty_bar(self): def test_empty_bar(self):
"""current=0 → all dashes.""" """current=0 → all empty blocks."""
assert render_progress_bar(0, 100) == "[----------]" assert render_progress_bar(0, 100) == "" * 12
def test_full_bar(self): def test_full_bar(self):
"""current == threshold → all equals.""" """current == threshold → all filled blocks."""
assert render_progress_bar(100, 100) == "[==========]" assert render_progress_bar(100, 100) == "" * 12
def test_partial_fill(self): def test_partial_fill(self):
"""120/149 ≈ 80.5%8 filled of 10.""" """120/149 ≈ 80.5%~10 filled of 12."""
bar = render_progress_bar(120, 149) bar = render_progress_bar(120, 149)
assert bar == "[========--]" filled = bar.count("")
empty = bar.count("")
assert filled + empty == 12
assert filled == 10 # round(0.805 * 12) = 10
def test_half_fill(self): def test_half_fill(self):
"""50/100 = 50% → 5 filled.""" """50/100 = 50% → 6 filled."""
assert render_progress_bar(50, 100) == "[=====-----]" bar = render_progress_bar(50, 100)
assert bar.count("") == 6
assert bar.count("") == 6
def test_over_threshold_clamps_to_full(self): def test_over_threshold_clamps_to_full(self):
"""current > threshold should not overflow the bar.""" """current > threshold should not overflow the bar."""
assert render_progress_bar(200, 100) == "[==========]" assert render_progress_bar(200, 100) == "" * 12
def test_zero_threshold_returns_full_bar(self): def test_zero_threshold_returns_full_bar(self):
"""threshold=0 avoids division by zero and returns full bar.""" """threshold=0 avoids division by zero and returns full bar."""
assert render_progress_bar(0, 0) == "[==========]" assert render_progress_bar(0, 0) == "" * 12
def test_custom_width(self): def test_custom_width(self):
"""Width parameter controls bar length.""" """Width parameter controls bar length."""
bar = render_progress_bar(5, 10, width=4) bar = render_progress_bar(5, 10, width=4)
assert bar == "[==--]" assert bar == "▰▰▱▱"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -140,7 +146,7 @@ class TestFormatRefractorEntry:
def test_tier_label_in_output(self, batter_state): def test_tier_label_in_output(self, batter_state):
"""Current tier name (Base Chrome for T1) appears in output.""" """Current tier name (Base Chrome for T1) appears in output."""
result = format_refractor_entry(batter_state) result = format_refractor_entry(batter_state)
assert "(Base Chrome)" in result assert "Base Chrome" in result
def test_progress_values_in_output(self, batter_state): def test_progress_values_in_output(self, batter_state):
"""current/threshold values appear in output.""" """current/threshold values appear in output."""
@ -191,69 +197,66 @@ class TestFormatRefractorEntry:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestTierBadges: class TestTierSymbols:
""" """
Verify TIER_BADGES values and that format_refractor_entry prepends badges Verify TIER_SYMBOLS values and that format_refractor_entry prepends
correctly for T1-T4. T0 cards should have no badge prefix. the correct symbol for each tier. Each tier has a unique Unicode symbol.
""" """
def test_t1_badge_value(self): def test_t0_symbol(self):
"""T1 badge is [BC] (Base Chrome).""" """T0 symbol is ○ (hollow circle)."""
assert TIER_BADGES[1] == "[BC]" assert TIER_SYMBOLS[0] == ""
def test_t2_badge_value(self): def test_t1_symbol(self):
"""T2 badge is [R] (Refractor).""" """T1 symbol is ◈ (diamond with dot)."""
assert TIER_BADGES[2] == "[R]" assert TIER_SYMBOLS[1] == ""
def test_t3_badge_value(self): def test_t2_symbol(self):
"""T3 badge is [GR] (Gold Refractor).""" """T2 symbol is ◆ (filled diamond)."""
assert TIER_BADGES[3] == "[GR]" assert TIER_SYMBOLS[2] == ""
def test_t4_badge_value(self): def test_t3_symbol(self):
"""T4 badge is [SF] (Superfractor).""" """T3 symbol is ✦ (four-pointed star)."""
assert TIER_BADGES[4] == "[SF]" assert TIER_SYMBOLS[3] == ""
def test_t0_no_badge(self): def test_t4_symbol(self):
"""T0 has no badge entry in TIER_BADGES.""" """T4 symbol is ★ (filled star)."""
assert 0 not in TIER_BADGES assert TIER_SYMBOLS[4] == ""
def test_format_entry_t1_badge_present(self, batter_state): def test_format_entry_t1_symbol_present(self, batter_state):
"""format_refractor_entry prepends [BC] badge for T1 cards.""" """format_refractor_entry prepends ◈ symbol for T1 cards."""
result = format_refractor_entry(batter_state) result = format_refractor_entry(batter_state)
assert "[BC]" in result assert "" in result
def test_format_entry_t2_badge_present(self, sp_state): def test_format_entry_t2_symbol_present(self, sp_state):
"""format_refractor_entry prepends [R] badge for T2 cards.""" """format_refractor_entry prepends ◆ symbol for T2 cards."""
result = format_refractor_entry(sp_state) result = format_refractor_entry(sp_state)
assert "[R]" in result assert "" in result
def test_format_entry_t4_badge_present(self, evolved_state): def test_format_entry_t4_symbol_present(self, evolved_state):
"""format_refractor_entry prepends [SF] badge for T4 cards.""" """format_refractor_entry prepends ★ symbol for T4 cards."""
result = format_refractor_entry(evolved_state) result = format_refractor_entry(evolved_state)
assert "[SF]" in result assert "" in result
def test_format_entry_t0_no_badge(self): def test_format_entry_t0_uses_hollow_circle(self):
"""format_refractor_entry does not prepend any badge for T0 cards.""" """T0 cards use the ○ symbol."""
state = { state = {
"player_name": "Rookie Player", "player_name": "Rookie Player",
"card_type": "batter", "track": {"card_type": "batter"},
"current_tier": 0, "current_tier": 0,
"formula_value": 10, "current_value": 10,
"next_threshold": 50, "next_threshold": 50,
} }
result = format_refractor_entry(state) result = format_refractor_entry(state)
assert "[BC]" not in result assert "" 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): def test_format_entry_symbol_before_name(self, batter_state):
"""Badge appears before the player name in the bold section.""" """Symbol appears before the player name in the first line."""
result = format_refractor_entry(batter_state) result = format_refractor_entry(batter_state)
first_line = result.split("\n")[0] first_line = result.split("\n")[0]
badge_pos = first_line.find("[BC]") symbol_pos = first_line.find("")
name_pos = first_line.find("Mike Trout") name_pos = first_line.find("Mike Trout")
assert badge_pos < name_pos assert symbol_pos < name_pos
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -271,34 +274,34 @@ class TestApplyCloseFilter:
def test_close_card_included(self): def test_close_card_included(self):
"""Card at exactly 80% is included.""" """Card at exactly 80% is included."""
state = {"current_tier": 1, "formula_value": 80, "next_threshold": 100} state = {"current_tier": 1, "current_value": 80, "next_threshold": 100}
assert apply_close_filter([state]) == [state] assert apply_close_filter([state]) == [state]
def test_above_80_percent_included(self): def test_above_80_percent_included(self):
"""Card above 80% is included.""" """Card above 80% is included."""
state = {"current_tier": 0, "formula_value": 95, "next_threshold": 100} state = {"current_tier": 0, "current_value": 95, "next_threshold": 100}
assert apply_close_filter([state]) == [state] assert apply_close_filter([state]) == [state]
def test_below_80_percent_excluded(self): def test_below_80_percent_excluded(self):
"""Card below 80% threshold is excluded.""" """Card below 80% threshold is excluded."""
state = {"current_tier": 1, "formula_value": 79, "next_threshold": 100} state = {"current_tier": 1, "current_value": 79, "next_threshold": 100}
assert apply_close_filter([state]) == [] assert apply_close_filter([state]) == []
def test_fully_evolved_excluded(self): def test_fully_evolved_excluded(self):
"""T4 cards are never returned by close filter.""" """T4 cards are never returned by close filter."""
state = {"current_tier": 4, "formula_value": 300, "next_threshold": None} state = {"current_tier": 4, "current_value": 300, "next_threshold": None}
assert apply_close_filter([state]) == [] assert apply_close_filter([state]) == []
def test_none_threshold_excluded(self): def test_none_threshold_excluded(self):
"""Cards with no next_threshold (regardless of tier) are excluded.""" """Cards with no next_threshold (regardless of tier) are excluded."""
state = {"current_tier": 3, "formula_value": 200, "next_threshold": None} state = {"current_tier": 3, "current_value": 200, "next_threshold": None}
assert apply_close_filter([state]) == [] assert apply_close_filter([state]) == []
def test_mixed_list(self): def test_mixed_list(self):
"""Only qualifying cards are returned from a mixed list.""" """Only qualifying cards are returned from a mixed list."""
close = {"current_tier": 1, "formula_value": 90, "next_threshold": 100} close = {"current_tier": 1, "current_value": 90, "next_threshold": 100}
not_close = {"current_tier": 1, "formula_value": 50, "next_threshold": 100} not_close = {"current_tier": 1, "current_value": 50, "next_threshold": 100}
evolved = {"current_tier": 4, "formula_value": 300, "next_threshold": None} evolved = {"current_tier": 4, "current_value": 300, "next_threshold": None}
result = apply_close_filter([close, not_close, evolved]) result = apply_close_filter([close, not_close, evolved])
assert result == [close] assert result == [close]
@ -506,9 +509,9 @@ class TestApplyCloseFilterWithAllT4Cards:
the "no cards close to advancement" message rather than an empty embed. the "no cards close to advancement" message rather than an empty embed.
""" """
t4_cards = [ t4_cards = [
{"current_tier": 4, "formula_value": 300, "next_threshold": None}, {"current_tier": 4, "current_value": 300, "next_threshold": None},
{"current_tier": 4, "formula_value": 500, "next_threshold": None}, {"current_tier": 4, "current_value": 500, "next_threshold": None},
{"current_tier": 4, "formula_value": 275, "next_threshold": None}, {"current_tier": 4, "current_value": 275, "next_threshold": None},
] ]
result = apply_close_filter(t4_cards) result = apply_close_filter(t4_cards)
assert result == [], ( assert result == [], (
@ -523,7 +526,7 @@ class TestApplyCloseFilterWithAllT4Cards:
""" """
t4_high_value = { t4_high_value = {
"current_tier": 4, "current_tier": 4,
"formula_value": 9999, "current_value": 9999,
"next_threshold": None, "next_threshold": None,
} }
assert apply_close_filter([t4_high_value]) == [] assert apply_close_filter([t4_high_value]) == []
@ -552,9 +555,9 @@ class TestFormatRefractorEntryMalformedInput:
than crashing with a KeyError. than crashing with a KeyError.
""" """
state = { state = {
"card_type": "batter", "track": {"card_type": "batter"},
"current_tier": 1, "current_tier": 1,
"formula_value": 100, "current_value": 100,
"next_threshold": 150, "next_threshold": 150,
} }
result = format_refractor_entry(state) result = format_refractor_entry(state)
@ -562,12 +565,12 @@ class TestFormatRefractorEntryMalformedInput:
def test_missing_formula_value_uses_zero(self): def test_missing_formula_value_uses_zero(self):
""" """
When formula_value is absent, the progress calculation should use 0 When current_value is absent, the progress calculation should use 0
without raising a TypeError. without raising a TypeError.
""" """
state = { state = {
"player_name": "Test Player", "player_name": "Test Player",
"card_type": "batter", "track": {"card_type": "batter"},
"current_tier": 1, "current_tier": 1,
"next_threshold": 150, "next_threshold": 150,
} }
@ -587,14 +590,14 @@ class TestFormatRefractorEntryMalformedInput:
def test_missing_card_type_uses_raw_fallback(self): def test_missing_card_type_uses_raw_fallback(self):
""" """
When card_type is absent, the code defaults to 'batter' internally When card_type is absent from the track, the code defaults to 'batter'
(via .get("card_type", "batter")), so "PA+TB×2" should appear as the internally (via .get("card_type", "batter")), so "PA+TB×2" should
formula label. appear as the formula label.
""" """
state = { state = {
"player_name": "Test Player", "player_name": "Test Player",
"current_tier": 1, "current_tier": 1,
"formula_value": 50, "current_value": 50,
"next_threshold": 100, "next_threshold": 100,
} }
result = format_refractor_entry(state) result = format_refractor_entry(state)
@ -623,30 +626,27 @@ class TestRenderProgressBarBoundaryPrecision:
rest empty. The bar must not appear more than minimally filled. rest empty. The bar must not appear more than minimally filled.
""" """
bar = render_progress_bar(1, 100) bar = render_progress_bar(1, 100)
# Interior is 10 chars: count '=' vs '-' filled_count = bar.count("")
interior = bar[1:-1] # strip '[' and ']'
filled_count = interior.count("=")
assert filled_count <= 1, ( assert filled_count <= 1, (
f"1/100 should show 0 or 1 filled segment, got {filled_count}: {bar!r}" f"1/100 should show 0 or 1 filled segment, got {filled_count}: {bar!r}"
) )
def test_ninety_nine_of_hundred_is_nearly_full(self): def test_ninety_nine_of_hundred_is_nearly_full(self):
""" """
99/100 = 99% should produce a bar with 9 or 10 filled segments. 99/100 = 99% should produce a bar with 11 or 12 filled segments.
The bar must NOT be completely empty or show fewer than 9 filled. The bar must NOT be completely empty or show fewer than 11 filled.
""" """
bar = render_progress_bar(99, 100) bar = render_progress_bar(99, 100)
interior = bar[1:-1] filled_count = bar.count("")
filled_count = interior.count("=") assert filled_count >= 11, (
assert filled_count >= 9, ( f"99/100 should show 11 or 12 filled segments, got {filled_count}: {bar!r}"
f"99/100 should show 9 or 10 filled segments, got {filled_count}: {bar!r}"
) )
# But it must not overflow the bar width # Bar width must be exactly 12
assert len(interior) == 10 assert len(bar) == 12
def test_zero_of_hundred_is_completely_empty(self): def test_zero_of_hundred_is_completely_empty(self):
"""0/100 = all dashes — re-verify the all-empty baseline.""" """0/100 = all empty blocks — re-verify the all-empty baseline."""
assert render_progress_bar(0, 100) == "[----------]" assert render_progress_bar(0, 100) == "" * 12
def test_negative_current_does_not_overflow_bar(self): def test_negative_current_does_not_overflow_bar(self):
""" """
@ -656,14 +656,12 @@ class TestRenderProgressBarBoundaryPrecision:
a future refactor removing the clamp. a future refactor removing the clamp.
""" """
bar = render_progress_bar(-5, 100) bar = render_progress_bar(-5, 100)
interior = bar[1:-1] filled_count = bar.count("")
# No filled segments should exist for a negative value
filled_count = interior.count("=")
assert filled_count == 0, ( assert filled_count == 0, (
f"Negative current should produce 0 filled segments, got {filled_count}: {bar!r}" f"Negative current should produce 0 filled segments, got {filled_count}: {bar!r}"
) )
# Bar width must be exactly 10 # Bar width must be exactly 12
assert len(interior) == 10 assert len(bar) == 12
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -689,9 +687,9 @@ class TestRPFormulaLabel:
""" """
rp_state = { rp_state = {
"player_name": "Edwin Diaz", "player_name": "Edwin Diaz",
"card_type": "rp", "track": {"card_type": "rp"},
"current_tier": 1, "current_tier": 1,
"formula_value": 45, "current_value": 45,
"next_threshold": 60, "next_threshold": 60,
} }
result = format_refractor_entry(rp_state) result = format_refractor_entry(rp_state)
@ -724,9 +722,9 @@ class TestUnknownCardTypeFallback:
""" """
util_state = { util_state = {
"player_name": "Ben Zobrist", "player_name": "Ben Zobrist",
"card_type": "util", "track": {"card_type": "util"},
"current_tier": 2, "current_tier": 2,
"formula_value": 80, "current_value": 80,
"next_threshold": 120, "next_threshold": 120,
} }
result = format_refractor_entry(util_state) result = format_refractor_entry(util_state)
@ -741,9 +739,9 @@ class TestUnknownCardTypeFallback:
""" """
state = { state = {
"player_name": "Test Player", "player_name": "Test Player",
"card_type": "dh", "track": {"card_type": "dh"},
"current_tier": 1, "current_tier": 1,
"formula_value": 30, "current_value": 30,
"next_threshold": 50, "next_threshold": 50,
} }
result = format_refractor_entry(state) result = format_refractor_entry(state)