mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-06-28 10:21:55 +00:00
7537c76c63
- Introduced World Cup command and service settings in `config.ini.example`, `config.ini.minimal-example`, and `config.ini.quickstart` to enable FIFA World Cup score tracking and updates. - Enhanced `espn_client.py` with methods to fetch tournament calendar and match states, supporting the new World Cup features. - Updated `sports_command.py` to resolve World Cup nation names and integrate with the new command. - Added translations for World Cup commands and messages in multiple languages, ensuring comprehensive support for users. - Included mappings for World Cup nations in `sports_mappings.py` to facilitate accurate team identification.
301 lines
14 KiB
Python
301 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
|
|
|
|
from modules.clients.espn_client import ESPNClient
|
|
from modules.clients.worldcup_data import WorldCupData
|
|
from modules.commands.worldcup_command import WorldCupCommand
|
|
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.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)
|
|
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": 100},
|
|
{"formatted": "@NED 2-2 JPN (45')", "timestamp": -1, "status": "STATUS_IN_PROGRESS", "event_timestamp": 200},
|
|
]}
|
|
)
|
|
await cmd.execute(mock_message("wc"))
|
|
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
|