feat: redesign /refractor status with rich Unicode display #129
@ -19,6 +19,7 @@ from discord.app_commands import Choice
|
||||
from discord.ext import commands
|
||||
|
||||
from api_calls import db_get
|
||||
from helpers.discord_utils import get_team_embed
|
||||
from helpers.main import get_team_by_owner
|
||||
|
||||
logger = logging.getLogger("discord_app")
|
||||
@ -39,40 +40,61 @@ FORMULA_LABELS = {
|
||||
"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:
|
||||
render_progress_bar(120, 149) -> '[========--]'
|
||||
render_progress_bar(0, 100) -> '[----------]'
|
||||
render_progress_bar(100, 100) -> '[==========]'
|
||||
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)
|
||||
ratio = max(0.0, min(current / threshold, 1.0))
|
||||
filled = round(ratio * width)
|
||||
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:
|
||||
"""
|
||||
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,
|
||||
next_threshold (None if fully evolved).
|
||||
Output example (in-progress):
|
||||
◈ **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
|
||||
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
|
||||
Output example (fully evolved):
|
||||
★ **Barry Bonds** — Superfractor
|
||||
▰▰▰▰▰▰▰▰▰▰▰▰ ✧ FULLY EVOLVED ✧
|
||||
"""
|
||||
player_name = card_state.get("player_name", "Unknown")
|
||||
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}")
|
||||
formula_label = FORMULA_LABELS.get(card_type, card_type)
|
||||
symbol = TIER_SYMBOLS.get(current_tier, "·")
|
||||
|
||||
badge = TIER_BADGES.get(current_tier, "")
|
||||
display_name = f"{badge} {player_name}" if badge else player_name
|
||||
first_line = f"{symbol} **{player_name}** — {tier_label}"
|
||||
|
||||
if current_tier >= 4 or next_threshold is None:
|
||||
bar = "[==========]"
|
||||
detail = "FULLY EVOLVED ★"
|
||||
bar = render_progress_bar(1, 1)
|
||||
second_line = f"{bar} ✧ 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}"
|
||||
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}"
|
||||
|
||||
|
||||
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:
|
||||
"""
|
||||
Return only cards within 80% of their next tier threshold.
|
||||
@ -140,6 +225,7 @@ class RefractorPaginationView(discord.ui.View):
|
||||
total_count: int,
|
||||
params: list,
|
||||
owner_id: int,
|
||||
tier_filter: Optional[int] = None,
|
||||
timeout: float = 120.0,
|
||||
):
|
||||
super().__init__(timeout=timeout)
|
||||
@ -149,6 +235,7 @@ class RefractorPaginationView(discord.ui.View):
|
||||
self.total_count = total_count
|
||||
self.base_params = params
|
||||
self.owner_id = owner_id
|
||||
self.tier_filter = tier_filter
|
||||
self._update_buttons()
|
||||
|
||||
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.page = min(self.page, self.total_pages)
|
||||
|
||||
lines = [format_refractor_entry(state) for state in items]
|
||||
embed = discord.Embed(
|
||||
title=f"{self.team['sname']} Refractor Status",
|
||||
description="\n\n".join(lines) if lines else "No cards found.",
|
||||
color=0x6F42C1,
|
||||
)
|
||||
embed.set_footer(
|
||||
text=f"Page {self.page}/{self.total_pages} · {self.total_count} card(s) total"
|
||||
embed = build_status_embed(
|
||||
self.team,
|
||||
items,
|
||||
self.page,
|
||||
self.total_pages,
|
||||
self.total_count,
|
||||
tier_filter=self.tier_filter,
|
||||
)
|
||||
self._update_buttons()
|
||||
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(
|
||||
self, interaction: discord.Interaction, button: discord.ui.Button
|
||||
):
|
||||
@ -191,7 +277,7 @@ class RefractorPaginationView(discord.ui.View):
|
||||
self.page = max(1, self.page - 1)
|
||||
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(
|
||||
self, interaction: discord.Interaction, button: discord.ui.Button
|
||||
):
|
||||
@ -269,6 +355,8 @@ class Refractor(commands.Cog):
|
||||
if progress:
|
||||
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)
|
||||
if not data:
|
||||
logger.error(
|
||||
@ -322,15 +410,9 @@ class Refractor(commands.Cog):
|
||||
|
||||
total_pages = max(1, (total_count + PAGE_SIZE - 1) // PAGE_SIZE)
|
||||
page = min(page, total_pages)
|
||||
lines = [format_refractor_entry(state) for state in 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} · {total_count} card(s) total"
|
||||
embed = build_status_embed(
|
||||
team, items, page, total_pages, total_count, tier_filter=tier_filter
|
||||
)
|
||||
|
||||
if total_pages > 1:
|
||||
@ -341,6 +423,7 @@ class Refractor(commands.Cog):
|
||||
total_count=total_count,
|
||||
params=params,
|
||||
owner_id=interaction.user.id,
|
||||
tier_filter=tier_filter,
|
||||
)
|
||||
await interaction.edit_original_response(embed=embed, view=view)
|
||||
else:
|
||||
|
||||
@ -665,16 +665,28 @@ design but means tier-up notifications are best-effort.
|
||||
|
||||
Run order for Playwright automation:
|
||||
|
||||
1. [ ] Execute REF-API-01 through REF-API-10 (API health + list endpoint)
|
||||
2. [ ] Execute REF-01 through REF-06 (basic /refractor status)
|
||||
3. [ ] Execute REF-10 through REF-19 (filters)
|
||||
4. [ ] Execute REF-20 through REF-23 (pagination)
|
||||
1. [~] Execute REF-API-01 through REF-API-10 (API health + list endpoint)
|
||||
- Tested 2026-03-25: REF-API-03 (single card ✓), REF-API-06 (list ✓), REF-API-07 (card_type filter ✓), REF-API-10 (pagination ✓)
|
||||
- Not yet tested: REF-API-01, REF-API-02, REF-API-04, REF-API-05, REF-API-08, REF-API-09
|
||||
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)
|
||||
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)
|
||||
8. [ ] Execute REF-60 through REF-64 (tier-up notifications -- requires threshold crossing)
|
||||
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
|
||||
- API health checks + list endpoint (REF-API-01 through REF-API-10): 2-3 minutes
|
||||
|
||||
@ -251,78 +251,36 @@ class TestEmbedColorUnchanged:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTierBadgesFormatConsistency:
|
||||
class TestTierSymbolsCompleteness:
|
||||
"""
|
||||
T1-7: Assert that TIER_BADGES in cogs.refractor (format: "[BC]") and
|
||||
helpers.main (format: "BC") are consistent — wrapping the helpers.main
|
||||
value in brackets must produce the cogs.refractor value.
|
||||
T1-7: Assert that TIER_SYMBOLS in cogs.refractor covers all tiers 0-4
|
||||
and that helpers.main TIER_BADGES still covers tiers 1-4 for card embeds.
|
||||
|
||||
Why: The two modules intentionally use different formats for different
|
||||
rendering contexts:
|
||||
- helpers.main uses bare strings ("BC") because get_card_embeds
|
||||
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.
|
||||
Why: The refractor status command uses Unicode TIER_SYMBOLS for display,
|
||||
while card embed titles use helpers.main TIER_BADGES in bracket format.
|
||||
Both must cover the full tier range for their respective contexts.
|
||||
"""
|
||||
|
||||
def test_cogs_badge_equals_bracketed_helpers_badge_for_all_tiers(self):
|
||||
"""
|
||||
For every tier in cogs.refractor TIER_BADGES, wrapping the
|
||||
helpers.main TIER_BADGES value in square brackets must produce
|
||||
the cogs.refractor value.
|
||||
def test_tier_symbols_covers_all_tiers(self):
|
||||
"""TIER_SYMBOLS must have entries for T0 through T4."""
|
||||
from cogs.refractor import TIER_SYMBOLS
|
||||
|
||||
i.e., f"[{helpers_badge}]" == cog_badge for all tiers.
|
||||
"""
|
||||
from cogs.refractor import TIER_BADGES as cog_badges
|
||||
from helpers.main import TIER_BADGES as helpers_badges
|
||||
for tier in range(5):
|
||||
assert tier in TIER_SYMBOLS, f"TIER_SYMBOLS missing tier {tier}"
|
||||
|
||||
assert set(cog_badges.keys()) == set(helpers_badges.keys()), (
|
||||
"TIER_BADGES key sets differ between cogs.refractor and helpers.main. "
|
||||
f"cogs keys: {set(cog_badges.keys())}, helpers keys: {set(helpers_badges.keys())}"
|
||||
)
|
||||
def test_tier_badges_covers_evolved_tiers(self):
|
||||
"""helpers.main TIER_BADGES must have entries for T1 through T4."""
|
||||
from helpers.main import TIER_BADGES
|
||||
|
||||
for tier, cog_badge in cog_badges.items():
|
||||
helpers_badge = helpers_badges[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}]')"
|
||||
)
|
||||
for tier in range(1, 5):
|
||||
assert tier in TIER_BADGES, f"TIER_BADGES missing tier {tier}"
|
||||
|
||||
def test_t1_badge_relationship(self):
|
||||
"""T1: helpers.main 'BC' wrapped in brackets equals cogs.refractor '[BC]'."""
|
||||
from cogs.refractor import TIER_BADGES as cog_badges
|
||||
from helpers.main import TIER_BADGES as helpers_badges
|
||||
def test_tier_symbols_are_unique(self):
|
||||
"""Each tier must have a distinct symbol."""
|
||||
from cogs.refractor import TIER_SYMBOLS
|
||||
|
||||
assert f"[{helpers_badges[1]}]" == cog_badges[1]
|
||||
|
||||
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]
|
||||
values = list(TIER_SYMBOLS.values())
|
||||
assert len(values) == len(set(values)), f"Duplicate symbols found: {values}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@ -29,7 +29,7 @@ from cogs.refractor import (
|
||||
apply_close_filter,
|
||||
paginate,
|
||||
TIER_NAMES,
|
||||
TIER_BADGES,
|
||||
TIER_SYMBOLS,
|
||||
PAGE_SIZE,
|
||||
)
|
||||
|
||||
@ -40,12 +40,12 @@ from cogs.refractor import (
|
||||
|
||||
@pytest.fixture
|
||||
def batter_state():
|
||||
"""A mid-progress batter card state."""
|
||||
"""A mid-progress batter card state (API response shape)."""
|
||||
return {
|
||||
"player_name": "Mike Trout",
|
||||
"card_type": "batter",
|
||||
"track": {"card_type": "batter", "formula": "pa + tb * 2"},
|
||||
"current_tier": 1,
|
||||
"formula_value": 120,
|
||||
"current_value": 120,
|
||||
"next_threshold": 149,
|
||||
}
|
||||
|
||||
@ -55,9 +55,9 @@ def evolved_state():
|
||||
"""A fully evolved card state (T4)."""
|
||||
return {
|
||||
"player_name": "Shohei Ohtani",
|
||||
"card_type": "batter",
|
||||
"track": {"card_type": "batter", "formula": "pa + tb * 2"},
|
||||
"current_tier": 4,
|
||||
"formula_value": 300,
|
||||
"current_value": 300,
|
||||
"next_threshold": None,
|
||||
}
|
||||
|
||||
@ -67,9 +67,9 @@ def sp_state():
|
||||
"""A starting pitcher card state at T2."""
|
||||
return {
|
||||
"player_name": "Sandy Alcantara",
|
||||
"card_type": "sp",
|
||||
"track": {"card_type": "sp", "formula": "ip + k"},
|
||||
"current_tier": 2,
|
||||
"formula_value": 95,
|
||||
"current_value": 95,
|
||||
"next_threshold": 120,
|
||||
}
|
||||
|
||||
@ -84,38 +84,44 @@ class TestRenderProgressBar:
|
||||
Tests for render_progress_bar().
|
||||
|
||||
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):
|
||||
"""current=0 → all dashes."""
|
||||
assert render_progress_bar(0, 100) == "[----------]"
|
||||
"""current=0 → all empty blocks."""
|
||||
assert render_progress_bar(0, 100) == "▱" * 12
|
||||
|
||||
def test_full_bar(self):
|
||||
"""current == threshold → all equals."""
|
||||
assert render_progress_bar(100, 100) == "[==========]"
|
||||
"""current == threshold → all filled blocks."""
|
||||
assert render_progress_bar(100, 100) == "▰" * 12
|
||||
|
||||
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)
|
||||
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):
|
||||
"""50/100 = 50% → 5 filled."""
|
||||
assert render_progress_bar(50, 100) == "[=====-----]"
|
||||
"""50/100 = 50% → 6 filled."""
|
||||
bar = render_progress_bar(50, 100)
|
||||
assert bar.count("▰") == 6
|
||||
assert bar.count("▱") == 6
|
||||
|
||||
def test_over_threshold_clamps_to_full(self):
|
||||
"""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):
|
||||
"""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):
|
||||
"""Width parameter controls bar length."""
|
||||
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):
|
||||
"""Current tier name (Base Chrome for T1) appears in output."""
|
||||
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):
|
||||
"""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
|
||||
correctly for T1-T4. T0 cards should have no badge prefix.
|
||||
Verify TIER_SYMBOLS values and that format_refractor_entry prepends
|
||||
the correct symbol for each tier. Each tier has a unique Unicode symbol.
|
||||
"""
|
||||
|
||||
def test_t1_badge_value(self):
|
||||
"""T1 badge is [BC] (Base Chrome)."""
|
||||
assert TIER_BADGES[1] == "[BC]"
|
||||
def test_t0_symbol(self):
|
||||
"""T0 symbol is ○ (hollow circle)."""
|
||||
assert TIER_SYMBOLS[0] == "○"
|
||||
|
||||
def test_t2_badge_value(self):
|
||||
"""T2 badge is [R] (Refractor)."""
|
||||
assert TIER_BADGES[2] == "[R]"
|
||||
def test_t1_symbol(self):
|
||||
"""T1 symbol is ◈ (diamond with dot)."""
|
||||
assert TIER_SYMBOLS[1] == "◈"
|
||||
|
||||
def test_t3_badge_value(self):
|
||||
"""T3 badge is [GR] (Gold Refractor)."""
|
||||
assert TIER_BADGES[3] == "[GR]"
|
||||
def test_t2_symbol(self):
|
||||
"""T2 symbol is ◆ (filled diamond)."""
|
||||
assert TIER_SYMBOLS[2] == "◆"
|
||||
|
||||
def test_t4_badge_value(self):
|
||||
"""T4 badge is [SF] (Superfractor)."""
|
||||
assert TIER_BADGES[4] == "[SF]"
|
||||
def test_t3_symbol(self):
|
||||
"""T3 symbol is ✦ (four-pointed star)."""
|
||||
assert TIER_SYMBOLS[3] == "✦"
|
||||
|
||||
def test_t0_no_badge(self):
|
||||
"""T0 has no badge entry in TIER_BADGES."""
|
||||
assert 0 not in TIER_BADGES
|
||||
def test_t4_symbol(self):
|
||||
"""T4 symbol is ★ (filled star)."""
|
||||
assert TIER_SYMBOLS[4] == "★"
|
||||
|
||||
def test_format_entry_t1_badge_present(self, batter_state):
|
||||
"""format_refractor_entry prepends [BC] badge for T1 cards."""
|
||||
def test_format_entry_t1_symbol_present(self, batter_state):
|
||||
"""format_refractor_entry prepends ◈ symbol for T1 cards."""
|
||||
result = format_refractor_entry(batter_state)
|
||||
assert "[BC]" in result
|
||||
assert "◈" in result
|
||||
|
||||
def test_format_entry_t2_badge_present(self, sp_state):
|
||||
"""format_refractor_entry prepends [R] badge for T2 cards."""
|
||||
def test_format_entry_t2_symbol_present(self, sp_state):
|
||||
"""format_refractor_entry prepends ◆ symbol for T2 cards."""
|
||||
result = format_refractor_entry(sp_state)
|
||||
assert "[R]" in result
|
||||
assert "◆" in result
|
||||
|
||||
def test_format_entry_t4_badge_present(self, evolved_state):
|
||||
"""format_refractor_entry prepends [SF] badge for T4 cards."""
|
||||
def test_format_entry_t4_symbol_present(self, evolved_state):
|
||||
"""format_refractor_entry prepends ★ symbol for T4 cards."""
|
||||
result = format_refractor_entry(evolved_state)
|
||||
assert "[SF]" in result
|
||||
assert "★" in result
|
||||
|
||||
def test_format_entry_t0_no_badge(self):
|
||||
"""format_refractor_entry does not prepend any badge for T0 cards."""
|
||||
def test_format_entry_t0_uses_hollow_circle(self):
|
||||
"""T0 cards use the ○ symbol."""
|
||||
state = {
|
||||
"player_name": "Rookie Player",
|
||||
"card_type": "batter",
|
||||
"track": {"card_type": "batter"},
|
||||
"current_tier": 0,
|
||||
"formula_value": 10,
|
||||
"current_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
|
||||
assert "○" in result
|
||||
|
||||
def test_format_entry_badge_before_name(self, batter_state):
|
||||
"""Badge appears before the player name in the bold section."""
|
||||
def test_format_entry_symbol_before_name(self, batter_state):
|
||||
"""Symbol appears before the player name in the first line."""
|
||||
result = format_refractor_entry(batter_state)
|
||||
first_line = result.split("\n")[0]
|
||||
badge_pos = first_line.find("[BC]")
|
||||
symbol_pos = first_line.find("◈")
|
||||
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):
|
||||
"""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]
|
||||
|
||||
def test_above_80_percent_included(self):
|
||||
"""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]
|
||||
|
||||
def test_below_80_percent_excluded(self):
|
||||
"""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]) == []
|
||||
|
||||
def test_fully_evolved_excluded(self):
|
||||
"""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]) == []
|
||||
|
||||
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}
|
||||
state = {"current_tier": 3, "current_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}
|
||||
close = {"current_tier": 1, "current_value": 90, "next_threshold": 100}
|
||||
not_close = {"current_tier": 1, "current_value": 50, "next_threshold": 100}
|
||||
evolved = {"current_tier": 4, "current_value": 300, "next_threshold": None}
|
||||
result = apply_close_filter([close, not_close, evolved])
|
||||
assert result == [close]
|
||||
|
||||
@ -506,9 +509,9 @@ class TestApplyCloseFilterWithAllT4Cards:
|
||||
the "no cards close to advancement" message rather than an empty embed.
|
||||
"""
|
||||
t4_cards = [
|
||||
{"current_tier": 4, "formula_value": 300, "next_threshold": None},
|
||||
{"current_tier": 4, "formula_value": 500, "next_threshold": None},
|
||||
{"current_tier": 4, "formula_value": 275, "next_threshold": None},
|
||||
{"current_tier": 4, "current_value": 300, "next_threshold": None},
|
||||
{"current_tier": 4, "current_value": 500, "next_threshold": None},
|
||||
{"current_tier": 4, "current_value": 275, "next_threshold": None},
|
||||
]
|
||||
result = apply_close_filter(t4_cards)
|
||||
assert result == [], (
|
||||
@ -523,7 +526,7 @@ class TestApplyCloseFilterWithAllT4Cards:
|
||||
"""
|
||||
t4_high_value = {
|
||||
"current_tier": 4,
|
||||
"formula_value": 9999,
|
||||
"current_value": 9999,
|
||||
"next_threshold": None,
|
||||
}
|
||||
assert apply_close_filter([t4_high_value]) == []
|
||||
@ -552,9 +555,9 @@ class TestFormatRefractorEntryMalformedInput:
|
||||
than crashing with a KeyError.
|
||||
"""
|
||||
state = {
|
||||
"card_type": "batter",
|
||||
"track": {"card_type": "batter"},
|
||||
"current_tier": 1,
|
||||
"formula_value": 100,
|
||||
"current_value": 100,
|
||||
"next_threshold": 150,
|
||||
}
|
||||
result = format_refractor_entry(state)
|
||||
@ -562,12 +565,12 @@ class TestFormatRefractorEntryMalformedInput:
|
||||
|
||||
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.
|
||||
"""
|
||||
state = {
|
||||
"player_name": "Test Player",
|
||||
"card_type": "batter",
|
||||
"track": {"card_type": "batter"},
|
||||
"current_tier": 1,
|
||||
"next_threshold": 150,
|
||||
}
|
||||
@ -587,14 +590,14 @@ class TestFormatRefractorEntryMalformedInput:
|
||||
|
||||
def test_missing_card_type_uses_raw_fallback(self):
|
||||
"""
|
||||
When card_type is absent, the code defaults to 'batter' internally
|
||||
(via .get("card_type", "batter")), so "PA+TB×2" should appear as the
|
||||
formula label.
|
||||
When card_type is absent from the track, the code defaults to 'batter'
|
||||
internally (via .get("card_type", "batter")), so "PA+TB×2" should
|
||||
appear as the formula label.
|
||||
"""
|
||||
state = {
|
||||
"player_name": "Test Player",
|
||||
"current_tier": 1,
|
||||
"formula_value": 50,
|
||||
"current_value": 50,
|
||||
"next_threshold": 100,
|
||||
}
|
||||
result = format_refractor_entry(state)
|
||||
@ -623,30 +626,27 @@ class TestRenderProgressBarBoundaryPrecision:
|
||||
rest empty. The bar must not appear more than minimally filled.
|
||||
"""
|
||||
bar = render_progress_bar(1, 100)
|
||||
# Interior is 10 chars: count '=' vs '-'
|
||||
interior = bar[1:-1] # strip '[' and ']'
|
||||
filled_count = interior.count("=")
|
||||
filled_count = bar.count("▰")
|
||||
assert filled_count <= 1, (
|
||||
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):
|
||||
"""
|
||||
99/100 = 99% — should produce a bar with 9 or 10 filled segments.
|
||||
The bar must NOT be completely empty or show fewer than 9 filled.
|
||||
99/100 = 99% — should produce a bar with 11 or 12 filled segments.
|
||||
The bar must NOT be completely empty or show fewer than 11 filled.
|
||||
"""
|
||||
bar = render_progress_bar(99, 100)
|
||||
interior = bar[1:-1]
|
||||
filled_count = interior.count("=")
|
||||
assert filled_count >= 9, (
|
||||
f"99/100 should show 9 or 10 filled segments, got {filled_count}: {bar!r}"
|
||||
filled_count = bar.count("▰")
|
||||
assert filled_count >= 11, (
|
||||
f"99/100 should show 11 or 12 filled segments, got {filled_count}: {bar!r}"
|
||||
)
|
||||
# But it must not overflow the bar width
|
||||
assert len(interior) == 10
|
||||
# Bar width must be exactly 12
|
||||
assert len(bar) == 12
|
||||
|
||||
def test_zero_of_hundred_is_completely_empty(self):
|
||||
"""0/100 = all dashes — re-verify the all-empty baseline."""
|
||||
assert render_progress_bar(0, 100) == "[----------]"
|
||||
"""0/100 = all empty blocks — re-verify the all-empty baseline."""
|
||||
assert render_progress_bar(0, 100) == "▱" * 12
|
||||
|
||||
def test_negative_current_does_not_overflow_bar(self):
|
||||
"""
|
||||
@ -656,14 +656,12 @@ class TestRenderProgressBarBoundaryPrecision:
|
||||
a future refactor removing the clamp.
|
||||
"""
|
||||
bar = render_progress_bar(-5, 100)
|
||||
interior = bar[1:-1]
|
||||
# No filled segments should exist for a negative value
|
||||
filled_count = interior.count("=")
|
||||
filled_count = bar.count("▰")
|
||||
assert filled_count == 0, (
|
||||
f"Negative current should produce 0 filled segments, got {filled_count}: {bar!r}"
|
||||
)
|
||||
# Bar width must be exactly 10
|
||||
assert len(interior) == 10
|
||||
# Bar width must be exactly 12
|
||||
assert len(bar) == 12
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -689,9 +687,9 @@ class TestRPFormulaLabel:
|
||||
"""
|
||||
rp_state = {
|
||||
"player_name": "Edwin Diaz",
|
||||
"card_type": "rp",
|
||||
"track": {"card_type": "rp"},
|
||||
"current_tier": 1,
|
||||
"formula_value": 45,
|
||||
"current_value": 45,
|
||||
"next_threshold": 60,
|
||||
}
|
||||
result = format_refractor_entry(rp_state)
|
||||
@ -724,9 +722,9 @@ class TestUnknownCardTypeFallback:
|
||||
"""
|
||||
util_state = {
|
||||
"player_name": "Ben Zobrist",
|
||||
"card_type": "util",
|
||||
"track": {"card_type": "util"},
|
||||
"current_tier": 2,
|
||||
"formula_value": 80,
|
||||
"current_value": 80,
|
||||
"next_threshold": 120,
|
||||
}
|
||||
result = format_refractor_entry(util_state)
|
||||
@ -741,9 +739,9 @@ class TestUnknownCardTypeFallback:
|
||||
"""
|
||||
state = {
|
||||
"player_name": "Test Player",
|
||||
"card_type": "dh",
|
||||
"track": {"card_type": "dh"},
|
||||
"current_tier": 1,
|
||||
"formula_value": 30,
|
||||
"current_value": 30,
|
||||
"next_threshold": 50,
|
||||
}
|
||||
result = format_refractor_entry(state)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user