paper-dynasty-discord/tests/test_refractor_progress_embed.py
Cal Corum 39424f7157
All checks were successful
Ruff Lint / lint (pull_request) Successful in 24s
feat: show refractor progress in post-game summary embed (#147)
Closes #147

Adds a "Refractor Progress" field to the game summary embed showing:
- Cards that tiered up during this game (⬆ Name → Tier Name)
- Cards currently ≥80% toward their next tier on either team (◈ Name (pct%))

The field is omitted entirely when there is nothing to show.

Implementation:
- _run_post_game_refractor_hook() now returns evo_result (or None on failure)
- New _build_refractor_progress_text() fetches close-to-tier cards from both
  teams via refractor/cards?progress=close and formats the combined output
- complete_game() adds the field between Rewards and Highlights sections

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 13:36:32 -05:00

258 lines
9.3 KiB
Python

"""
Tests for the post-game Refractor Progress embed field (#147).
Covers _build_refractor_progress_text() which formats tier-ups and
near-threshold cards into the summary embed field value, and the updated
_run_post_game_refractor_hook() return value.
"""
from unittest.mock import AsyncMock, MagicMock, patch
from command_logic.logic_gameplay import (
_build_refractor_progress_text,
_run_post_game_refractor_hook,
)
# ---------------------------------------------------------------------------
# _build_refractor_progress_text
# ---------------------------------------------------------------------------
class TestBuildRefractorProgressText:
"""_build_refractor_progress_text formats tier-ups and close cards."""
async def test_returns_none_when_no_tier_ups_and_no_close_cards(self):
"""Returns None when evaluate-game had no tier-ups and refractor/cards returns empty.
Caller uses None to skip adding the field to the embed entirely.
"""
with patch(
"command_logic.logic_gameplay.db_get", new_callable=AsyncMock
) as mock_get:
mock_get.return_value = {"items": [], "count": 0}
result = await _build_refractor_progress_text(
evo_result={"tier_ups": []},
winning_team_id=1,
losing_team_id=2,
)
assert result is None
async def test_returns_none_when_evo_result_is_none(self):
"""Returns None gracefully when the hook returned None (e.g. on API failure).
Near-threshold fetch still runs; returns None when that also yields nothing.
"""
with patch(
"command_logic.logic_gameplay.db_get", new_callable=AsyncMock
) as mock_get:
mock_get.return_value = None
result = await _build_refractor_progress_text(
evo_result=None,
winning_team_id=1,
losing_team_id=2,
)
assert result is None
async def test_tier_up_shows_player_name_and_tier_name(self):
"""Tier-ups are formatted as '⬆ **Name** → Tier Name'.
The tier name comes from TIER_NAMES (e.g. new_tier=1 → 'Base Chrome').
"""
with patch(
"command_logic.logic_gameplay.db_get", new_callable=AsyncMock
) as mock_get:
mock_get.return_value = None
result = await _build_refractor_progress_text(
evo_result={
"tier_ups": [
{
"player_id": 10,
"player_name": "Mike Trout",
"new_tier": 1,
}
]
},
winning_team_id=1,
losing_team_id=2,
)
assert result is not None
assert "" in result
assert "Mike Trout" in result
assert "Base Chrome" in result
async def test_multiple_tier_ups_each_on_own_line(self):
"""Each tier-up gets its own line in the output."""
with patch(
"command_logic.logic_gameplay.db_get", new_callable=AsyncMock
) as mock_get:
mock_get.return_value = None
result = await _build_refractor_progress_text(
evo_result={
"tier_ups": [
{"player_id": 10, "player_name": "Mike Trout", "new_tier": 1},
{
"player_id": 11,
"player_name": "Shohei Ohtani",
"new_tier": 2,
},
]
},
winning_team_id=1,
losing_team_id=2,
)
assert result is not None
assert "Mike Trout" in result
assert "Shohei Ohtani" in result
assert "Base Chrome" in result
assert "Refractor" in result
async def test_near_threshold_card_shows_percentage(self):
"""Near-threshold cards appear as '◈ Name (pct%)'.
The percentage is current_value / next_threshold rounded to nearest integer.
"""
with patch(
"command_logic.logic_gameplay.db_get", new_callable=AsyncMock
) as mock_get:
mock_get.return_value = {
"items": [
{
"player_name": "Sandy Koufax",
"current_value": 120,
"next_threshold": 149,
}
],
"count": 1,
}
result = await _build_refractor_progress_text(
evo_result=None,
winning_team_id=1,
losing_team_id=2,
)
assert result is not None
assert "" in result
assert "Sandy Koufax" in result
assert "81%" in result # 120/149 = ~80.5% → 81%
async def test_near_threshold_fetch_queried_for_both_teams(self):
"""refractor/cards is called once per team with progress=close."""
with patch(
"command_logic.logic_gameplay.db_get", new_callable=AsyncMock
) as mock_get:
mock_get.return_value = None
await _build_refractor_progress_text(
evo_result=None,
winning_team_id=3,
losing_team_id=7,
)
team_ids_queried = []
for call in mock_get.call_args_list:
params = dict(call.kwargs.get("params", []))
if "team_id" in params:
team_ids_queried.append(params["team_id"])
assert 3 in team_ids_queried
assert 7 in team_ids_queried
async def test_near_threshold_api_failure_is_non_fatal(self):
"""An exception during the near-threshold fetch does not propagate.
Tier-ups are still shown; close cards silently dropped.
"""
with patch(
"command_logic.logic_gameplay.db_get", new_callable=AsyncMock
) as mock_get:
mock_get.side_effect = RuntimeError("API down")
result = await _build_refractor_progress_text(
evo_result={
"tier_ups": [
{"player_id": 10, "player_name": "Mike Trout", "new_tier": 1}
]
},
winning_team_id=1,
losing_team_id=2,
)
assert result is not None
assert "Mike Trout" in result
async def test_close_cards_capped_at_five(self):
"""At most 5 near-threshold entries are included across both teams."""
many_cards = [
{"player_name": f"Player {i}", "current_value": 90, "next_threshold": 100}
for i in range(10)
]
with patch(
"command_logic.logic_gameplay.db_get", new_callable=AsyncMock
) as mock_get:
mock_get.return_value = {"items": many_cards, "count": len(many_cards)}
result = await _build_refractor_progress_text(
evo_result=None,
winning_team_id=1,
losing_team_id=2,
)
assert result is not None
assert result.count("") <= 5
# ---------------------------------------------------------------------------
# _run_post_game_refractor_hook return value
# ---------------------------------------------------------------------------
class TestRefractorHookReturnValue:
"""_run_post_game_refractor_hook returns evo_result on success, None on failure."""
async def test_returns_evo_result_when_successful(self):
"""The evaluate-game response dict is returned so complete_game can use it."""
evo_response = {
"tier_ups": [{"player_id": 1, "player_name": "Babe Ruth", "new_tier": 2}]
}
def _side_effect(url, *args, **kwargs):
if url.startswith("season-stats"):
return None
return evo_response
with (
patch(
"command_logic.logic_gameplay.db_post", new_callable=AsyncMock
) as mock_post,
patch(
"command_logic.logic_gameplay._trigger_variant_renders",
new_callable=AsyncMock,
return_value={},
),
patch(
"command_logic.logic_gameplay.notify_tier_completion",
new_callable=AsyncMock,
),
):
mock_post.side_effect = _side_effect
result = await _run_post_game_refractor_hook(42, MagicMock())
assert result == evo_response
async def test_returns_none_on_exception(self):
"""Hook returns None when an exception occurs (game result is unaffected)."""
with patch(
"command_logic.logic_gameplay.db_post", new_callable=AsyncMock
) as mock_post:
mock_post.side_effect = RuntimeError("db unreachable")
result = await _run_post_game_refractor_hook(42, MagicMock())
assert result is None
async def test_returns_evo_result_when_no_tier_ups(self):
"""Returns the full evo_result even when tier_ups is empty or absent."""
evo_response = {"tier_ups": []}
with patch(
"command_logic.logic_gameplay.db_post", new_callable=AsyncMock
) as mock_post:
mock_post.return_value = evo_response
result = await _run_post_game_refractor_hook(42, MagicMock())
assert result == evo_response