test: add comprehensive refractor system test coverage (23 tests) Covers TIER_NAMES/TIER_BADGES cross-module consistency, WP-14 tier_up dict shape mismatch (latent KeyError documented), None channel handling, filter combinations, progress bar boundaries, and malformed API response handling.
803 lines
29 KiB
Python
803 lines
29 KiB
Python
"""
|
||
Unit tests for refractor command helper functions (WP-11).
|
||
|
||
Tests cover:
|
||
- render_progress_bar: ASCII bar rendering at various fill levels
|
||
- format_refractor_entry: Full card state formatting including fully evolved case
|
||
- apply_close_filter: 80% proximity filter logic
|
||
- paginate: 1-indexed page slicing and total-page calculation
|
||
- TIER_NAMES: Display names for all tiers
|
||
- Slash command: empty roster and no-team responses (async, uses mocks)
|
||
|
||
All tests are pure-unit unless marked otherwise; no network calls are made.
|
||
"""
|
||
|
||
import sys
|
||
import os
|
||
|
||
import pytest
|
||
from unittest.mock import AsyncMock, Mock, patch
|
||
import discord
|
||
from discord.ext import commands
|
||
|
||
# Make the repo root importable
|
||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||
|
||
from cogs.refractor import (
|
||
render_progress_bar,
|
||
format_refractor_entry,
|
||
apply_close_filter,
|
||
paginate,
|
||
TIER_NAMES,
|
||
TIER_BADGES,
|
||
PAGE_SIZE,
|
||
)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Fixtures
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.fixture
|
||
def batter_state():
|
||
"""A mid-progress batter card state."""
|
||
return {
|
||
"player_name": "Mike Trout",
|
||
"card_type": "batter",
|
||
"current_tier": 1,
|
||
"formula_value": 120,
|
||
"next_threshold": 149,
|
||
}
|
||
|
||
|
||
@pytest.fixture
|
||
def evolved_state():
|
||
"""A fully evolved card state (T4)."""
|
||
return {
|
||
"player_name": "Shohei Ohtani",
|
||
"card_type": "batter",
|
||
"current_tier": 4,
|
||
"formula_value": 300,
|
||
"next_threshold": None,
|
||
}
|
||
|
||
|
||
@pytest.fixture
|
||
def sp_state():
|
||
"""A starting pitcher card state at T2."""
|
||
return {
|
||
"player_name": "Sandy Alcantara",
|
||
"card_type": "sp",
|
||
"current_tier": 2,
|
||
"formula_value": 95,
|
||
"next_threshold": 120,
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# render_progress_bar
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestRenderProgressBar:
|
||
"""
|
||
Tests for render_progress_bar().
|
||
|
||
Verifies width, fill character, empty character, boundary conditions,
|
||
and clamping when current exceeds threshold.
|
||
"""
|
||
|
||
def test_empty_bar(self):
|
||
"""current=0 → all dashes."""
|
||
assert render_progress_bar(0, 100) == "[----------]"
|
||
|
||
def test_full_bar(self):
|
||
"""current == threshold → all equals."""
|
||
assert render_progress_bar(100, 100) == "[==========]"
|
||
|
||
def test_partial_fill(self):
|
||
"""120/149 ≈ 80.5% → 8 filled of 10."""
|
||
bar = render_progress_bar(120, 149)
|
||
assert bar == "[========--]"
|
||
|
||
def test_half_fill(self):
|
||
"""50/100 = 50% → 5 filled."""
|
||
assert render_progress_bar(50, 100) == "[=====-----]"
|
||
|
||
def test_over_threshold_clamps_to_full(self):
|
||
"""current > threshold should not overflow the bar."""
|
||
assert render_progress_bar(200, 100) == "[==========]"
|
||
|
||
def test_zero_threshold_returns_full_bar(self):
|
||
"""threshold=0 avoids division by zero and returns full bar."""
|
||
assert render_progress_bar(0, 0) == "[==========]"
|
||
|
||
def test_custom_width(self):
|
||
"""Width parameter controls bar length."""
|
||
bar = render_progress_bar(5, 10, width=4)
|
||
assert bar == "[==--]"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# format_refractor_entry
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestFormatRefractorEntry:
|
||
"""
|
||
Tests for format_refractor_entry().
|
||
|
||
Verifies player name, tier label, progress bar, formula label,
|
||
and the special fully-evolved formatting.
|
||
"""
|
||
|
||
def test_player_name_in_output(self, batter_state):
|
||
"""Player name appears bold in the first line (badge may prefix it)."""
|
||
result = format_refractor_entry(batter_state)
|
||
assert "Mike Trout" in result
|
||
assert "**" in result
|
||
|
||
def test_tier_label_in_output(self, batter_state):
|
||
"""Current tier name (Base Chrome for T1) appears in output."""
|
||
result = format_refractor_entry(batter_state)
|
||
assert "(Base Chrome)" in result
|
||
|
||
def test_progress_values_in_output(self, batter_state):
|
||
"""current/threshold values appear in output."""
|
||
result = format_refractor_entry(batter_state)
|
||
assert "120/149" in result
|
||
|
||
def test_formula_label_batter(self, batter_state):
|
||
"""Batter formula label PA+TB×2 appears in output."""
|
||
result = format_refractor_entry(batter_state)
|
||
assert "PA+TB×2" in result
|
||
|
||
def test_tier_progression_arrow(self, batter_state):
|
||
"""T1 → T2 arrow progression appears for non-evolved cards."""
|
||
result = format_refractor_entry(batter_state)
|
||
assert "T1 → T2" in result
|
||
|
||
def test_sp_formula_label(self, sp_state):
|
||
"""SP formula label IP+K appears for starting pitchers."""
|
||
result = format_refractor_entry(sp_state)
|
||
assert "IP+K" in result
|
||
|
||
def test_fully_evolved_no_threshold(self, evolved_state):
|
||
"""T4 card with next_threshold=None shows FULLY EVOLVED."""
|
||
result = format_refractor_entry(evolved_state)
|
||
assert "FULLY EVOLVED" in result
|
||
|
||
def test_fully_evolved_by_tier(self, batter_state):
|
||
"""current_tier=4 triggers fully evolved display even with a threshold."""
|
||
batter_state["current_tier"] = 4
|
||
batter_state["next_threshold"] = 200
|
||
result = format_refractor_entry(batter_state)
|
||
assert "FULLY EVOLVED" in result
|
||
|
||
def test_fully_evolved_no_arrow(self, evolved_state):
|
||
"""Fully evolved cards don't show a tier arrow."""
|
||
result = format_refractor_entry(evolved_state)
|
||
assert "→" not in result
|
||
|
||
def test_two_line_output(self, batter_state):
|
||
"""Output always has exactly two lines (name line + bar line)."""
|
||
result = format_refractor_entry(batter_state)
|
||
lines = result.split("\n")
|
||
assert len(lines) == 2
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# TIER_BADGES
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestTierBadges:
|
||
"""
|
||
Verify TIER_BADGES values and that format_refractor_entry prepends badges
|
||
correctly for T1-T4. T0 cards should have no badge prefix.
|
||
"""
|
||
|
||
def test_t1_badge_value(self):
|
||
"""T1 badge is [BC] (Base Chrome)."""
|
||
assert TIER_BADGES[1] == "[BC]"
|
||
|
||
def test_t2_badge_value(self):
|
||
"""T2 badge is [R] (Refractor)."""
|
||
assert TIER_BADGES[2] == "[R]"
|
||
|
||
def test_t3_badge_value(self):
|
||
"""T3 badge is [GR] (Gold Refractor)."""
|
||
assert TIER_BADGES[3] == "[GR]"
|
||
|
||
def test_t4_badge_value(self):
|
||
"""T4 badge is [SF] (Superfractor)."""
|
||
assert TIER_BADGES[4] == "[SF]"
|
||
|
||
def test_t0_no_badge(self):
|
||
"""T0 has no badge entry in TIER_BADGES."""
|
||
assert 0 not in TIER_BADGES
|
||
|
||
def test_format_entry_t1_badge_present(self, batter_state):
|
||
"""format_refractor_entry prepends [BC] badge for T1 cards."""
|
||
result = format_refractor_entry(batter_state)
|
||
assert "[BC]" in result
|
||
|
||
def test_format_entry_t2_badge_present(self, sp_state):
|
||
"""format_refractor_entry prepends [R] badge for T2 cards."""
|
||
result = format_refractor_entry(sp_state)
|
||
assert "[R]" in result
|
||
|
||
def test_format_entry_t4_badge_present(self, evolved_state):
|
||
"""format_refractor_entry prepends [SF] badge for T4 cards."""
|
||
result = format_refractor_entry(evolved_state)
|
||
assert "[SF]" in result
|
||
|
||
def test_format_entry_t0_no_badge(self):
|
||
"""format_refractor_entry does not prepend any badge for T0 cards."""
|
||
state = {
|
||
"player_name": "Rookie Player",
|
||
"card_type": "batter",
|
||
"current_tier": 0,
|
||
"formula_value": 10,
|
||
"next_threshold": 50,
|
||
}
|
||
result = format_refractor_entry(state)
|
||
assert "[BC]" not in result
|
||
assert "[R]" not in result
|
||
assert "[GR]" not in result
|
||
assert "[SF]" not in result
|
||
|
||
def test_format_entry_badge_before_name(self, batter_state):
|
||
"""Badge appears before the player name in the bold section."""
|
||
result = format_refractor_entry(batter_state)
|
||
first_line = result.split("\n")[0]
|
||
badge_pos = first_line.find("[BC]")
|
||
name_pos = first_line.find("Mike Trout")
|
||
assert badge_pos < name_pos
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# apply_close_filter
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestApplyCloseFilter:
|
||
"""
|
||
Tests for apply_close_filter().
|
||
|
||
'Close' means formula_value >= 80% of next_threshold.
|
||
Fully evolved (T4 or no threshold) cards are excluded from results.
|
||
"""
|
||
|
||
def test_close_card_included(self):
|
||
"""Card at exactly 80% is included."""
|
||
state = {"current_tier": 1, "formula_value": 80, "next_threshold": 100}
|
||
assert apply_close_filter([state]) == [state]
|
||
|
||
def test_above_80_percent_included(self):
|
||
"""Card above 80% is included."""
|
||
state = {"current_tier": 0, "formula_value": 95, "next_threshold": 100}
|
||
assert apply_close_filter([state]) == [state]
|
||
|
||
def test_below_80_percent_excluded(self):
|
||
"""Card below 80% threshold is excluded."""
|
||
state = {"current_tier": 1, "formula_value": 79, "next_threshold": 100}
|
||
assert apply_close_filter([state]) == []
|
||
|
||
def test_fully_evolved_excluded(self):
|
||
"""T4 cards are never returned by close filter."""
|
||
state = {"current_tier": 4, "formula_value": 300, "next_threshold": None}
|
||
assert apply_close_filter([state]) == []
|
||
|
||
def test_none_threshold_excluded(self):
|
||
"""Cards with no next_threshold (regardless of tier) are excluded."""
|
||
state = {"current_tier": 3, "formula_value": 200, "next_threshold": None}
|
||
assert apply_close_filter([state]) == []
|
||
|
||
def test_mixed_list(self):
|
||
"""Only qualifying cards are returned from a mixed list."""
|
||
close = {"current_tier": 1, "formula_value": 90, "next_threshold": 100}
|
||
not_close = {"current_tier": 1, "formula_value": 50, "next_threshold": 100}
|
||
evolved = {"current_tier": 4, "formula_value": 300, "next_threshold": None}
|
||
result = apply_close_filter([close, not_close, evolved])
|
||
assert result == [close]
|
||
|
||
def test_empty_list(self):
|
||
"""Empty input returns empty list."""
|
||
assert apply_close_filter([]) == []
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# paginate
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestPaginate:
|
||
"""
|
||
Tests for paginate().
|
||
|
||
Verifies 1-indexed page slicing, total page count calculation,
|
||
page clamping, and PAGE_SIZE default.
|
||
"""
|
||
|
||
def _items(self, n):
|
||
return list(range(n))
|
||
|
||
def test_single_page_all_items(self):
|
||
"""Fewer items than page size returns all on page 1."""
|
||
items, total = paginate(self._items(5), page=1)
|
||
assert items == [0, 1, 2, 3, 4]
|
||
assert total == 1
|
||
|
||
def test_first_page(self):
|
||
"""Page 1 returns first PAGE_SIZE items."""
|
||
items, total = paginate(self._items(25), page=1)
|
||
assert items == list(range(10))
|
||
assert total == 3
|
||
|
||
def test_second_page(self):
|
||
"""Page 2 returns next PAGE_SIZE items."""
|
||
items, total = paginate(self._items(25), page=2)
|
||
assert items == list(range(10, 20))
|
||
|
||
def test_last_page_partial(self):
|
||
"""Last page returns remaining items (fewer than PAGE_SIZE)."""
|
||
items, total = paginate(self._items(25), page=3)
|
||
assert items == [20, 21, 22, 23, 24]
|
||
assert total == 3
|
||
|
||
def test_page_clamp_low(self):
|
||
"""Page 0 or negative is clamped to page 1."""
|
||
items, _ = paginate(self._items(15), page=0)
|
||
assert items == list(range(10))
|
||
|
||
def test_page_clamp_high(self):
|
||
"""Page beyond total is clamped to last page."""
|
||
items, total = paginate(self._items(15), page=99)
|
||
assert items == [10, 11, 12, 13, 14]
|
||
assert total == 2
|
||
|
||
def test_empty_list_returns_empty_page(self):
|
||
"""Empty input returns empty page with total_pages=1."""
|
||
items, total = paginate([], page=1)
|
||
assert items == []
|
||
assert total == 1
|
||
|
||
def test_exact_page_boundary(self):
|
||
"""Exactly PAGE_SIZE items → 1 full page."""
|
||
items, total = paginate(self._items(PAGE_SIZE), page=1)
|
||
assert len(items) == PAGE_SIZE
|
||
assert total == 1
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# TIER_NAMES
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestTierNames:
|
||
"""
|
||
Verify all tier display names are correctly defined.
|
||
|
||
T0=Base Card, T1=Base Chrome, T2=Refractor, T3=Gold Refractor, T4=Superfractor
|
||
"""
|
||
|
||
def test_t0_base_card(self):
|
||
assert TIER_NAMES[0] == "Base Card"
|
||
|
||
def test_t1_base_chrome(self):
|
||
assert TIER_NAMES[1] == "Base Chrome"
|
||
|
||
def test_t2_refractor(self):
|
||
assert TIER_NAMES[2] == "Refractor"
|
||
|
||
def test_t3_gold_refractor(self):
|
||
assert TIER_NAMES[3] == "Gold Refractor"
|
||
|
||
def test_t4_superfractor(self):
|
||
assert TIER_NAMES[4] == "Superfractor"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Slash command: empty roster / no-team scenarios
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.fixture
|
||
def mock_bot():
|
||
bot = AsyncMock(spec=commands.Bot)
|
||
return bot
|
||
|
||
|
||
@pytest.fixture
|
||
def mock_interaction():
|
||
interaction = AsyncMock(spec=discord.Interaction)
|
||
interaction.response = AsyncMock()
|
||
interaction.response.defer = AsyncMock()
|
||
interaction.edit_original_response = AsyncMock()
|
||
interaction.user = Mock()
|
||
interaction.user.id = 12345
|
||
return interaction
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# T1-6: TIER_NAMES duplication divergence check
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestTierNamesDivergenceCheck:
|
||
"""
|
||
T1-6: Assert that TIER_NAMES in cogs.refractor and helpers.refractor_notifs
|
||
are identical (same keys, same values).
|
||
|
||
Why: TIER_NAMES is duplicated in two modules. If one is updated and the
|
||
other is not (e.g. a tier is renamed or a new tier is added), tier labels
|
||
in the /refractor status embed and the tier-up notification embed will
|
||
diverge silently. This test acts as a divergence tripwire — it will fail
|
||
the moment the two copies fall out of sync, forcing an explicit fix.
|
||
"""
|
||
|
||
def test_tier_names_are_identical_across_modules(self):
|
||
"""
|
||
Import TIER_NAMES from both modules and assert deep equality.
|
||
|
||
The test imports the name at call-time rather than at module level to
|
||
ensure it always reads the current definition and is not affected by
|
||
module-level caching or monkeypatching in other tests.
|
||
"""
|
||
from cogs.refractor import TIER_NAMES as cog_tier_names
|
||
from helpers.refractor_notifs import TIER_NAMES as notifs_tier_names
|
||
|
||
assert cog_tier_names == notifs_tier_names, (
|
||
"TIER_NAMES differs between cogs.refractor and helpers.refractor_notifs. "
|
||
"Both copies must be kept in sync. "
|
||
f"cogs.refractor: {cog_tier_names!r} "
|
||
f"helpers.refractor_notifs: {notifs_tier_names!r}"
|
||
)
|
||
|
||
def test_tier_names_have_same_keys(self):
|
||
"""Keys (tier numbers) must be identical in both modules."""
|
||
from cogs.refractor import TIER_NAMES as cog_tier_names
|
||
from helpers.refractor_notifs import TIER_NAMES as notifs_tier_names
|
||
|
||
assert set(cog_tier_names.keys()) == set(notifs_tier_names.keys()), (
|
||
"TIER_NAMES key sets differ between modules."
|
||
)
|
||
|
||
def test_tier_names_have_same_values(self):
|
||
"""Display strings (values) must be identical for every shared key."""
|
||
from cogs.refractor import TIER_NAMES as cog_tier_names
|
||
from helpers.refractor_notifs import TIER_NAMES as notifs_tier_names
|
||
|
||
for tier, name in cog_tier_names.items():
|
||
assert notifs_tier_names.get(tier) == name, (
|
||
f"Tier {tier} name mismatch: "
|
||
f"cogs.refractor={name!r}, "
|
||
f"helpers.refractor_notifs={notifs_tier_names.get(tier)!r}"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# T2-8: Filter combination — tier=4 + progress="close" yields empty result
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestApplyCloseFilterWithAllT4Cards:
|
||
"""
|
||
T2-8: When all cards in the list are T4 (fully evolved), apply_close_filter
|
||
must return an empty list.
|
||
|
||
Why: T4 cards have no next tier to advance to, so they have no threshold.
|
||
The close filter explicitly excludes fully evolved cards (tier >= 4 or
|
||
next_threshold is None). If a user passes both tier=4 and progress="close"
|
||
to /refractor status, the combined result should be empty — the command
|
||
already handles this by showing "No cards are currently close to a tier
|
||
advancement." This test documents and protects that behaviour.
|
||
"""
|
||
|
||
def test_all_t4_cards_returns_empty(self):
|
||
"""
|
||
A list of T4-only card states should produce an empty result from
|
||
apply_close_filter, because T4 cards are fully evolved and have no
|
||
next threshold to be "close" to.
|
||
|
||
This is the intended behaviour when tier=4 and progress="close" are
|
||
combined: there are no qualifying cards, and the command should show
|
||
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},
|
||
]
|
||
result = apply_close_filter(t4_cards)
|
||
assert result == [], (
|
||
"apply_close_filter must return [] for fully evolved T4 cards — "
|
||
"they have no next threshold and cannot be 'close' to advancement."
|
||
)
|
||
|
||
def test_t4_cards_excluded_even_with_high_formula_value(self):
|
||
"""
|
||
T4 cards are excluded regardless of their formula_value, since the
|
||
filter is based on tier (>= 4) and threshold (None), not raw values.
|
||
"""
|
||
t4_high_value = {
|
||
"current_tier": 4,
|
||
"formula_value": 9999,
|
||
"next_threshold": None,
|
||
}
|
||
assert apply_close_filter([t4_high_value]) == []
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# T3-2: Malformed API response handling in format_refractor_entry
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestFormatRefractorEntryMalformedInput:
|
||
"""
|
||
T3-2: format_refractor_entry should not crash when given a card state dict
|
||
that is missing expected keys.
|
||
|
||
Why: API responses can be incomplete due to migration states, partially
|
||
populated records, or future schema changes. format_refractor_entry uses
|
||
.get() with fallbacks for all keys, so missing fields should gracefully
|
||
degrade to sensible defaults ("Unknown" for name, 0 for values) rather than
|
||
raising a KeyError or TypeError.
|
||
"""
|
||
|
||
def test_missing_player_name_uses_unknown(self):
|
||
"""
|
||
When player_name is absent, the output should contain "Unknown" rather
|
||
than crashing with a KeyError.
|
||
"""
|
||
state = {
|
||
"card_type": "batter",
|
||
"current_tier": 1,
|
||
"formula_value": 100,
|
||
"next_threshold": 150,
|
||
}
|
||
result = format_refractor_entry(state)
|
||
assert "Unknown" in result
|
||
|
||
def test_missing_formula_value_uses_zero(self):
|
||
"""
|
||
When formula_value is absent, the progress calculation should use 0
|
||
without raising a TypeError.
|
||
"""
|
||
state = {
|
||
"player_name": "Test Player",
|
||
"card_type": "batter",
|
||
"current_tier": 1,
|
||
"next_threshold": 150,
|
||
}
|
||
result = format_refractor_entry(state)
|
||
assert "0/150" in result
|
||
|
||
def test_completely_empty_dict_does_not_crash(self):
|
||
"""
|
||
An entirely empty dict should produce a valid (if sparse) string using
|
||
all fallback values, not raise any exception.
|
||
"""
|
||
result = format_refractor_entry({})
|
||
# Should not raise; output should be a string with two lines
|
||
assert isinstance(result, str)
|
||
lines = result.split("\n")
|
||
assert len(lines) == 2
|
||
|
||
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.
|
||
"""
|
||
state = {
|
||
"player_name": "Test Player",
|
||
"current_tier": 1,
|
||
"formula_value": 50,
|
||
"next_threshold": 100,
|
||
}
|
||
result = format_refractor_entry(state)
|
||
assert "PA+TB×2" in result
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# T3-3: Progress bar boundary precision
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestRenderProgressBarBoundaryPrecision:
|
||
"""
|
||
T3-3: Verify the progress bar behaves correctly at edge values — near zero,
|
||
near full, exactly at extremes, and for negative input.
|
||
|
||
Why: Off-by-one errors in rounding or integer truncation can make a nearly-
|
||
full bar look full (or vice versa), confusing users about how close their
|
||
card is to a tier advancement. Defensive handling of negative values ensures
|
||
no bar is rendered longer than its declared width.
|
||
"""
|
||
|
||
def test_one_of_hundred_shows_mostly_empty(self):
|
||
"""
|
||
1/100 = 1% — should produce a bar with 0 or 1 filled segment and the
|
||
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("=")
|
||
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.
|
||
"""
|
||
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}"
|
||
)
|
||
# But it must not overflow the bar width
|
||
assert len(interior) == 10
|
||
|
||
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) == "[----------]"
|
||
|
||
def test_negative_current_does_not_overflow_bar(self):
|
||
"""
|
||
A negative formula_value (data anomaly) must not produce a bar with
|
||
more filled segments than the width. The min(..., 1.0) clamp in
|
||
render_progress_bar should handle this, but this test guards against
|
||
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("=")
|
||
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
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# T3-4: RP formula label
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestRPFormulaLabel:
|
||
"""
|
||
T3-4: Verify that relief pitchers (card_type="rp") show the "IP+K" formula
|
||
label in format_refractor_entry output.
|
||
|
||
Why: FORMULA_LABELS maps both "sp" and "rp" to "IP+K". The existing test
|
||
suite only verifies "sp" (via the sp_state fixture). Adding "rp" explicitly
|
||
prevents a future refactor from accidentally giving RPs a different label
|
||
or falling through to the raw card_type fallback.
|
||
"""
|
||
|
||
def test_rp_formula_label_is_ip_plus_k(self):
|
||
"""
|
||
A card with card_type="rp" must show "IP+K" as the formula label
|
||
in its progress line.
|
||
"""
|
||
rp_state = {
|
||
"player_name": "Edwin Diaz",
|
||
"card_type": "rp",
|
||
"current_tier": 1,
|
||
"formula_value": 45,
|
||
"next_threshold": 60,
|
||
}
|
||
result = format_refractor_entry(rp_state)
|
||
assert "IP+K" in result, (
|
||
f"Relief pitcher card should show 'IP+K' formula label, got: {result!r}"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# T3-5: Unknown card_type fallback
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestUnknownCardTypeFallback:
|
||
"""
|
||
T3-5: format_refractor_entry should use the raw card_type string as the
|
||
formula label when the type is not in FORMULA_LABELS, rather than crashing.
|
||
|
||
Why: FORMULA_LABELS only covers "batter", "sp", and "rp". If the API
|
||
introduces a new card type (e.g. "util" for utility players) before the
|
||
bot is updated, FORMULA_LABELS.get(card_type, card_type) will fall back to
|
||
the raw string. This test ensures that fallback path produces readable
|
||
output rather than an error, and explicitly documents what to expect.
|
||
"""
|
||
|
||
def test_unknown_card_type_uses_raw_string_as_label(self):
|
||
"""
|
||
card_type="util" is not in FORMULA_LABELS. The output should include
|
||
"util" as the formula label (the raw fallback) and must not raise.
|
||
"""
|
||
util_state = {
|
||
"player_name": "Ben Zobrist",
|
||
"card_type": "util",
|
||
"current_tier": 2,
|
||
"formula_value": 80,
|
||
"next_threshold": 120,
|
||
}
|
||
result = format_refractor_entry(util_state)
|
||
assert "util" in result, (
|
||
f"Unknown card_type should appear verbatim as the formula label, got: {result!r}"
|
||
)
|
||
|
||
def test_unknown_card_type_does_not_crash(self):
|
||
"""
|
||
Any unknown card_type must produce a valid two-line string without
|
||
raising an exception.
|
||
"""
|
||
state = {
|
||
"player_name": "Test Player",
|
||
"card_type": "dh",
|
||
"current_tier": 1,
|
||
"formula_value": 30,
|
||
"next_threshold": 50,
|
||
}
|
||
result = format_refractor_entry(state)
|
||
assert isinstance(result, str)
|
||
assert len(result.split("\n")) == 2
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Slash command: empty roster / no-team scenarios
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_refractor_status_no_team(mock_bot, mock_interaction):
|
||
"""
|
||
When the user has no team, the command replies with a signup prompt
|
||
and does not call db_get.
|
||
|
||
Why: get_team_by_owner returning None means the user is unregistered;
|
||
the command must short-circuit before hitting the API.
|
||
"""
|
||
from cogs.refractor import Refractor
|
||
|
||
cog = Refractor(mock_bot)
|
||
|
||
with patch("cogs.refractor.get_team_by_owner", new=AsyncMock(return_value=None)):
|
||
with patch("cogs.refractor.db_get", new=AsyncMock()) as mock_db:
|
||
await cog.refractor_status.callback(cog, mock_interaction)
|
||
mock_db.assert_not_called()
|
||
|
||
call_kwargs = mock_interaction.edit_original_response.call_args
|
||
content = call_kwargs.kwargs.get("content", "")
|
||
assert "newteam" in content.lower() or "team" in content.lower()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_refractor_status_empty_roster(mock_bot, mock_interaction):
|
||
"""
|
||
When the API returns an empty card list, the command sends an
|
||
informative 'no data' message rather than an empty embed.
|
||
|
||
Why: An empty list is valid (team has no refractor cards yet);
|
||
the command should not crash or send a blank embed.
|
||
"""
|
||
from cogs.refractor import Refractor
|
||
|
||
cog = Refractor(mock_bot)
|
||
team = {"id": 1, "sname": "Test"}
|
||
|
||
with patch("cogs.refractor.get_team_by_owner", new=AsyncMock(return_value=team)):
|
||
with patch("cogs.refractor.db_get", new=AsyncMock(return_value={"cards": []})):
|
||
await cog.refractor_status.callback(cog, mock_interaction)
|
||
|
||
call_kwargs = mock_interaction.edit_original_response.call_args
|
||
content = call_kwargs.kwargs.get("content", "")
|
||
assert "no refractor data" in content.lower()
|