mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-06-29 10:51:37 +00:00
5aa84c4ffb
- Added new utility functions `espn_dates_for_local_day` and `filter_events_local_day` to handle local timezone date calculations and event filtering. - Updated `ESPNClient` to support optional date parameters in `fetch_scoreboard_with_calendar` and `fetch_match_states` methods, allowing for more precise data retrieval. - Modified `WorldCupCommand` and `WorldCupLiveService` to utilize the new date range functionality, ensuring accurate event reporting based on local time. - Implemented unit tests to verify the correct behavior of date handling and event filtering in various scenarios.
313 lines
14 KiB
Python
313 lines
14 KiB
Python
"""Tests for the World Cup command and its data helper."""
|
|
|
|
import configparser
|
|
from datetime import datetime, timedelta, timezone
|
|
from unittest.mock import AsyncMock, MagicMock, Mock
|
|
|
|
import pytz
|
|
|
|
from modules.clients.espn_client import ESPNClient
|
|
from modules.clients.worldcup_data import WorldCupData
|
|
from modules.commands.worldcup_command import WorldCupCommand
|
|
from modules.utils import espn_dates_for_local_day
|
|
from tests.conftest import mock_message
|
|
|
|
|
|
def _make_bot():
|
|
bot = MagicMock()
|
|
bot.logger = Mock()
|
|
config = configparser.ConfigParser()
|
|
config.add_section("Bot")
|
|
config.set("Bot", "bot_name", "TestBot")
|
|
config.set("Bot", "timezone", "America/Los_Angeles")
|
|
config.add_section("Channels")
|
|
config.set("Channels", "monitor_channels", "general")
|
|
config.set("Channels", "respond_to_dms", "true")
|
|
config.add_section("Worldcup_Command")
|
|
config.set("Worldcup_Command", "enabled", "true")
|
|
bot.config = config
|
|
bot.translator = MagicMock()
|
|
# Echo the key back (with the kwargs ignored) so assertions can match on key
|
|
bot.translator.translate = Mock(side_effect=lambda key, **kw: key)
|
|
bot.command_manager = MagicMock()
|
|
bot.command_manager.monitor_channels = ["general"]
|
|
bot.command_manager.send_response = AsyncMock(return_value=True)
|
|
bot.command_manager.send_response_chunked = AsyncMock(return_value=True)
|
|
return bot
|
|
|
|
|
|
def _make_command():
|
|
cmd = WorldCupCommand(_make_bot())
|
|
# Deterministic, network-free message budget
|
|
cmd.get_max_message_length = lambda message: 150
|
|
return cmd
|
|
|
|
|
|
def _window_calendar(active: bool):
|
|
"""Build an ESPN-style calendar that does/doesn't contain now."""
|
|
if active:
|
|
start = datetime.now(timezone.utc) - timedelta(days=2)
|
|
end = datetime.now(timezone.utc) + timedelta(days=20)
|
|
else:
|
|
start = datetime(2023, 7, 20, tzinfo=timezone.utc)
|
|
end = datetime(2023, 8, 20, tzinfo=timezone.utc)
|
|
fmt = "%Y-%m-%dT%H:%MZ"
|
|
return [{"label": "Group", "startDate": start.strftime(fmt), "endDate": end.strftime(fmt)}]
|
|
|
|
|
|
# --------------------------------------------------------------------- helpers
|
|
|
|
|
|
class TestStatValue:
|
|
def test_lookup_by_name_and_type(self):
|
|
stats = [
|
|
{"name": "wins", "value": 2.0, "type": "wins"},
|
|
{"name": "ties", "value": 1.0, "type": "ties"},
|
|
{"name": "pointDifferential", "value": 3.0, "type": "pointdifferential"},
|
|
]
|
|
assert ESPNClient._stat_value(stats, "wins") == 2.0
|
|
assert ESPNClient._stat_value(stats, "ties", "draws") == 1.0
|
|
assert ESPNClient._stat_value(stats, "missing") is None
|
|
|
|
|
|
class TestPackLines:
|
|
def test_respects_byte_budget_and_cap(self):
|
|
cmd = _make_command()
|
|
lines = [f"line-{i}-xxxxxxxxxxxxxxxxxxxx" for i in range(20)]
|
|
chunks = cmd._pack_lines(lines, max_len=40, max_chunks=3)
|
|
assert len(chunks) <= 3
|
|
for chunk in chunks:
|
|
assert len(chunk.encode("utf-8")) <= 40
|
|
|
|
def test_oversized_single_line_truncated(self):
|
|
cmd = _make_command()
|
|
chunks = cmd._pack_lines(["x" * 500], max_len=50, max_chunks=3)
|
|
assert len(chunks) == 1
|
|
assert len(chunks[0].encode("utf-8")) <= 50
|
|
|
|
|
|
# ------------------------------------------------------------ season detection
|
|
|
|
|
|
class TestActiveTournament:
|
|
async def test_mens_in_window_is_active(self):
|
|
helper = WorldCupData(MagicMock())
|
|
helper.espn.fetch_scoreboard_with_calendar = AsyncMock(
|
|
return_value={"calendar": _window_calendar(True), "league_name": "FIFA World Cup", "competitors": []}
|
|
)
|
|
active = await helper.get_active_tournament()
|
|
assert active is not None
|
|
assert active["league"] == "fifa.world"
|
|
|
|
async def test_off_season_returns_none(self):
|
|
helper = WorldCupData(MagicMock())
|
|
helper.espn.fetch_scoreboard_with_calendar = AsyncMock(
|
|
return_value={"calendar": _window_calendar(False), "league_name": "FIFA Women's World Cup", "competitors": []}
|
|
)
|
|
active = await helper.get_active_tournament()
|
|
assert active is None
|
|
|
|
async def test_result_is_cached(self):
|
|
helper = WorldCupData(MagicMock())
|
|
helper.espn.fetch_scoreboard_with_calendar = AsyncMock(
|
|
return_value={"calendar": _window_calendar(True), "league_name": "FIFA World Cup", "competitors": []}
|
|
)
|
|
await helper.get_active_tournament()
|
|
await helper.get_active_tournament()
|
|
# Only the men's probe runs once (cached), never re-hitting ESPN
|
|
assert helper.espn.fetch_scoreboard_with_calendar.await_count == 1
|
|
|
|
|
|
# ------------------------------------------------------------ nation resolving
|
|
|
|
|
|
class TestResolveNation:
|
|
async def _helper_with_teams(self):
|
|
helper = WorldCupData(MagicMock())
|
|
helper.espn.fetch_standings = AsyncMock(
|
|
return_value=[
|
|
{"group_name": "Group A", "entries": [
|
|
{"id": "203", "name": "Mexico", "abbr": "MEX", "location": "Mexico"},
|
|
]},
|
|
{"group_name": "Group C", "entries": [
|
|
{"id": "111", "name": "United States", "abbr": "USA", "location": "United States"},
|
|
]},
|
|
]
|
|
)
|
|
helper.espn.fetch_scoreboard_with_calendar = AsyncMock(return_value={"competitors": []})
|
|
return helper
|
|
|
|
async def test_resolve_by_name(self):
|
|
helper = await self._helper_with_teams()
|
|
info = await helper.resolve_nation("mexico", "fifa.world")
|
|
assert info and info["team_id"] == "203"
|
|
assert info["league"] == "fifa.world"
|
|
|
|
async def test_resolve_by_synonym(self):
|
|
helper = await self._helper_with_teams()
|
|
info = await helper.resolve_nation("usa", "fifa.world")
|
|
assert info and info["team_id"] == "111"
|
|
|
|
async def test_unknown_nation(self):
|
|
helper = await self._helper_with_teams()
|
|
assert await helper.resolve_nation("atlantis", "fifa.world") is None
|
|
|
|
|
|
# ----------------------------------------------------------------- dispatch
|
|
|
|
|
|
def _patch_active(cmd, league="fifa.world", dates=False, in_group_stage=False):
|
|
value = {"league": league, "label": "FIFA World Cup", "in_group_stage": in_group_stage}
|
|
if dates:
|
|
value["start_date"] = "20260611"
|
|
value["end_date"] = "20260719"
|
|
cmd.wc_data.get_active_tournament = AsyncMock(return_value=value)
|
|
|
|
|
|
class TestDispatch:
|
|
async def test_not_in_season(self):
|
|
cmd = _make_command()
|
|
cmd.wc_data.get_active_tournament = AsyncMock(return_value=None)
|
|
await cmd.execute(mock_message("wc"))
|
|
cmd.bot.command_manager.send_response.assert_awaited_once()
|
|
assert cmd.bot.command_manager.send_response.await_args.args[1] == "commands.worldcup.not_in_season"
|
|
|
|
async def test_today_no_args(self):
|
|
cmd = _make_command()
|
|
_patch_active(cmd)
|
|
tz = pytz.timezone("America/Los_Angeles")
|
|
_, _, local_start_ts, _ = espn_dates_for_local_day(tz)
|
|
ft_ts = local_start_ts + 14 * 3600
|
|
live_ts = local_start_ts + 21 * 3600
|
|
cmd.espn_client.fetch_scoreboard_with_calendar = AsyncMock(
|
|
return_value={"events": [
|
|
{"formatted": "@GER 7-1 CUW (FT)", "timestamp": 9999999998, "status": "STATUS_FULL_TIME", "event_timestamp": ft_ts},
|
|
{"formatted": "@NED 2-2 JPN (45')", "timestamp": -1, "status": "STATUS_IN_PROGRESS", "event_timestamp": live_ts},
|
|
]}
|
|
)
|
|
await cmd.execute(mock_message("wc"))
|
|
kwargs = cmd.espn_client.fetch_scoreboard_with_calendar.await_args.kwargs
|
|
assert kwargs["start_date"]
|
|
assert kwargs["end_date"]
|
|
assert kwargs["start_date"] <= kwargs["end_date"]
|
|
chunks = cmd.bot.command_manager.send_response_chunked.await_args.args[1]
|
|
# Live game should be ordered before the completed result
|
|
assert chunks[0].splitlines()[0].endswith("(45')")
|
|
|
|
async def test_group_standings(self):
|
|
cmd = _make_command()
|
|
_patch_active(cmd)
|
|
cmd.espn_client.fetch_standings = AsyncMock(
|
|
return_value=[
|
|
{"group_name": "Group A", "entries": [
|
|
{"rank": 1, "abbr": "MEX", "pts": 3, "gd": 2, "w": 1, "d": 0, "l": 0},
|
|
{"rank": 2, "abbr": "KSA", "pts": 0, "gd": -2, "w": 0, "d": 0, "l": 1},
|
|
]},
|
|
]
|
|
)
|
|
await cmd.execute(mock_message("wc group a"))
|
|
chunks = cmd.bot.command_manager.send_response_chunked.await_args.args[1]
|
|
joined = "\n".join(chunks)
|
|
assert "Group A" in joined
|
|
assert "MEX 3p +2 (1-0-0)" in joined
|
|
|
|
async def test_group_not_found(self):
|
|
cmd = _make_command()
|
|
_patch_active(cmd)
|
|
cmd.espn_client.fetch_standings = AsyncMock(
|
|
return_value=[{"group_name": "Group A", "entries": []}]
|
|
)
|
|
await cmd.execute(mock_message("wc group z"))
|
|
assert cmd.bot.command_manager.send_response.await_args.args[1] == "commands.worldcup.group_not_found"
|
|
|
|
async def test_groups_list(self):
|
|
cmd = _make_command()
|
|
_patch_active(cmd)
|
|
cmd.espn_client.fetch_standings = AsyncMock(
|
|
return_value=[{"group_name": "Group A", "entries": []}, {"group_name": "Group B", "entries": []}]
|
|
)
|
|
await cmd.execute(mock_message("wc groups"))
|
|
assert cmd.bot.command_manager.send_response.await_args.args[1] == "commands.worldcup.groups_list"
|
|
|
|
async def test_nation_matches(self):
|
|
cmd = _make_command()
|
|
_patch_active(cmd)
|
|
cmd.wc_data.resolve_nation = AsyncMock(return_value={"sport": "soccer", "league": "fifa.world", "team_id": "481"})
|
|
cmd.espn_client.fetch_team_schedule = AsyncMock(
|
|
return_value=[{"formatted": "@GER 7-1 CUW (FT)", "timestamp": 9999999998, "status": "STATUS_FULL_TIME", "event_timestamp": 100, "id": "1"}]
|
|
)
|
|
await cmd.execute(mock_message("wc germany"))
|
|
chunks = cmd.bot.command_manager.send_response_chunked.await_args.args[1]
|
|
assert "GER 7-1 CUW" in "\n".join(chunks)
|
|
|
|
async def test_unknown_nation(self):
|
|
cmd = _make_command()
|
|
_patch_active(cmd)
|
|
cmd.wc_data.resolve_nation = AsyncMock(return_value=None)
|
|
await cmd.execute(mock_message("wc atlantis"))
|
|
assert cmd.bot.command_manager.send_response.await_args.args[1] == "commands.worldcup.nation_not_found"
|
|
|
|
async def test_nation_group_stage_shows_all_in_one_message(self):
|
|
cmd = _make_command()
|
|
_patch_active(cmd, dates=True, in_group_stage=True)
|
|
cmd.wc_data.resolve_nation = AsyncMock(return_value={"sport": "soccer", "league": "fifa.world", "team_id": "660"})
|
|
cmd.espn_client.fetch_team_fixtures = AsyncMock(return_value=[
|
|
{"formatted": "@USA 4-1 PAR (FT, 6/12)", "timestamp": 9999999998, "status": "STATUS_FULL_TIME", "event_timestamp": 100, "id": "1"},
|
|
{"formatted": "@USA vs. AUS (6/19 12:00 PM)", "timestamp": 200, "status": "STATUS_SCHEDULED", "event_timestamp": 200, "id": "2"},
|
|
{"formatted": "@TUR vs. USA (6/25 7:00 PM)", "timestamp": 300, "status": "STATUS_SCHEDULED", "event_timestamp": 300, "id": "3"},
|
|
])
|
|
await cmd.execute(mock_message("wc usa"))
|
|
cmd.espn_client.fetch_team_fixtures.assert_awaited_once_with("soccer", "fifa.world", "660", "20260611", "20260719")
|
|
chunks = cmd.bot.command_manager.send_response_chunked.await_args.args[1]
|
|
assert len(chunks) == 1 # single message
|
|
joined = chunks[0]
|
|
# All three group matches shown
|
|
assert "USA 4-1 PAR" in joined and "USA vs. AUS" in joined and "TUR vs. USA" in joined
|
|
|
|
async def test_nation_knockout_shows_last_and_next(self):
|
|
cmd = _make_command()
|
|
_patch_active(cmd, dates=True, in_group_stage=False)
|
|
cmd.wc_data.resolve_nation = AsyncMock(return_value={"sport": "soccer", "league": "fifa.world", "team_id": "660"})
|
|
cmd.espn_client.fetch_team_fixtures = AsyncMock(return_value=[
|
|
{"formatted": "@USA 4-1 PAR (FT, 6/12)", "timestamp": 9999999998, "status": "STATUS_FULL_TIME", "event_timestamp": 100, "id": "1"},
|
|
{"formatted": "@USA 2-0 AUS (FT, 6/19)", "timestamp": 9999999998, "status": "STATUS_FULL_TIME", "event_timestamp": 200, "id": "2"},
|
|
{"formatted": "@TUR 1-3 USA (FT, 6/25)", "timestamp": 9999999998, "status": "STATUS_FULL_TIME", "event_timestamp": 300, "id": "3"},
|
|
{"formatted": "@USA vs. WIN (7/3 11:00 AM)", "timestamp": 400, "status": "STATUS_SCHEDULED", "event_timestamp": 400, "id": "4"},
|
|
])
|
|
await cmd.execute(mock_message("wc usa"))
|
|
chunks = cmd.bot.command_manager.send_response_chunked.await_args.args[1]
|
|
assert len(chunks) == 1
|
|
joined = chunks[0]
|
|
# Only the most recent result + the next fixture (earlier results omitted)
|
|
assert "TUR 1-3 USA" in joined and "USA vs. WIN" in joined
|
|
assert "USA 4-1 PAR" not in joined and "USA 2-0 AUS" not in joined
|
|
|
|
|
|
class TestPhaseDetection:
|
|
async def test_group_stage_flag_from_calendar(self):
|
|
from datetime import datetime, timedelta, timezone
|
|
helper = WorldCupData(MagicMock())
|
|
now = datetime.now(timezone.utc)
|
|
cal = [
|
|
{"label": "Group Stage", "startDate": (now - timedelta(days=3)).strftime("%Y-%m-%dT%H:%MZ"),
|
|
"endDate": (now + timedelta(days=5)).strftime("%Y-%m-%dT%H:%MZ")},
|
|
{"label": "Round of 16", "startDate": (now + timedelta(days=6)).strftime("%Y-%m-%dT%H:%MZ"),
|
|
"endDate": (now + timedelta(days=10)).strftime("%Y-%m-%dT%H:%MZ")},
|
|
]
|
|
helper.espn.fetch_scoreboard_with_calendar = AsyncMock(
|
|
return_value={"calendar": cal, "league_name": "FIFA World Cup", "competitors": []}
|
|
)
|
|
active = await helper.get_active_tournament()
|
|
assert active["in_group_stage"] is True
|
|
|
|
async def test_nation_matches_deduped(self):
|
|
cmd = _make_command()
|
|
_patch_active(cmd)
|
|
cmd.wc_data.resolve_nation = AsyncMock(return_value={"sport": "soccer", "league": "fifa.world", "team_id": "481"})
|
|
# The schedule endpoint can return the same event twice (schedule + scoreboard fallback)
|
|
dup = {"formatted": "@GER 7-1 CUW (FT)", "timestamp": 9999999998, "status": "STATUS_FULL_TIME", "event_timestamp": 100, "id": "1"}
|
|
cmd.espn_client.fetch_team_schedule = AsyncMock(return_value=[dict(dup), dict(dup)])
|
|
await cmd.execute(mock_message("wc germany"))
|
|
chunks = cmd.bot.command_manager.send_response_chunked.await_args.args[1]
|
|
assert "\n".join(chunks).count("GER 7-1 CUW") == 1
|