Merge pull request 'feat: WP-13 post-game evolution callback hook' (#111) from feature/wp13-postgame-hook into main
This commit is contained in:
commit
aa2fce94b8
@ -4242,6 +4242,24 @@ async def get_game_summary_embed(
|
|||||||
return game_embed
|
return game_embed
|
||||||
|
|
||||||
|
|
||||||
|
async def notify_tier_completion(channel: discord.TextChannel, tier_up: dict) -> None:
|
||||||
|
"""Stub for WP-14: log evolution tier-up events.
|
||||||
|
|
||||||
|
WP-14 will replace this with a full Discord embed notification. For now we
|
||||||
|
only log the event so that the WP-13 hook has a callable target and the
|
||||||
|
tier-up data is visible in the application log.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
channel: The Discord channel where the game was played.
|
||||||
|
tier_up: Dict from the evolution API, expected to contain at minimum
|
||||||
|
'player_id', 'old_tier', and 'new_tier' keys.
|
||||||
|
"""
|
||||||
|
logger.info(
|
||||||
|
f"[WP-14 stub] notify_tier_completion called for channel={channel.id if channel else 'N/A'} "
|
||||||
|
f"tier_up={tier_up}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def complete_game(
|
async def complete_game(
|
||||||
session: Session,
|
session: Session,
|
||||||
interaction: discord.Interaction,
|
interaction: discord.Interaction,
|
||||||
@ -4318,7 +4336,6 @@ async def complete_game(
|
|||||||
await roll_back(db_game["id"], plays=True, decisions=True)
|
await roll_back(db_game["id"], plays=True, decisions=True)
|
||||||
log_exception(e, msg="Unable to post decisions to API, rolling back")
|
log_exception(e, msg="Unable to post decisions to API, rolling back")
|
||||||
|
|
||||||
|
|
||||||
# Post game rewards (gauntlet and main team)
|
# Post game rewards (gauntlet and main team)
|
||||||
try:
|
try:
|
||||||
win_reward, loss_reward = await post_game_rewards(
|
win_reward, loss_reward = await post_game_rewards(
|
||||||
@ -4342,6 +4359,26 @@ async def complete_game(
|
|||||||
await roll_back(db_game["id"], plays=True, decisions=True)
|
await roll_back(db_game["id"], plays=True, decisions=True)
|
||||||
log_exception(e, msg="Error while posting game rewards")
|
log_exception(e, msg="Error while posting game rewards")
|
||||||
|
|
||||||
|
# Post-game refractor processing (non-blocking)
|
||||||
|
# WP-13: update season stats then evaluate refractor milestones for all
|
||||||
|
# participating players. Wrapped in try/except so any failure here is
|
||||||
|
# non-fatal — the game is already saved and refractor will catch up on the
|
||||||
|
# next evaluate call.
|
||||||
|
try:
|
||||||
|
await db_post(f"season-stats/update-game/{db_game['id']}")
|
||||||
|
evo_result = await db_post(f"refractor/evaluate-game/{db_game['id']}")
|
||||||
|
if evo_result and evo_result.get("tier_ups"):
|
||||||
|
for tier_up in evo_result["tier_ups"]:
|
||||||
|
# WP-14 will implement full Discord notification; stub for now
|
||||||
|
logger.info(
|
||||||
|
f"Refractor tier-up for player {tier_up.get('player_id')}: "
|
||||||
|
f"{tier_up.get('old_tier')} -> {tier_up.get('new_tier')} "
|
||||||
|
f"(game {db_game['id']})"
|
||||||
|
)
|
||||||
|
await notify_tier_completion(interaction.channel, tier_up)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Post-game refractor processing failed (non-fatal): {e}")
|
||||||
|
|
||||||
session.delete(this_play)
|
session.delete(this_play)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
|||||||
201
tests/test_complete_game_hook.py
Normal file
201
tests/test_complete_game_hook.py
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
"""
|
||||||
|
Tests for the WP-13 post-game callback integration hook.
|
||||||
|
|
||||||
|
These tests verify that after a game is saved to the API, two additional
|
||||||
|
POST requests are fired in the correct order:
|
||||||
|
1. POST season-stats/update-game/{game_id} — update player_season_stats
|
||||||
|
2. POST refractor/evaluate-game/{game_id} — evaluate refractor milestones
|
||||||
|
|
||||||
|
Key design constraints being tested:
|
||||||
|
- Season stats MUST be updated before refractor is evaluated (ordering).
|
||||||
|
- Failure of either refractor call must NOT propagate — the game result has
|
||||||
|
already been committed; refractor will self-heal on the next evaluate pass.
|
||||||
|
- Tier-up dicts returned by the refractor endpoint are passed to
|
||||||
|
notify_tier_completion so WP-14 can present them to the player.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, call, patch
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_channel(channel_id: int = 999) -> MagicMock:
|
||||||
|
ch = MagicMock()
|
||||||
|
ch.id = channel_id
|
||||||
|
return ch
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_hook(db_post_mock, db_game_id: int = 42):
|
||||||
|
"""
|
||||||
|
Execute the post-game hook in isolation.
|
||||||
|
|
||||||
|
We import the hook logic inline rather than calling the full
|
||||||
|
complete_game() function (which requires a live DB session, Discord
|
||||||
|
interaction, and Play object). The hook is a self-contained try/except
|
||||||
|
block so we replicate it verbatim here to test its behaviour.
|
||||||
|
"""
|
||||||
|
channel = _make_channel()
|
||||||
|
from command_logic.logic_gameplay import notify_tier_completion
|
||||||
|
|
||||||
|
db_game = {"id": db_game_id}
|
||||||
|
|
||||||
|
try:
|
||||||
|
await db_post_mock(f"season-stats/update-game/{db_game['id']}")
|
||||||
|
evo_result = await db_post_mock(f"refractor/evaluate-game/{db_game['id']}")
|
||||||
|
if evo_result and evo_result.get("tier_ups"):
|
||||||
|
for tier_up in evo_result["tier_ups"]:
|
||||||
|
await notify_tier_completion(channel, tier_up)
|
||||||
|
except Exception:
|
||||||
|
pass # non-fatal — mirrors the logger.warning in production
|
||||||
|
|
||||||
|
return channel
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hook_posts_to_both_endpoints_in_order():
|
||||||
|
"""
|
||||||
|
Both refractor endpoints are called, and season-stats comes first.
|
||||||
|
|
||||||
|
The ordering is critical: player_season_stats must be populated before the
|
||||||
|
refractor engine tries to read them for milestone evaluation.
|
||||||
|
"""
|
||||||
|
db_post_mock = AsyncMock(return_value={})
|
||||||
|
|
||||||
|
await _run_hook(db_post_mock, db_game_id=42)
|
||||||
|
|
||||||
|
assert db_post_mock.call_count == 2
|
||||||
|
calls = db_post_mock.call_args_list
|
||||||
|
# First call must be season-stats
|
||||||
|
assert calls[0] == call("season-stats/update-game/42")
|
||||||
|
# Second call must be refractor evaluate
|
||||||
|
assert calls[1] == call("refractor/evaluate-game/42")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hook_is_nonfatal_when_db_post_raises():
|
||||||
|
"""
|
||||||
|
A failure inside the hook must not raise to the caller.
|
||||||
|
|
||||||
|
The game result is already persisted when the hook runs. If the refractor
|
||||||
|
API is down or returns an error, we log a warning and continue — the game
|
||||||
|
completion flow must not be interrupted.
|
||||||
|
"""
|
||||||
|
db_post_mock = AsyncMock(side_effect=Exception("refractor API unavailable"))
|
||||||
|
|
||||||
|
# Should not raise
|
||||||
|
try:
|
||||||
|
await _run_hook(db_post_mock, db_game_id=7)
|
||||||
|
except Exception as exc:
|
||||||
|
pytest.fail(f"Hook raised unexpectedly: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hook_processes_tier_ups_from_evo_result():
|
||||||
|
"""
|
||||||
|
When the refractor endpoint returns tier_ups, each entry is forwarded to
|
||||||
|
notify_tier_completion.
|
||||||
|
|
||||||
|
This confirms the data path between the API response and the WP-14
|
||||||
|
notification stub so that WP-14 only needs to replace the stub body.
|
||||||
|
"""
|
||||||
|
tier_ups = [
|
||||||
|
{"player_id": 101, "old_tier": 1, "new_tier": 2},
|
||||||
|
{"player_id": 202, "old_tier": 2, "new_tier": 3},
|
||||||
|
]
|
||||||
|
|
||||||
|
async def fake_db_post(endpoint):
|
||||||
|
if "refractor" in endpoint:
|
||||||
|
return {"tier_ups": tier_ups}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
db_post_mock = AsyncMock(side_effect=fake_db_post)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"command_logic.logic_gameplay.notify_tier_completion",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
) as mock_notify:
|
||||||
|
channel = _make_channel()
|
||||||
|
db_game = {"id": 99}
|
||||||
|
|
||||||
|
try:
|
||||||
|
await db_post_mock(f"season-stats/update-game/{db_game['id']}")
|
||||||
|
evo_result = await db_post_mock(f"refractor/evaluate-game/{db_game['id']}")
|
||||||
|
if evo_result and evo_result.get("tier_ups"):
|
||||||
|
for tier_up in evo_result["tier_ups"]:
|
||||||
|
await mock_notify(channel, tier_up)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert mock_notify.call_count == 2
|
||||||
|
# Verify both tier_up dicts were forwarded
|
||||||
|
forwarded = [c.args[1] for c in mock_notify.call_args_list]
|
||||||
|
assert {"player_id": 101, "old_tier": 1, "new_tier": 2} in forwarded
|
||||||
|
assert {"player_id": 202, "old_tier": 2, "new_tier": 3} in forwarded
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hook_no_tier_ups_does_not_call_notify():
|
||||||
|
"""
|
||||||
|
When the refractor response has no tier_ups (empty list or missing key),
|
||||||
|
notify_tier_completion is never called.
|
||||||
|
|
||||||
|
Avoids spurious Discord messages for routine game completions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def fake_db_post(endpoint):
|
||||||
|
if "refractor" in endpoint:
|
||||||
|
return {"tier_ups": []}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
db_post_mock = AsyncMock(side_effect=fake_db_post)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"command_logic.logic_gameplay.notify_tier_completion",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
) as mock_notify:
|
||||||
|
channel = _make_channel()
|
||||||
|
db_game = {"id": 55}
|
||||||
|
|
||||||
|
try:
|
||||||
|
await db_post_mock(f"season-stats/update-game/{db_game['id']}")
|
||||||
|
evo_result = await db_post_mock(f"refractor/evaluate-game/{db_game['id']}")
|
||||||
|
if evo_result and evo_result.get("tier_ups"):
|
||||||
|
for tier_up in evo_result["tier_ups"]:
|
||||||
|
await mock_notify(channel, tier_up)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
mock_notify.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_notify_tier_completion_stub_logs_and_does_not_raise(caplog):
|
||||||
|
"""
|
||||||
|
The WP-14 stub must log the event and return cleanly.
|
||||||
|
|
||||||
|
Verifies the contract that WP-14 can rely on: the function accepts
|
||||||
|
(channel, tier_up) and does not raise, so the hook's for-loop is safe.
|
||||||
|
"""
|
||||||
|
from command_logic.logic_gameplay import notify_tier_completion
|
||||||
|
|
||||||
|
channel = _make_channel(channel_id=123)
|
||||||
|
tier_up = {"player_id": 77, "old_tier": 0, "new_tier": 1}
|
||||||
|
|
||||||
|
with caplog.at_level(logging.INFO):
|
||||||
|
await notify_tier_completion(channel, tier_up)
|
||||||
|
|
||||||
|
# At minimum one log message should reference the channel or tier_up data
|
||||||
|
assert any(
|
||||||
|
"notify_tier_completion" in rec.message or "77" in rec.message
|
||||||
|
for rec in caplog.records
|
||||||
|
)
|
||||||
@ -1,154 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for evolution tier completion notification embeds (WP-14).
|
|
||||||
|
|
||||||
These are pure unit tests — no database or Discord bot connection required.
|
|
||||||
Each test constructs embeds and asserts on title, description, color, and
|
|
||||||
footer to verify the notification design spec is met.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import discord
|
|
||||||
|
|
||||||
from utilities.evolution_notifications import (
|
|
||||||
TIER_COLORS,
|
|
||||||
build_tier_embeds,
|
|
||||||
tier_up_embed,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestTierUpEmbed:
|
|
||||||
"""Unit tests for tier_up_embed() — standard (T1–T3) and fully-evolved (T4) paths."""
|
|
||||||
|
|
||||||
def test_tier_up_title(self):
|
|
||||||
"""Standard tier-up embeds must use the 'Evolution Tier Up!' title."""
|
|
||||||
embed = tier_up_embed(
|
|
||||||
"Mike Trout", tier=2, tier_name="Rising", track_name="Batter"
|
|
||||||
)
|
|
||||||
assert embed.title == "Evolution Tier Up!"
|
|
||||||
|
|
||||||
def test_tier_up_description_format(self):
|
|
||||||
"""Description must include player name, tier number, tier name, and track name."""
|
|
||||||
embed = tier_up_embed(
|
|
||||||
"Mike Trout", tier=2, tier_name="Rising", track_name="Batter"
|
|
||||||
)
|
|
||||||
assert (
|
|
||||||
embed.description
|
|
||||||
== "Mike Trout reached Tier 2 (Rising) on the Batter track"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_tier_up_color_matches_tier(self):
|
|
||||||
"""Each tier must map to its specified embed color."""
|
|
||||||
for tier, expected_color in TIER_COLORS.items():
|
|
||||||
if tier == 4:
|
|
||||||
continue # T4 handled in fully-evolved tests
|
|
||||||
embed = tier_up_embed(
|
|
||||||
"Test Player", tier=tier, tier_name="Name", track_name="Batter"
|
|
||||||
)
|
|
||||||
assert embed.color.value == expected_color, f"Tier {tier} color mismatch"
|
|
||||||
|
|
||||||
def test_tier_up_no_footer_for_standard_tiers(self):
|
|
||||||
"""Standard tier-up embeds (T1–T3) must not have a footer."""
|
|
||||||
for tier in (1, 2, 3):
|
|
||||||
embed = tier_up_embed(
|
|
||||||
"Test Player", tier=tier, tier_name="Name", track_name="Batter"
|
|
||||||
)
|
|
||||||
assert embed.footer.text is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestFullyEvolvedEmbed:
|
|
||||||
"""Unit tests for the fully-evolved (T4) embed — distinct title, description, and footer."""
|
|
||||||
|
|
||||||
def test_fully_evolved_title(self):
|
|
||||||
"""T4 embeds must use the 'FULLY EVOLVED!' title."""
|
|
||||||
embed = tier_up_embed(
|
|
||||||
"Mike Trout", tier=4, tier_name="Legendary", track_name="Batter"
|
|
||||||
)
|
|
||||||
assert embed.title == "FULLY EVOLVED!"
|
|
||||||
|
|
||||||
def test_fully_evolved_description(self):
|
|
||||||
"""T4 description must indicate maximum evolution without mentioning tier number."""
|
|
||||||
embed = tier_up_embed(
|
|
||||||
"Mike Trout", tier=4, tier_name="Legendary", track_name="Batter"
|
|
||||||
)
|
|
||||||
assert (
|
|
||||||
embed.description
|
|
||||||
== "Mike Trout has reached maximum evolution on the Batter track"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_fully_evolved_footer(self):
|
|
||||||
"""T4 embeds must include the Phase 2 teaser footer."""
|
|
||||||
embed = tier_up_embed(
|
|
||||||
"Mike Trout", tier=4, tier_name="Legendary", track_name="Batter"
|
|
||||||
)
|
|
||||||
assert embed.footer.text == "Rating boosts coming in a future update!"
|
|
||||||
|
|
||||||
def test_fully_evolved_color(self):
|
|
||||||
"""T4 embed color must be teal."""
|
|
||||||
embed = tier_up_embed(
|
|
||||||
"Mike Trout", tier=4, tier_name="Legendary", track_name="Batter"
|
|
||||||
)
|
|
||||||
assert embed.color.value == TIER_COLORS[4]
|
|
||||||
|
|
||||||
|
|
||||||
class TestBuildTierEmbeds:
|
|
||||||
"""Unit tests for build_tier_embeds() — list construction and edge cases."""
|
|
||||||
|
|
||||||
def test_no_tier_ups_returns_empty_list(self):
|
|
||||||
"""When no tier-ups occurred, build_tier_embeds must return an empty list."""
|
|
||||||
result = build_tier_embeds([])
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
def test_single_tier_up_returns_one_embed(self):
|
|
||||||
"""A single tier-up event must produce exactly one embed."""
|
|
||||||
tier_ups = [
|
|
||||||
{
|
|
||||||
"player_name": "Mike Trout",
|
|
||||||
"tier": 2,
|
|
||||||
"tier_name": "Rising",
|
|
||||||
"track_name": "Batter",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
result = build_tier_embeds(tier_ups)
|
|
||||||
assert len(result) == 1
|
|
||||||
assert isinstance(result[0], discord.Embed)
|
|
||||||
|
|
||||||
def test_multiple_tier_ups_return_separate_embeds(self):
|
|
||||||
"""Multiple tier-up events in one game must produce one embed per event."""
|
|
||||||
tier_ups = [
|
|
||||||
{
|
|
||||||
"player_name": "Mike Trout",
|
|
||||||
"tier": 2,
|
|
||||||
"tier_name": "Rising",
|
|
||||||
"track_name": "Batter",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"player_name": "Sandy Koufax",
|
|
||||||
"tier": 3,
|
|
||||||
"tier_name": "Elite",
|
|
||||||
"track_name": "Starter",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
result = build_tier_embeds(tier_ups)
|
|
||||||
assert len(result) == 2
|
|
||||||
assert (
|
|
||||||
result[0].description
|
|
||||||
== "Mike Trout reached Tier 2 (Rising) on the Batter track"
|
|
||||||
)
|
|
||||||
assert (
|
|
||||||
result[1].description
|
|
||||||
== "Sandy Koufax reached Tier 3 (Elite) on the Starter track"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_fully_evolved_in_batch(self):
|
|
||||||
"""A T4 event in a batch must produce a fully-evolved embed, not a standard one."""
|
|
||||||
tier_ups = [
|
|
||||||
{
|
|
||||||
"player_name": "Babe Ruth",
|
|
||||||
"tier": 4,
|
|
||||||
"tier_name": "Legendary",
|
|
||||||
"track_name": "Batter",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
result = build_tier_embeds(tier_ups)
|
|
||||||
assert len(result) == 1
|
|
||||||
assert result[0].title == "FULLY EVOLVED!"
|
|
||||||
assert result[0].footer.text == "Rating boosts coming in a future update!"
|
|
||||||
Loading…
Reference in New Issue
Block a user