feat: redesign /refractor status with rich Unicode display #129

Merged
cal merged 1 commits from feat/refractor-status-redesign into main 2026-03-26 03:50:43 +00:00
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 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:

View File

@ -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

View File

@ -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}"
# ---------------------------------------------------------------------------

View File

@ -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)