All checks were successful
Ruff Lint / lint (pull_request) Successful in 20s
- Tier labels as suffix tags: **Name** — Base Chrome [T1] (T0 gets no suffix) - Compact progress line: bar value/threshold (pct) — removed formula and tier arrow - Fully evolved shows `MAX` instead of FULLY EVOLVED - Deleted unused FORMULA_LABELS dict - Added _FULL_BAR constant, moved T0-branch lookups into else - Fixed mock API shape in test (cards → items) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
741 lines
26 KiB
Python
741 lines
26 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_SYMBOLS,
|
|
PAGE_SIZE,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def batter_state():
|
|
"""A mid-progress batter card state (API response shape)."""
|
|
return {
|
|
"player_name": "Mike Trout",
|
|
"track": {"card_type": "batter", "formula": "pa + tb * 2"},
|
|
"current_tier": 1,
|
|
"current_value": 120,
|
|
"next_threshold": 149,
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def evolved_state():
|
|
"""A fully evolved card state (T4)."""
|
|
return {
|
|
"player_name": "Shohei Ohtani",
|
|
"track": {"card_type": "batter", "formula": "pa + tb * 2"},
|
|
"current_tier": 4,
|
|
"current_value": 300,
|
|
"next_threshold": None,
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def sp_state():
|
|
"""A starting pitcher card state at T2."""
|
|
return {
|
|
"player_name": "Sandy Alcantara",
|
|
"track": {"card_type": "sp", "formula": "ip + k"},
|
|
"current_tier": 2,
|
|
"current_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. Default width is 12.
|
|
Uses Unicode block chars: ▰ (filled) and ▱ (empty).
|
|
"""
|
|
|
|
def test_empty_bar(self):
|
|
"""current=0 → all empty blocks."""
|
|
assert render_progress_bar(0, 100) == "▱" * 12
|
|
|
|
def test_full_bar(self):
|
|
"""current == threshold → all filled blocks."""
|
|
assert render_progress_bar(100, 100) == "▰" * 12
|
|
|
|
def test_partial_fill(self):
|
|
"""120/149 ≈ 80.5% → ~10 filled of 12."""
|
|
bar = render_progress_bar(120, 149)
|
|
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% → 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) == "▰" * 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) == "▰" * 12
|
|
|
|
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_percentage_in_output(self, batter_state):
|
|
"""Percentage appears in parentheses in output."""
|
|
result = format_refractor_entry(batter_state)
|
|
assert "(80%)" in result or "(81%)" in result
|
|
|
|
def test_fully_evolved_no_threshold(self, evolved_state):
|
|
"""T4 card with next_threshold=None shows MAX."""
|
|
result = format_refractor_entry(evolved_state)
|
|
assert "`MAX`" 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 "`MAX`" 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 TestTierSymbols:
|
|
"""
|
|
Verify TIER_SYMBOLS values and that format_refractor_entry prepends
|
|
the correct label for each tier. Labels use short readable text (T0-T4).
|
|
"""
|
|
|
|
def test_t0_symbol(self):
|
|
"""T0 label is empty (base cards get no prefix)."""
|
|
assert TIER_SYMBOLS[0] == "Base"
|
|
|
|
def test_t1_symbol(self):
|
|
"""T1 label is 'T1'."""
|
|
assert TIER_SYMBOLS[1] == "T1"
|
|
|
|
def test_t2_symbol(self):
|
|
"""T2 label is 'T2'."""
|
|
assert TIER_SYMBOLS[2] == "T2"
|
|
|
|
def test_t3_symbol(self):
|
|
"""T3 label is 'T3'."""
|
|
assert TIER_SYMBOLS[3] == "T3"
|
|
|
|
def test_t4_symbol(self):
|
|
"""T4 label is 'T4★'."""
|
|
assert TIER_SYMBOLS[4] == "T4★"
|
|
|
|
def test_format_entry_t1_suffix_tag(self, batter_state):
|
|
"""T1 cards show [T1] suffix tag after the tier name."""
|
|
result = format_refractor_entry(batter_state)
|
|
assert "[T1]" in result
|
|
|
|
def test_format_entry_t2_suffix_tag(self, sp_state):
|
|
"""T2 cards show [T2] suffix tag."""
|
|
result = format_refractor_entry(sp_state)
|
|
assert "[T2]" in result
|
|
|
|
def test_format_entry_t4_suffix_tag(self, evolved_state):
|
|
"""T4 cards show [T4★] suffix tag."""
|
|
result = format_refractor_entry(evolved_state)
|
|
assert "[T4★]" in result
|
|
|
|
def test_format_entry_t0_name_only(self):
|
|
"""T0 cards show just the bold name, no tier suffix."""
|
|
state = {
|
|
"player_name": "Rookie Player",
|
|
"current_tier": 0,
|
|
"current_value": 10,
|
|
"next_threshold": 50,
|
|
}
|
|
result = format_refractor_entry(state)
|
|
first_line = result.split("\n")[0]
|
|
assert first_line == "**Rookie Player**"
|
|
|
|
def test_format_entry_tag_after_name(self, batter_state):
|
|
"""Tag appears after the player name in the first line."""
|
|
result = format_refractor_entry(batter_state)
|
|
first_line = result.split("\n")[0]
|
|
name_pos = first_line.find("Mike Trout")
|
|
tag_pos = first_line.find("[T1]")
|
|
assert name_pos < tag_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, "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, "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, "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, "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, "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, "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]
|
|
|
|
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, "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 == [], (
|
|
"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,
|
|
"current_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 = {
|
|
"track": {"card_type": "batter"},
|
|
"current_tier": 1,
|
|
"current_value": 100,
|
|
"next_threshold": 150,
|
|
}
|
|
result = format_refractor_entry(state)
|
|
assert "Unknown" in result
|
|
|
|
def test_missing_formula_value_uses_zero(self):
|
|
"""
|
|
When current_value is absent, the progress calculation should use 0
|
|
without raising a TypeError.
|
|
"""
|
|
state = {
|
|
"player_name": "Test Player",
|
|
"track": {"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_does_not_crash(self):
|
|
"""
|
|
When card_type is absent from the track, the code should still
|
|
produce a valid two-line output without crashing.
|
|
"""
|
|
state = {
|
|
"player_name": "Test Player",
|
|
"current_tier": 1,
|
|
"current_value": 50,
|
|
"next_threshold": 100,
|
|
}
|
|
result = format_refractor_entry(state)
|
|
assert "50/100" 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)
|
|
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 11 or 12 filled segments.
|
|
The bar must NOT be completely empty or show fewer than 11 filled.
|
|
"""
|
|
bar = render_progress_bar(99, 100)
|
|
filled_count = bar.count("▰")
|
|
assert filled_count >= 11, (
|
|
f"99/100 should show 11 or 12 filled segments, got {filled_count}: {bar!r}"
|
|
)
|
|
# Bar width must be exactly 12
|
|
assert len(bar) == 12
|
|
|
|
def test_zero_of_hundred_is_completely_empty(self):
|
|
"""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):
|
|
"""
|
|
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)
|
|
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 12
|
|
assert len(bar) == 12
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# T3-4: RP formula label
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCardTypeVariants:
|
|
"""
|
|
T3-4/T3-5: Verify that format_refractor_entry produces valid output for
|
|
all card types including unknown ones, without crashing.
|
|
"""
|
|
|
|
def test_rp_card_produces_valid_output(self):
|
|
"""Relief pitcher card produces a valid two-line string."""
|
|
rp_state = {
|
|
"player_name": "Edwin Diaz",
|
|
"track": {"card_type": "rp"},
|
|
"current_tier": 1,
|
|
"current_value": 45,
|
|
"next_threshold": 60,
|
|
}
|
|
result = format_refractor_entry(rp_state)
|
|
assert "Edwin Diaz" in result
|
|
assert "45/60" in result
|
|
|
|
def test_unknown_card_type_does_not_crash(self):
|
|
"""Unknown card_type produces a valid two-line string."""
|
|
state = {
|
|
"player_name": "Test Player",
|
|
"track": {"card_type": "dh"},
|
|
"current_tier": 1,
|
|
"current_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={"items": [], "count": 0}),
|
|
):
|
|
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()
|