""" 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()