diff --git a/.env.example b/.env.example index ca588c6..0851cee 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,11 @@ DISCORD_TOKEN= # Google Sheets spreadsheet ID (the long string in the sheet URL) SHEET_ID=your-google-sheet-id-here +# Separate spreadsheet holding tournament team registrations (Team Name + lineup +# of Discord usernames). Optional; drives /teamsync + hourly team-role sync on +# the economy/community bot. Leave unset to disable team-role sync entirely. +TEAM_SHEET_ID= + # Path to Google service account credentials JSON GOOGLE_CREDS_PATH=credentials.json diff --git a/bot.py b/bot.py index 241e1b6..3411f44 100644 --- a/bot.py +++ b/bot.py @@ -23,7 +23,7 @@ import config import strings as S from core import economy, pb_client, sheets from core.admin import is_bot_admin -from core.member_sync import SyncResult +from core.member_sync import SyncResult, sync_all_team_roles from commands.dev_member_commands import register_dev_member_commands from commands.dev_member_runtime import handle_member_join, run_birthday_daily from commands.economy_admin_commands import register_economy_admin_commands @@ -35,6 +35,7 @@ from commands.economy_prestige_commands import register_prestige_commands from commands.economy_quests_commands import register_economy_quests_commands from commands.economy_profile_commands import register_economy_profile_commands from commands.economy_support_commands import register_economy_support_commands +from commands.economy_team_commands import register_economy_team_commands from commands.ops_channel_commands import register_ops_channel_commands from commands.ops_admin_commands import register_ops_admin_commands from commands.info_commands import register_info_commands @@ -336,6 +337,40 @@ async def before_birthday_daily(): await bot.wait_until_ready() +@tasks.loop(hours=1) +async def team_sync_hourly(): + """Reload the tournament registration sheet and re-apply team roles. + + Economy profile only (the tournament players live in the community guild). + Runs the first iteration immediately on start, so this also covers the + initial load at boot. No-op when TEAM_SHEET_ID is unset. + """ + if IS_DEV_PROFILE or not config.TEAM_SHEET_ID: + return + try: + rosters = await sheets.refresh_teams() + except Exception as e: + log.error("team_sync_hourly: failed to load team sheet: %s", e) + return + if not rosters: + return + guild = bot.get_guild(config.GUILD_ID) + if guild is None: + log.warning("team_sync_hourly: guild %s not found", config.GUILD_ID) + return + summary = await sync_all_team_roles(guild, log) + if summary.assigned or summary.removed or summary.created: + log.info( + "team_sync_hourly: assigned=%d, removed=%d, created=%d, errors=%d", + summary.assigned, summary.removed, len(summary.created), len(summary.errors), + ) + + +@team_sync_hourly.before_loop +async def before_team_sync_hourly(): + await bot.wait_until_ready() + + # --------------------------------------------------------------------------- # Rotating rich presence # --------------------------------------------------------------------------- @@ -420,12 +455,6 @@ async def on_ready(): log.info("Loaded %d member rows from Google Sheets", len(data)) except Exception as e: log.error("Failed to load sheet on startup: %s", e) - try: - rosters = await sheets.refresh_teams() - if rosters: - log.info("Loaded %d teams from the registration sheet", len(rosters)) - except Exception as e: - log.error("Failed to load team sheet on startup: %s", e) # Sync slash commands to the guild only; wipe any leftover global registrations tree.copy_global_to(guild=GUILD_OBJ) @@ -439,6 +468,11 @@ async def on_ready(): birthday_daily.start() log.info("Birthday daily task started (fires 09:00 Tallinn time)") + # Start hourly tournament team-role sync (economy/community guild) + if not IS_DEV_PROFILE and config.TEAM_SHEET_ID and not team_sync_hourly.is_running(): + team_sync_hourly.start() + log.info("Team-role sync task started (hourly, from the registration sheet)") + # Start rotating rich presence if not _rotate_presence.is_running(): _rotate_presence.start() @@ -496,6 +530,10 @@ if IS_DEV_PROFILE: has_announced_today=_has_announced_today, mark_announced_today=_mark_announced_today, ) +else: + # Tournament team-role sync lives on the economy/community bot, where the + # registered players actually are (see commands/economy_team_commands.py). + register_economy_team_commands(tree, bot, log) register_ops_admin_commands( tree, diff --git a/commands/dev_member_commands.py b/commands/dev_member_commands.py index c60d4e7..4cedbd7 100644 --- a/commands/dev_member_commands.py +++ b/commands/dev_member_commands.py @@ -182,10 +182,6 @@ def register_dev_member_commands( except Exception as e: await interaction.followup.send(S.ERR["sheet_error"].format(error=e), ephemeral=True) return - try: - await sheets.refresh_teams() - except Exception as e: - log.warning("/check: team sheet refresh failed, using stale team cache: %s", e) ids_filled = 0 for row in data: @@ -237,8 +233,6 @@ def register_dev_member_commands( parts.append(S.CHECK_UI["detail_nickname"]) if result.roles_added: parts.append(S.CHECK_UI["detail_roles_added"].format(roles=", ".join(result.roles_added))) - if result.roles_removed: - parts.append(S.CHECK_UI["detail_roles_removed"].format(roles=", ".join(result.roles_removed))) details.append(S.CHECK_UI["detail_changed"].format(name=member.display_name, parts=", ".join(parts))) else: already_ok += 1 diff --git a/commands/economy_team_commands.py b/commands/economy_team_commands.py new file mode 100644 index 0000000..6559c7b --- /dev/null +++ b/commands/economy_team_commands.py @@ -0,0 +1,87 @@ +"""Tournament team-role sync for the economy/community guild. + +The tournament participants live in the *economy* (community) guild, not the +internal dev guild, so team-role assignment runs here rather than as part of the +member-roster sync in :mod:`commands.dev_member_commands`. This is deliberately +roster-INDEPENDENT: it matches Discord usernames straight against the separate +registration spreadsheet (``TEAM_SHEET_ID``) and never touches the member sheet. +""" + +from __future__ import annotations + +import logging + +import discord +from discord import app_commands + +from core import sheets +from core.admin import bot_admin_check +from core.member_sync import sync_all_team_roles +import strings as S + + +def register_economy_team_commands( + tree: app_commands.CommandTree, + bot: discord.Client, + log: logging.Logger, +) -> None: + @tree.command(name="teamsync", description=S.CMD["teamsync"]) + @app_commands.guild_only() + @bot_admin_check() + async def cmd_teamsync(interaction: discord.Interaction): + await interaction.response.defer(ephemeral=True) + + guild = interaction.guild + if guild is None: + await interaction.followup.send(S.ERR["guild_only"], ephemeral=True) + return + + try: + rosters = await sheets.refresh_teams() + except Exception as e: + await interaction.followup.send( + S.TEAMSYNC_UI["refresh_error"].format(error=e), ephemeral=True + ) + return + if not rosters: + await interaction.followup.send(S.TEAMSYNC_UI["disabled"], ephemeral=True) + return + + summary = await sync_all_team_roles(guild, log) + await interaction.followup.send(_format_summary(summary), ephemeral=True) + log.info( + "/teamsync - scanned=%d, assigned=%d, removed=%d, created=%d, errors=%d", + summary.scanned, + summary.assigned, + summary.removed, + len(summary.created), + len(summary.errors), + ) + + +def _format_summary(summary) -> str: + lines = [ + S.TEAMSYNC_UI["done"], + S.TEAMSYNC_UI["scanned"].format(count=summary.scanned), + S.TEAMSYNC_UI["assigned"].format(count=summary.assigned), + S.TEAMSYNC_UI["removed"].format(count=summary.removed), + ] + if summary.created: + # A team can be created only once, but the same role could surface for + # several members in one run - de-dupe for the report. + unique = list(dict.fromkeys(summary.created)) + lines.append(S.TEAMSYNC_UI["created"].format(roles=", ".join(unique))) + if summary.errors: + lines.append(S.TEAMSYNC_UI["errors"].format(count=len(summary.errors))) + + text = "\n".join(lines) + + if summary.changes: + shown = summary.changes[:20] + text += "\n\n" + S.TEAMSYNC_UI["changes_header"] + "\n" + "\n".join(shown) + if len(summary.changes) > 20: + text += "\n" + S.TEAMSYNC_UI["changes_more"].format(count=len(summary.changes) - 20) + else: + text += "\n\n" + S.TEAMSYNC_UI["no_changes"] + + return text diff --git a/core/member_sync.py b/core/member_sync.py index 47eb4a1..0c89117 100644 --- a/core/member_sync.py +++ b/core/member_sync.py @@ -48,7 +48,6 @@ class SyncResult: """Tracks what happened during a sync operation.""" nickname_changed: bool = False roles_added: list[str] = field(default_factory=list) - roles_removed: list[str] = field(default_factory=list) birthday_soon: bool = False birthday_today: bool = False not_found: bool = False @@ -57,7 +56,31 @@ class SyncResult: @property def changed(self) -> bool: - return self.nickname_changed or self.roles_added or self.roles_removed + return self.nickname_changed or self.roles_added + + +@dataclass +class TeamSyncResult: + """What happened when syncing one member's tournament team role.""" + added: str | None = None # team role name granted, if any + removed: list[str] = field(default_factory=list) # stale team roles taken away + created: str | None = None # team role name auto-created in the guild, if any + errors: list[str] = field(default_factory=list) + + @property + def changed(self) -> bool: + return bool(self.added or self.removed) + + +@dataclass +class TeamSyncSummary: + """Aggregate outcome of a whole-guild team-role sync.""" + scanned: int = 0 + assigned: int = 0 + removed: int = 0 + created: list[str] = field(default_factory=list) + changes: list[str] = field(default_factory=list) # human-readable per-member lines + errors: list[str] = field(default_factory=list) def _format_nickname(full_name: str) -> str: @@ -199,35 +222,9 @@ async def sync_member( else: result.errors.append(f"Baasrolli ID {rid} ei leitud serverist") - # --- Team role (from the separate registration spreadsheet) -------------- - # Matched by Discord username. One team per person: switching teams removes - # the previous team's role; a team with no Discord role yet is auto-created. - # Only role NAMES that appear in the team sheet are ever touched here, so - # organisation/field/base roles are never at risk. When TEAM_SHEET_ID is - # unset the caches are empty and this whole block is a no-op. - team_name = sheets.get_team_for_username(member.name) - all_teams = sheets.all_team_names() - if team_name: - team_role = discord.utils.get(guild.roles, name=team_name) - if team_role is None: - try: - team_role = await guild.create_role(name=team_name, reason="Team sync: uus tiim") - log.info("Created team role %r for %s", team_name, member) - except discord.Forbidden: - result.errors.append(f"Tiimirolli '{team_name}' loomiseks puudub õigus") - team_role = None - except discord.HTTPException as e: - result.errors.append(f"Tiimirolli '{team_name}' loomine ebaõnnestus: {e}") - team_role = None - if team_role is not None: - desired_roles.append(team_role) - - # Team roles the member has but is no longer registered for (left/switched). - to_remove = [r for r in member.roles if r.name in all_teams and r.name != team_name] - # Roles to add (desired but member doesn't have) to_add = [r for r in desired_roles if r not in member.roles] - # (outside of team roles we only ADD, never remove extras - safe default) + # (we currently only ADD the desired roles, not remove extras - safe default) if to_add: try: @@ -238,15 +235,6 @@ async def sync_member( except discord.HTTPException as e: result.errors.append(f"Rolli viga kasutajale {member}: {e}") - if to_remove: - try: - await member.remove_roles(*to_remove, reason="Team sync: tiim vahetus") - result.roles_removed = [r.name for r in to_remove] - except discord.Forbidden: - log.debug("No permission to remove roles for %s (likely admin), skipping", member) - except discord.HTTPException as e: - result.errors.append(f"Rolli eemaldamise viga kasutajale {member}: {e}") - # --- Birthday check --- birthday_str = str(row.get("Sünnipäev", "")).strip() if not _is_placeholder(birthday_str): @@ -259,6 +247,101 @@ async def sync_member( return result +async def sync_team_role( + member: discord.Member, + guild: discord.Guild, +) -> TeamSyncResult: + """Give one member their tournament team role from the registration sheet. + + Roster-INDEPENDENT: unlike :func:`sync_member` this does not touch the + internal member sheet at all. It matches the member's Discord username + against the team sheet caches (populated by ``sheets.refresh_teams``) and: + + * grants the role for the team they're registered on (auto-creating that + role in the guild when it does not exist yet); + * removes any *other* team role they still carry (left / switched teams). + + Only role NAMES present in the team sheet are ever added or removed, so no + unrelated role is ever at risk. When ``TEAM_SHEET_ID`` is unset the caches + are empty and this is a no-op returning an unchanged result. + """ + result = TeamSyncResult() + + team_name = sheets.get_team_for_username(member.name) + all_teams = sheets.all_team_names() + if not all_teams: + return result # feature switched off (no team sheet loaded) + + desired: discord.Role | None = None + if team_name: + desired = discord.utils.get(guild.roles, name=team_name) + if desired is None: + try: + desired = await guild.create_role(name=team_name, reason="Team sync: uus tiim") + result.created = team_name + log.info("Created team role %r for %s", team_name, member) + except discord.Forbidden: + result.errors.append(f"Tiimirolli '{team_name}' loomiseks puudub õigus") + except discord.HTTPException as e: + result.errors.append(f"Tiimirolli '{team_name}' loomine ebaõnnestus: {e}") + + # Team roles held but no longer registered for (switched teams / dropped out). + to_remove = [r for r in member.roles if r.name in all_teams and r.name != team_name] + + if desired is not None and desired not in member.roles: + try: + await member.add_roles(desired, reason="Team sync") + result.added = desired.name + except discord.Forbidden: + log.debug("No permission to add team role for %s, skipping", member) + except discord.HTTPException as e: + result.errors.append(f"Tiimirolli viga kasutajale {member}: {e}") + + if to_remove: + try: + await member.remove_roles(*to_remove, reason="Team sync: tiim vahetus") + result.removed = [r.name for r in to_remove] + except discord.Forbidden: + log.debug("No permission to remove team roles for %s, skipping", member) + except discord.HTTPException as e: + result.errors.append(f"Tiimirolli eemaldamise viga kasutajale {member}: {e}") + + return result + + +async def sync_all_team_roles( + guild: discord.Guild, + log: logging.Logger = log, +) -> TeamSyncSummary: + """Run :func:`sync_team_role` for every human member of ``guild``. + + Assumes the team caches are already fresh (caller runs ``refresh_teams`` + first). Returns an aggregate summary for reporting. + """ + summary = TeamSyncSummary() + for member in guild.members: + if member.bot: + continue + summary.scanned += 1 + res = await sync_team_role(member, guild) + if res.created: + summary.created.append(res.created) + if res.errors: + summary.errors.extend(res.errors) + if res.added: + summary.assigned += 1 + if res.removed: + summary.removed += len(res.removed) + if res.changed: + bits: list[str] = [] + if res.added: + bits.append(f"+{res.added}") + if res.removed: + bits.append("-" + ", -".join(res.removed)) + summary.changes.append(f"{member.display_name}: {', '.join(bits)}") + return summary + + async def announce_birthday( member: discord.Member, bot: discord.Client, diff --git a/strings/__init__.py b/strings/__init__.py index 4c65c3a..7841026 100644 --- a/strings/__init__.py +++ b/strings/__init__.py @@ -44,6 +44,7 @@ from .member import ( BIRTHDAY_UI, BIRTHDAY_MONTHS, CHECK_UI, + TEAMSYNC_UI, ) from .economy import ( @@ -139,6 +140,7 @@ __all__ = [ 'BIRTHDAY_UI', 'BIRTHDAY_MONTHS', 'CHECK_UI', + 'TEAMSYNC_UI', 'WORK_JOBS', 'BEG_LINES', 'BEG_JAIL_LINES', diff --git a/strings/commands.py b/strings/commands.py index e3a9a28..7c9ce03 100644 --- a/strings/commands.py +++ b/strings/commands.py @@ -24,6 +24,7 @@ CMD: dict[str, str] = { "check": "Laadi andmed, täida ID'd ja sünkroniseeri kõik liikmed", "sync": "Sünkroniseeri käsklused Discordi serveriga", "member": "Näita liikme andmeid tabelist", + "teamsync": "[Admin] Sünkroniseeri tiimirollid registreerimistabelist", "restart": "Tee taaskäivitus botile", "shutdown": "Lülita bot välja (ilma taaskäivituseta)", "pause": "Peata / jätka kõik käsklused (hooldusrežiim)", diff --git a/strings/member.py b/strings/member.py index b86e3e5..a604887 100644 --- a/strings/member.py +++ b/strings/member.py @@ -11,6 +11,7 @@ __all__ = [ 'BIRTHDAY_UI', 'BIRTHDAY_MONTHS', 'CHECK_UI', + 'TEAMSYNC_UI', ] # --------------------------------------------------------------------------- @@ -91,7 +92,25 @@ CHECK_UI: dict[str, str] = { "detail_error": "⚠️ {error}", "detail_nickname": "hüüdnimi", "detail_roles_added": "+rollid: {roles}", - "detail_roles_removed": "-rollid: {roles}", "detail_changed": "🔧 **{name}**: {parts}", "ids_filled": "\n🔑 Täideti **{count}** puuduvat kasutaja ID-d.", } + + +# --------------------------------------------------------------------------- +# /teamsync UI strings (tournament team-role sync from the registration sheet) +# --------------------------------------------------------------------------- + +TEAMSYNC_UI: dict[str, str] = { + "disabled": "⚠️ Tiimide sünkroonimine on välja lülitatud (TEAM_SHEET_ID puudub).", + "refresh_error": "⚠️ Registreerimistabeli laadimine ebaõnnestus: {error}", + "done": "**Tiimide sünkroonimine lõpetatud!**", + "scanned": "👥 Kontrollitud liikmeid: {count}", + "assigned": "✅ Tiimirolle antud: {count}", + "removed": "➖ Tiimirolle eemaldatud: {count}", + "created": "🆕 Loodud uusi tiimirolle: {roles}", + "errors": "⚠️ Vead: {count}", + "no_changes": "✨ Kõik tiimirollid olid juba korras.", + "changes_header": "**Muudatused:**", + "changes_more": "... ja {count} rohkem", +} diff --git a/tests/test_team_sync.py b/tests/test_team_sync.py index 9c2b178..85f781e 100644 --- a/tests/test_team_sync.py +++ b/tests/test_team_sync.py @@ -1,8 +1,9 @@ """Tests for team-role sync from the tournament registration sheet. Covers the risky parsing (turning a messy, multi-section, merged-cell sheet -into {team: [discord usernames]}) and the add/remove/auto-create behaviour of -sync_member, using lightweight fakes for discord + the sheets cache. +into {team: [discord usernames]}) and the roster-independent add/remove/ +auto-create behaviour of sync_team_role, using lightweight fakes for discord ++ the sheets cache. """ from __future__ import annotations @@ -10,11 +11,8 @@ from __future__ import annotations import sys from pathlib import Path -import pytest - sys.path.insert(0, str(Path(__file__).parent.parent)) -import config # noqa: E402 from core import member_sync, sheets # noqa: E402 from tests.conftest import run # noqa: E402 @@ -94,7 +92,7 @@ def test_build_username_index_is_case_insensitive(): assert index == {"kapa": "GENESIS", "neaq": "GENESIS"} -# --- sync_member team behaviour ------------------------------------------- +# --- sync_team_role behaviour (roster-independent) ------------------------- class FakeRole: def __init__(self, rid: int, name: str): @@ -109,15 +107,13 @@ class FakeRole: class FakeMember: - def __init__(self, uid: int, name: str, roles): + def __init__(self, uid: int, name: str, roles, bot: bool = False): self.id = uid self.name = name - self.nick = None + self.display_name = name + self.bot = bot self.roles = list(roles) - async def edit(self, nick=None): - self.nick = nick - async def add_roles(self, *roles, reason=None): self.roles.extend(roles) @@ -126,14 +122,12 @@ class FakeMember: class FakeGuild: - def __init__(self, roles): + def __init__(self, roles, members=None): self.roles = list(roles) + self.members = list(members or []) self._next = 9000 self.created: list[str] = [] - def get_role(self, rid): - return next((r for r in self.roles if r.id == rid), None) - async def create_role(self, name, reason=None): self._next += 1 role = FakeRole(self._next, name) @@ -142,84 +136,89 @@ class FakeGuild: return role -@pytest.fixture -def team_env(monkeypatch): - """Base roles present in the guild; no organisation/field/birthday noise.""" - base_roles = [FakeRole(rid, f"base-{rid}") for rid in config.BASE_ROLE_IDS] - - def make_row(name): - # User ID + Discord match the member so no sheet writes fire; everything - # else is a placeholder so only the team role logic is exercised. - return { - "User ID": "1", "Discord": name, "Nimi": "-", - "Organisatsioon": "-", "Valdkond": "-", "Roll": "-", "Sünnipäev": "-", - } - - monkeypatch.setattr(sheets, "find_member", lambda uid, name: make_row(name)) - return base_roles - - -def test_sync_creates_missing_team_role_and_removes_old_one(team_env, monkeypatch): +def test_sync_creates_missing_team_role_and_removes_old_one(monkeypatch): old_team = FakeRole(1, "OldTeam") keeper = FakeRole(2, "Member") # not a team role - must be left alone member = FakeMember(1, "tft", roles=[old_team, keeper]) - guild = FakeGuild(team_env + [old_team, keeper]) + guild = FakeGuild([old_team, keeper]) monkeypatch.setattr(sheets, "get_team_for_username", lambda n: "GENESIS" if n.lower() == "tft" else None) monkeypatch.setattr(sheets, "all_team_names", lambda: {"GENESIS", "OldTeam"}) - result = run(member_sync.sync_member(member, guild)) + result = run(member_sync.sync_team_role(member, guild)) - assert "GENESIS" in guild.created # auto-created the missing role - assert "GENESIS" in result.roles_added - assert result.roles_removed == ["OldTeam"] # left their previous team + assert result.created == "GENESIS" # auto-created the missing role + assert "GENESIS" in guild.created + assert result.added == "GENESIS" + assert result.removed == ["OldTeam"] # left their previous team role_names = {r.name for r in member.roles} assert "GENESIS" in role_names assert "OldTeam" not in role_names assert "Member" in role_names # unrelated role untouched -def test_sync_uses_existing_team_role(team_env, monkeypatch): +def test_sync_uses_existing_team_role(monkeypatch): genesis = FakeRole(3, "GENESIS") member = FakeMember(1, "kapa", roles=[]) - guild = FakeGuild(team_env + [genesis]) + guild = FakeGuild([genesis]) monkeypatch.setattr(sheets, "get_team_for_username", lambda n: "GENESIS") monkeypatch.setattr(sheets, "all_team_names", lambda: {"GENESIS"}) - result = run(member_sync.sync_member(member, guild)) + result = run(member_sync.sync_team_role(member, guild)) assert guild.created == [] # did NOT create a duplicate - assert "GENESIS" in result.roles_added + assert result.created is None + assert result.added == "GENESIS" assert genesis in member.roles -def test_sync_strips_team_role_when_not_registered(team_env, monkeypatch): +def test_sync_strips_team_role_when_not_registered(monkeypatch): old_team = FakeRole(1, "OldTeam") member = FakeMember(1, "ghost", roles=[old_team]) - guild = FakeGuild(team_env + [old_team]) + guild = FakeGuild([old_team]) monkeypatch.setattr(sheets, "get_team_for_username", lambda n: None) monkeypatch.setattr(sheets, "all_team_names", lambda: {"OldTeam"}) - result = run(member_sync.sync_member(member, guild)) + result = run(member_sync.sync_team_role(member, guild)) - assert result.roles_removed == ["OldTeam"] + assert result.removed == ["OldTeam"] + assert result.added is None assert old_team not in member.roles -def test_sync_no_team_sheet_is_noop(team_env, monkeypatch): +def test_sync_no_team_sheet_is_noop(monkeypatch): keeper = FakeRole(2, "Member") member = FakeMember(1, "someone", roles=[keeper]) - guild = FakeGuild(team_env + [keeper]) + guild = FakeGuild([keeper]) - # Empty caches = feature switched off. + # Empty caches = feature switched off: no removals even of a stale team role. monkeypatch.setattr(sheets, "get_team_for_username", lambda n: None) monkeypatch.setattr(sheets, "all_team_names", lambda: set()) - result = run(member_sync.sync_member(member, guild)) + result = run(member_sync.sync_team_role(member, guild)) - assert result.roles_removed == [] + assert result.removed == [] assert guild.created == [] assert keeper in member.roles + + +def test_sync_all_team_roles_aggregates_and_skips_bots(monkeypatch): + genesis = FakeRole(3, "GENESIS") + m1 = FakeMember(1, "kapa", roles=[]) # will get GENESIS + m2 = FakeMember(2, "nobody", roles=[]) # not registered, unchanged + bot_member = FakeMember(3, "botto", roles=[], bot=True) # skipped + guild = FakeGuild([genesis], members=[m1, m2, bot_member]) + + teams = {"kapa": "GENESIS"} + monkeypatch.setattr(sheets, "get_team_for_username", lambda n: teams.get(n.lower())) + monkeypatch.setattr(sheets, "all_team_names", lambda: {"GENESIS"}) + + summary = run(member_sync.sync_all_team_roles(guild)) + + assert summary.scanned == 2 # bot excluded + assert summary.assigned == 1 + assert summary.removed == 0 + assert summary.changes == ["kapa: +GENESIS"]