diff --git a/features/config.py b/features/config.py index 06fa98d..c87ca29 100644 --- a/features/config.py +++ b/features/config.py @@ -20,6 +20,8 @@ TOURNEY_SUPPORT_CHANNEL_ID = 1442037795987259464 PRE_TOURNEY_SUPPORT_CHANNEL_ID = 1448917516071207133 TOURNEY_ADMIN_CHANNEL_ID = 724835692395626516 + TOURNEY_REPORT_CHANNEL_ID = 1516162989374570689 + TOURNEY_SCHEDULE_CHANNEL_ID = 754927753643688046 SPANISH_CHANNEL_ID = 1052348820513636463 TOURNEY_ADMIN_ROLE_ID = 1160989152381251756 @@ -200,6 +202,8 @@ TOURNEY_SUPPORT_CHANNEL_ID = 1448916985713791000 PRE_TOURNEY_SUPPORT_CHANNEL_ID = 1448917121743716485 TOURNEY_ADMIN_CHANNEL_ID = 1452338842798526514 + TOURNEY_REPORT_CHANNEL_ID = 1516169206553509919 + TOURNEY_SCHEDULE_CHANNEL_ID = 1516186562478735400 SPANISH_CHANNEL_ID = 1477148473710084127 TOURNEY_ADMIN_ROLE_ID = 1442028161889079469 diff --git a/features/tourney/tourney_commands.py b/features/tourney/tourney_commands.py index 69894fb..1a4e77b 100644 --- a/features/tourney/tourney_commands.py +++ b/features/tourney/tourney_commands.py @@ -60,6 +60,8 @@ HALL_OF_FAME_CHANNEL_ID, BOT_VERSION, TOURNEY_ADMIN_ROLE_ID, + TOURNEY_REPORT_CHANNEL_ID, + TOURNEY_SCHEDULE_CHANNEL_ID, ) from .tourney_utils import ( close_ticket_via_command, @@ -1340,6 +1342,61 @@ async def start_tourney_command(ctx: commands.Context, region: str = None): else: await reset_tourney_session_start_time(existing_session["_id"]) + # Auto-detect Matcherino ID from #tourney-schedule (±1 day of today) + auto_matcherino_id = None + auto_detect_error = None + schedule_channel = ctx.bot.get_channel(TOURNEY_SCHEDULE_CHANNEL_ID) + if not schedule_channel: + auto_detect_error = "⚠️ Auto-detect: `TOURNEY_SCHEDULE_CHANNEL_ID` not found — check `config.py`." + else: + today = datetime.datetime.now(datetime.timezone.utc).date() + msgs_checked = 0 + closest_date_seen = None + try: + async for msg in schedule_channel.history(limit=100): + content = msg.content or "" + date_match = re.search( + r"•\s*\**\s*Date:\**\s*(.+)", content, re.IGNORECASE + ) + if not date_match: + continue + msgs_checked += 1 + date_str = date_match.group(1).strip() + try: + parsed_date = datetime.datetime.strptime( + date_str, "%B %d, %Y" + ).date() + except ValueError: + auto_detect_error = f"⚠️ Auto-detect: Found a date but couldn't parse it: `{date_str}` — expected format `June 19, 2026`." + continue + closest_date_seen = parsed_date + if abs((parsed_date - today).days) <= 1: + url_match = re.search( + r"matcherino\.com/supercell/tournaments/(\d+)", content + ) + if url_match: + auto_matcherino_id = url_match.group(1) + break + else: + auto_detect_error = f"⚠️ Auto-detect: Found the schedule post for `{date_str}` but no Matcherino URL in the message." + break + + if not auto_matcherino_id and not auto_detect_error: + if msgs_checked == 0: + auto_detect_error = f"⚠️ Auto-detect: No messages with a `• Date:` field found in <#{TOURNEY_SCHEDULE_CHANNEL_ID}>." + else: + auto_detect_error = ( + f"⚠️ Auto-detect: No schedule post within ±1 day of today ({today.strftime('%B %d, %Y')}). " + f"Closest date found: `{closest_date_seen.strftime('%B %d, %Y') if closest_date_seen else 'none'}`." + ) + except Exception as e: + auto_detect_error = f"⚠️ Auto-detect error: `{e}`" + + if auto_matcherino_id: + active_session = await get_active_tourney_session() + if active_session: + await update_matcherino_id(active_session["_id"], auto_matcherino_id) + await lock_command(ctx) # --- SA REGION LOGIC --- @@ -1479,8 +1536,16 @@ async def start_tourney_command(ctx: commands.Context, region: str = None): except Exception as e: print(f"Failed to delete pre-tourney ticket {ch.name}: {e}") + if auto_matcherino_id: + matcherino_notice = f"🔗 Matcherino ID auto-set to `{auto_matcherino_id}` from the schedule. If wrong, use `/set-matcherino` to fix it." + else: + matcherino_notice = ( + auto_detect_error + or "⚠️ Could not auto-detect Matcherino ID. Set it manually with `/set-matcherino`." + ) + await ctx.send( - f"✅ Tourney Started! Channels updated and {deleted_count} pre-tourney tickets deleted.\n⚠️ Don't forget to set the new Matcherino ID with `/set-matcherino` and enable ML data collection if needed." + f"✅ Tourney Started! Channels updated and {deleted_count} pre-tourney tickets deleted.\n{matcherino_notice}" ) # Grant Tourney Admin the Timeout Members permission for the duration of the tourney. @@ -1529,8 +1594,6 @@ async def _rename_admin_role(): "hype_message_id": None, }, } - await dashboard_cog.start_dashboard() - # Apply 60s slow mode to general channel during tourney. general_channel = guild.get_channel(GENERAL_CHANNEL_ID) if isinstance(general_channel, discord.TextChannel): @@ -1542,6 +1605,11 @@ async def _rename_admin_role(): except Exception as e: print(f"Failed to set slow mode on general channel: {e}") + # Start dashboard last so it's the final message in #tourney-admin, + # preventing an immediate delete+repost flash on the first 5-minute tick. + if dashboard_cog: + await dashboard_cog.start_dashboard() + @bot.command(name="endtourney") async def end_tourney_command(ctx: commands.Context): """ @@ -1588,13 +1656,6 @@ async def end_tourney_command(ctx: commands.Context): except Exception as e: print(f"!endtourney announcement sync error: {e}") - # Do a final progress dashboard refresh before stopping so it shows Tournament Over. - if dashboard_cog: - try: - await dashboard_cog.update_progress_dashboard() - except Exception as e: - print(f"!endtourney final progress update error: {e}") - winner_was_posted = ( dashboard_cog is not None and dashboard_cog._winner_announcement_state.get("winner") is not None @@ -1684,16 +1745,55 @@ async def _retry_winner_post(): icon = f"**{i + 1}.**" # e.g. "4.", "5.", "6." staff_msg += ( - f"{icon} **{s['username']}**: {s['tickets_closed']} tickets\n" + f"{icon} <@{s['user_id']}>: {s['tickets_closed']} tickets\n" ) if not staff_msg: staff_msg = "No tickets closed." - # 3. Send Embed - stat_embed = discord.Embed( - title="📊 Tournament Report", color=discord.Color.gold() - ) + # 3. Look up tournament name (Matcherino API) and canonical date (#tourney-schedule) + tourney_date_str = None + tourney_name = None + matcherino_id = session.get("matcherino_id") + if matcherino_id: + try: + payout_data = fetch_payout_report(str(matcherino_id)) + if "error" not in payout_data: + tourney_name = payout_data.get("tourney_name") + except Exception as e: + print(f"⚠️ Could not fetch tourney name from Matcherino: {e}") + + ann_channel = ctx.bot.get_channel(TOURNEY_SCHEDULE_CHANNEL_ID) + if ann_channel: + try: + async for msg in ann_channel.history(limit=500): + if str(matcherino_id) in (msg.content or ""): + m = re.search( + r"•\s*\**\s*Date:\**\s*(.+)", + msg.content, + re.IGNORECASE, + ) + if m: + tourney_date_str = m.group(1).strip() + break + except Exception as e: + print(f"⚠️ Could not look up tourney date from schedule: {e}") + + # 4. Build Embed + if tourney_name and matcherino_id: + matcherino_url = ( + f"https://matcherino.com/supercell/tournaments/{matcherino_id}" + ) + stat_embed = discord.Embed( + title=f"📊 Tournament Report ({tourney_name})", + url=matcherino_url, + color=discord.Color.gold(), + ) + else: + stat_embed = discord.Embed( + title="📊 Tournament Report", + color=discord.Color.gold(), + ) stat_embed.add_field( name="⏱️ Duration", value=f"`{hours}h {minutes}m`", inline=True ) @@ -1715,15 +1815,20 @@ async def _retry_winner_post(): stat_embed.add_field( name="🏆 Top Tourney Admins", value=staff_msg, inline=False ) + if tourney_date_str: + stat_embed.add_field( + name="📅 Tournament Date", value=tourney_date_str, inline=False + ) + if matcherino_id: + stat_embed.set_footer(text=f"Matcherino ID: {matcherino_id}") - report_msg = await ctx.send(embed=stat_embed) - - try: - await report_msg.pin() - except Exception as e: - print(f"⚠️ Could not pin report: {e}") + # 5. Send to command channel (no pin) and archive to #tourney-reports + await ctx.send(embed=stat_embed) + report_channel = ctx.bot.get_channel(TOURNEY_REPORT_CHANNEL_ID) + if report_channel: + await report_channel.send(embed=stat_embed) - # 4. Close Session in DB + # 6. Close Session in DB await end_tourney_session(session["_id"]) await set_tourney_collect_data(session["_id"], False) clear_bracket_teams_cache() diff --git a/features/tourney/tourney_reports.py b/features/tourney/tourney_reports.py new file mode 100644 index 0000000..b574707 --- /dev/null +++ b/features/tourney/tourney_reports.py @@ -0,0 +1,256 @@ +import datetime +import re +import zoneinfo + +import discord +from discord import app_commands +from discord.ext import commands, tasks + +from features.config import ( + ALLOWED_STAFF_ROLES, + TOURNEY_REPORT_CHANNEL_ID, +) + + +def is_staff(member: discord.Member) -> bool: + return any(role.id in ALLOWED_STAFF_ROLES for role in member.roles) + + +def _prev_month(year: int, month: int) -> tuple[int, int]: + if month == 1: + return year - 1, 12 + return year, month - 1 + + +def _month_range(year: int, month: int) -> tuple[datetime.datetime, datetime.datetime]: + start = datetime.datetime(year, month, 1, tzinfo=datetime.timezone.utc) + ny, nm = (year, month + 1) if month < 12 else (year + 1, 1) + end = datetime.datetime(ny, nm, 1, tzinfo=datetime.timezone.utc) + return start, end + + +def _parse_tourney_date(date_str: str) -> datetime.datetime | None: + for fmt in ( + "%B %d, %Y", + "%B %dst, %Y", + "%B %dnd, %Y", + "%B %drd, %Y", + "%B %dth, %Y", + ): + try: + return datetime.datetime.strptime(date_str.strip(), fmt).replace( + tzinfo=datetime.timezone.utc + ) + except ValueError: + continue + return None + + +async def _run_monthly_report( + bot: commands.Bot, + year: int, + month: int, + *, + status_cb=None, +) -> str: + """ + Core logic for generating a monthly tournament report. + + Reads #tourney-reports for per-tourney embeds whose Tournament Date falls + in the given month/year. Aggregates and posts a combined embed to the same + channel. + + status_cb: optional async callable(str) for streaming progress to a slash command. + Returns a summary string on success, or raises ValueError with an error message. + """ + + async def status(msg: str): + print(msg) + if status_cb: + await status_cb(msg) + + month_label = datetime.datetime(year, month, 1).strftime("%B %Y") + start_dt, end_dt = _month_range(year, month) + + report_channel = bot.get_channel(TOURNEY_REPORT_CHANNEL_ID) + if not report_channel: + raise ValueError( + "⚠️ `TOURNEY_REPORT_CHANNEL_ID` not found — update it in `config.py`." + ) + + await status(f"🔍 Scanning {report_channel.mention} for **{month_label}**...") + + total_tickets = 0 + total_messages = 0 + peak_queues: list[int] = [] + staff_totals: dict[str, int] = {} + tourney_count = 0 + parse_warnings: list[str] = [] + + async for msg in report_channel.history(limit=1000): + if msg.author.id != bot.user.id: + continue + for embed in msg.embeds: + # Skip monthly rollup embeds to avoid double-counting + if embed.title and "Monthly Tournament Report" in embed.title: + continue + + date_field = next( + (f for f in embed.fields if "Tournament Date" in f.name), None + ) + if not date_field: + continue + + parsed = _parse_tourney_date(date_field.value) + if not parsed: + parse_warnings.append( + f"⚠️ Could not parse date `{date_field.value}` " + f"from embed posted {msg.created_at.strftime('%Y-%m-%d')} — skipped." + ) + continue + + if not (start_dt <= parsed < end_dt): + continue + + tourney_count += 1 + + for field in embed.fields: + name = field.name + val = field.value + if "Total Tickets" in name: + m = re.search(r"\d+", val) + if m: + total_tickets += int(m.group()) + elif "Total Messages" in name: + m = re.search(r"\d+", val) + if m: + total_messages += int(m.group()) + elif "Peak Queue" in name: + m = re.search(r"\d+", val) + if m: + peak_queues.append(int(m.group())) + elif "Top Tourney Admins" in name: + for user_id, count in re.findall(r"<@(\d+)>: (\d+) tickets", val): + staff_totals[user_id] = staff_totals.get(user_id, 0) + int( + count + ) + + for warning in parse_warnings: + await status(warning) + + if tourney_count == 0: + return ( + f"📊 No tournaments found in **{month_label}**. " + f"If reports exist for this month, ensure they include a **📅 Tournament Date** field." + ) + + avg_peak = round(sum(peak_queues) / len(peak_queues), 1) if peak_queues else 0 + + sorted_staff = sorted(staff_totals.items(), key=lambda x: x[1], reverse=True) + medals = ["🥇", "🥈", "🥉"] + staff_lines = [] + for i, (user_id, count) in enumerate(sorted_staff[:12]): + icon = medals[i] if i < 3 else f"**{i + 1}.**" + staff_lines.append(f"{icon} <@{user_id}>: {count} tickets") + staff_msg = "\n".join(staff_lines) if staff_lines else "No tickets closed." + + embed = discord.Embed( + title=f"📅 Monthly Tournament Report — {month_label}", + color=discord.Color.purple(), + ) + embed.add_field(name="🏆 Tournaments", value=f"`{tourney_count}`", inline=True) + embed.add_field(name="📩 Total Tickets", value=f"`{total_tickets}`", inline=True) + embed.add_field(name="💬 Total Messages", value=f"`{total_messages}`", inline=True) + embed.add_field( + name="📈 Avg Peak Queue", value=f"`{avg_peak}` tickets", inline=True + ) + embed.add_field(name="🏆 Top Tourney Admins", value=staff_msg, inline=False) + + await report_channel.send(embed=embed) + return ( + f"✅ Monthly report for **{month_label}** posted — " + f"{tourney_count} tournament(s), {total_tickets} total tickets." + ) + + +class TourneyReports(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + self.monthly_report_task.start() + + def cog_unload(self): + self.monthly_report_task.cancel() + + # ⚠️ FOR TESTING: Change to @tasks.loop(seconds=30) and comment out the day != 1 guard + @tasks.loop(time=datetime.time(hour=6, minute=0, tzinfo=zoneinfo.ZoneInfo("UTC"))) + async def monthly_report_task(self): + if not self.bot.is_ready(): + return + now = datetime.datetime.now(datetime.timezone.utc) + if now.day != 1: + return + + py, pm = _prev_month(now.year, now.month) + try: + result = await _run_monthly_report(self.bot, py, pm) + print(f"✅ Auto monthly report: {result}") + except ValueError as e: + print(f"❌ Auto monthly report failed: {e}") + except Exception as e: + print(f"❌ Auto monthly report unexpected error: {e}") + + @app_commands.command( + name="monthly-report", + description="STAFF ONLY: Generate (or re-generate) a monthly tournament report.", + ) + @app_commands.describe( + month="Month number (1–12). Defaults to last month.", + year="4-digit year. Defaults to the year of last month.", + ) + async def monthly_report_cmd( + self, + interaction: discord.Interaction, + month: int | None = None, + year: int | None = None, + ): + if not is_staff(interaction.user): + await interaction.response.send_message( + "❌ You don't have permission to use this command.", ephemeral=True + ) + return + + if month is not None and not (1 <= month <= 12): + await interaction.response.send_message( + "❌ Month must be between 1 and 12.", ephemeral=True + ) + return + + now = datetime.datetime.now(datetime.timezone.utc) + if month is None or year is None: + py, pm = _prev_month(now.year, now.month) + month = month if month is not None else pm + year = year if year is not None else py + + await interaction.response.defer(ephemeral=True) + + messages: list[str] = [] + + async def status_cb(msg: str): + messages.append(msg) + await interaction.edit_original_response(content="\n".join(messages)) + + try: + result = await _run_monthly_report( + self.bot, year, month, status_cb=status_cb + ) + messages.append(result) + except ValueError as e: + messages.append(str(e)) + except Exception as e: + messages.append(f"❌ Unexpected error: {e}") + + await interaction.edit_original_response(content="\n".join(messages)) + + +async def setup(bot: commands.Bot): + await bot.add_cog(TourneyReports(bot)) diff --git a/main.py b/main.py index 799490c..fe889b4 100644 --- a/main.py +++ b/main.py @@ -76,6 +76,9 @@ async def on_ready(): await bot.load_extension("features.counting") print("✅ Loaded Feature: Counting") + await bot.load_extension("features.tourney.tourney_reports") + print("✅ Loaded Feature: Tourney Reports") + except Exception as e: print(f"❌ Error loading features: {e}") diff --git a/tests/test_tourney_reports.py b/tests/test_tourney_reports.py new file mode 100644 index 0000000..54a521d --- /dev/null +++ b/tests/test_tourney_reports.py @@ -0,0 +1,274 @@ +"""Tests for pure functions in features/tourney/tourney_reports.py and +the date/URL regex patterns introduced on branch 312-Feature.""" + +import datetime +import re + + +from features.tourney.tourney_reports import ( + _month_range, + _parse_tourney_date, + _prev_month, +) + +# Regex patterns copied from tourney_commands.py (starttourney auto-detect) +DATE_REGEX = re.compile(r"•\s*\**\s*Date:\**\s*(.+)", re.IGNORECASE) +MATCHERINO_URL_REGEX = re.compile(r"matcherino\.com/supercell/tournaments/(\d+)") + +# Regex pattern from _run_monthly_report staff parsing +STAFF_LINE_REGEX = re.compile(r"<@(\d+)>: (\d+) tickets") + + +# --------------------------------------------------------------------------- +# _prev_month +# --------------------------------------------------------------------------- + + +def test_prev_month_normal(): + assert _prev_month(2026, 6) == (2026, 5) + + +def test_prev_month_january_rolls_back_to_december(): + assert _prev_month(2026, 1) == (2025, 12) + + +def test_prev_month_december(): + assert _prev_month(2026, 12) == (2026, 11) + + +def test_prev_month_february(): + assert _prev_month(2026, 2) == (2026, 1) + + +# --------------------------------------------------------------------------- +# _month_range +# --------------------------------------------------------------------------- + + +def test_month_range_normal(): + start, end = _month_range(2026, 6) + assert start == datetime.datetime(2026, 6, 1, tzinfo=datetime.timezone.utc) + assert end == datetime.datetime(2026, 7, 1, tzinfo=datetime.timezone.utc) + + +def test_month_range_december_rolls_to_next_year(): + start, end = _month_range(2026, 12) + assert start == datetime.datetime(2026, 12, 1, tzinfo=datetime.timezone.utc) + assert end == datetime.datetime(2027, 1, 1, tzinfo=datetime.timezone.utc) + + +def test_month_range_january(): + start, end = _month_range(2026, 1) + assert start == datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc) + assert end == datetime.datetime(2026, 2, 1, tzinfo=datetime.timezone.utc) + + +def test_month_range_start_is_utc_aware(): + start, end = _month_range(2026, 3) + assert start.tzinfo == datetime.timezone.utc + assert end.tzinfo == datetime.timezone.utc + + +def test_month_range_date_falls_inside(): + start, end = _month_range(2026, 6) + test_date = datetime.datetime(2026, 6, 19, tzinfo=datetime.timezone.utc) + assert start <= test_date < end + + +def test_month_range_date_falls_outside(): + start, end = _month_range(2026, 6) + test_date = datetime.datetime(2026, 7, 1, tzinfo=datetime.timezone.utc) + assert not (start <= test_date < end) + + +# --------------------------------------------------------------------------- +# _parse_tourney_date +# --------------------------------------------------------------------------- + + +def test_parse_standard_format(): + result = _parse_tourney_date("June 19, 2026") + assert result is not None + assert result.month == 6 + assert result.day == 19 + assert result.year == 2026 + + +def test_parse_with_ordinal_st(): + result = _parse_tourney_date("June 1st, 2026") + assert result is not None + assert result.day == 1 + + +def test_parse_with_ordinal_nd(): + result = _parse_tourney_date("June 2nd, 2026") + assert result is not None + assert result.day == 2 + + +def test_parse_with_ordinal_rd(): + result = _parse_tourney_date("June 3rd, 2026") + assert result is not None + assert result.day == 3 + + +def test_parse_with_ordinal_th(): + result = _parse_tourney_date("June 4th, 2026") + assert result is not None + assert result.day == 4 + + +def test_parse_returns_none_for_invalid(): + assert _parse_tourney_date("not a date") is None + + +def test_parse_returns_none_for_empty(): + assert _parse_tourney_date("") is None + + +def test_parse_returns_none_for_wrong_format(): + assert _parse_tourney_date("19/06/2026") is None + + +def test_parse_result_is_utc_aware(): + result = _parse_tourney_date("June 19, 2026") + assert result.tzinfo == datetime.timezone.utc + + +def test_parse_strips_leading_whitespace(): + result = _parse_tourney_date(" June 19, 2026") + assert result is not None + assert result.day == 19 + + +# --------------------------------------------------------------------------- +# Date regex (schedule announcement parsing) +# --------------------------------------------------------------------------- + + +def test_date_regex_plain_format(): + content = "• Date: June 19, 2026" + m = DATE_REGEX.search(content) + assert m is not None + assert m.group(1).strip() == "June 19, 2026" + + +def test_date_regex_bold_format(): + content = "• **Date:** June 20, 2026" + m = DATE_REGEX.search(content) + assert m is not None + assert m.group(1).strip() == "June 20, 2026" + + +def test_date_regex_with_emoji_prefix(): + # Real announcement format: 🗓️ • **Date:** June 20, 2026 + content = "🗓️ • **Date:** June 20, 2026" + m = DATE_REGEX.search(content) + assert m is not None + assert m.group(1).strip() == "June 20, 2026" + + +def test_date_regex_case_insensitive(): + content = "• date: June 19, 2026" + m = DATE_REGEX.search(content) + assert m is not None + + +def test_date_regex_no_match_without_bullet(): + content = "Date: June 19, 2026" + m = DATE_REGEX.search(content) + assert m is None + + +def test_date_regex_multiline_finds_correct_line(): + content = ( + "Remaining 7 NA Tourney\n" + "🗓️ • **Date:** June 20, 2026\n" + "🪜 • **Bracket Size:** 256 Teams\n" + "✔️ **Register:** https://matcherino.com/supercell/tournaments/208611" + ) + m = DATE_REGEX.search(content) + assert m is not None + assert m.group(1).strip() == "June 20, 2026" + + +# --------------------------------------------------------------------------- +# Matcherino URL regex (schedule announcement parsing) +# --------------------------------------------------------------------------- + + +def test_matcherino_url_extracts_id(): + content = "✔️ **Register:** https://matcherino.com/supercell/tournaments/208611" + m = MATCHERINO_URL_REGEX.search(content) + assert m is not None + assert m.group(1) == "208611" + + +def test_matcherino_url_in_full_announcement(): + content = ( + "Remaining 7 NA Tourney\n" + "🗓️ • **Date:** June 20, 2026\n" + "✔️ **Register:** https://matcherino.com/supercell/tournaments/208611\n" + ) + m = MATCHERINO_URL_REGEX.search(content) + assert m is not None + assert m.group(1) == "208611" + + +def test_matcherino_url_no_match_for_wrong_domain(): + content = "https://otherdomain.com/supercell/tournaments/12345" + m = MATCHERINO_URL_REGEX.search(content) + assert m is None + + +def test_matcherino_url_no_match_when_absent(): + content = "No URL in this message" + m = MATCHERINO_URL_REGEX.search(content) + assert m is None + + +# --------------------------------------------------------------------------- +# Staff line regex (_run_monthly_report embed parsing) +# --------------------------------------------------------------------------- + + +def test_staff_line_parses_gold_medal(): + line = "🥇 <@408419700729708545>: 54 tickets" + matches = STAFF_LINE_REGEX.findall(line) + assert len(matches) == 1 + assert matches[0] == ("408419700729708545", "54") + + +def test_staff_line_parses_numbered_entry(): + # Numbered entries like **4.** <@id>: N tickets + line = "**4.** <@824311612227715083>: 12 tickets" + matches = STAFF_LINE_REGEX.findall(line) + assert len(matches) == 1 + assert matches[0] == ("824311612227715083", "12") + + +def test_staff_line_parses_multiple_lines(): + block = ( + "🥇 <@111>: 54 tickets\n" + "🥈 <@222>: 23 tickets\n" + "🥉 <@333>: 10 tickets\n" + "**4.** <@444>: 5 tickets\n" + ) + matches = STAFF_LINE_REGEX.findall(block) + assert len(matches) == 4 + assert matches[0] == ("111", "54") + assert matches[3] == ("444", "5") + + +def test_staff_line_aggregation_across_embeds(): + # Simulate combining two embed staff fields + embed1 = "🥇 <@111>: 30 tickets\n🥈 <@222>: 20 tickets" + embed2 = "🥇 <@222>: 15 tickets\n🥈 <@111>: 10 tickets" + + totals: dict[str, int] = {} + for block in [embed1, embed2]: + for uid, count in STAFF_LINE_REGEX.findall(block): + totals[uid] = totals.get(uid, 0) + int(count) + + assert totals["111"] == 40 + assert totals["222"] == 35