2 Commits

Author SHA1 Message Date
Rene Arumetsa
bd3fb7d3b8 feat(teams): add opt-in /teamsync reposition to tidy existing team roles
Creation-time placement only positions NEW team roles. Team roles made before
divider placement existed (sitting at the bottom of the role list) stayed put.

Add `reposition_team_roles(guild)` and expose it via `/teamsync reposition:true`
(off by default, so the hourly task never reorders roles on its own). It moves
each EXISTING team role under its game's divider, sheet-driven:

- a role's game/divider comes from sheets.get_game_for_team;
- roles whose team is no longer in the sheet (or game unknown) are left alone;
- idempotent: a role already within its section band (below its own divider,
  above the next divider down) is skipped, so re-runs move nothing.

Adds RepositionSummary, TEAMSYNC_UI/OPT strings, and tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R6JZkyszyDFuFtk25WBbcR
2026-08-28 16:05:38 +03:00
Rene Arumetsa
e179a35fc4 feat(teams): place new team roles under their game's divider role
Auto-created team roles now land directly under the CS2 / LoL "divider" role
in the community guild's role list, keeping it grouped by game.

- config: CS2_DIVIDER_ROLE_ID / LOL_DIVIDER_ROLE_ID (season-specific, env
  overridable) exposed as TEAM_DIVIDER_ROLE_IDS keyed by game code.
- core/sheets: tag each team with its game (CS2/LoL) from the section title it
  sits under; shared _scan_sections generator feeds both parse_team_rosters and
  the new parse_team_games; get_game_for_team accessor + _team_game cache.
- core/member_sync: on creating a team role, position it at its game divider's
  slot (best-effort; left in place if game/divider unknown or move refused).
- .env.example + tests for game tagging and role placement.

Each participant is in exactly one game, so one role per team is unambiguous.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R6JZkyszyDFuFtk25WBbcR
2026-08-28 15:51:48 +03:00
8 changed files with 306 additions and 13 deletions

View File

@@ -14,6 +14,12 @@ SHEET_ID=your-google-sheet-id-here
# the economy/community bot. Leave unset to disable team-role sync entirely. # the economy/community bot. Leave unset to disable team-role sync entirely.
TEAM_SHEET_ID= TEAM_SHEET_ID=
# Team-section "divider" role IDs. A newly-created team role is positioned
# directly under the divider for its game (CS2 / LoL). Season-specific - update
# yearly. 0 = leave that game's new roles at the bottom of the role list.
CS2_DIVIDER_ROLE_ID=
LOL_DIVIDER_ROLE_ID=
# Path to Google service account credentials JSON # Path to Google service account credentials JSON
GOOGLE_CREDS_PATH=credentials.json GOOGLE_CREDS_PATH=credentials.json

View File

@@ -16,7 +16,7 @@ from discord import app_commands
from core import sheets from core import sheets
from core.admin import bot_admin_check from core.admin import bot_admin_check
from core.member_sync import sync_all_team_roles from core.member_sync import reposition_team_roles, sync_all_team_roles
import strings as S import strings as S
@@ -27,8 +27,9 @@ def register_economy_team_commands(
) -> None: ) -> None:
@tree.command(name="teamsync", description=S.CMD["teamsync"]) @tree.command(name="teamsync", description=S.CMD["teamsync"])
@app_commands.guild_only() @app_commands.guild_only()
@app_commands.describe(reposition=S.OPT["teamsync_reposition"])
@bot_admin_check() @bot_admin_check()
async def cmd_teamsync(interaction: discord.Interaction): async def cmd_teamsync(interaction: discord.Interaction, reposition: bool = False):
await interaction.response.defer(ephemeral=True) await interaction.response.defer(ephemeral=True)
guild = interaction.guild guild = interaction.guild
@@ -48,14 +49,22 @@ def register_economy_team_commands(
return return
summary = await sync_all_team_roles(guild, log) summary = await sync_all_team_roles(guild, log)
await interaction.followup.send(_format_summary(summary), ephemeral=True) message = _format_summary(summary)
repo = None
if reposition:
repo = await reposition_team_roles(guild, log)
message += "\n\n" + _format_reposition(repo)
await interaction.followup.send(message, ephemeral=True)
log.info( log.info(
"/teamsync - scanned=%d, assigned=%d, removed=%d, created=%d, errors=%d", "/teamsync - scanned=%d, assigned=%d, removed=%d, created=%d, errors=%d%s",
summary.scanned, summary.scanned,
summary.assigned, summary.assigned,
summary.removed, summary.removed,
len(summary.created), len(summary.created),
len(summary.errors), len(summary.errors),
f", repositioned={repo.moved}" if repo else "",
) )
@@ -85,3 +94,17 @@ def _format_summary(summary) -> str:
text += "\n\n" + S.TEAMSYNC_UI["no_changes"] text += "\n\n" + S.TEAMSYNC_UI["no_changes"]
return text return text
def _format_reposition(repo) -> str:
lines = [S.TEAMSYNC_UI["reposition_header"]]
if not repo.moved:
lines.append(S.TEAMSYNC_UI["reposition_none"])
else:
lines.append(S.TEAMSYNC_UI["repositioned"].format(count=repo.moved))
lines.extend(repo.moves[:20])
if len(repo.moves) > 20:
lines.append(S.TEAMSYNC_UI["changes_more"].format(count=len(repo.moves) - 20))
if repo.errors:
lines.append(S.TEAMSYNC_UI["errors"].format(count=len(repo.errors)))
return "\n".join(lines)

View File

@@ -45,6 +45,19 @@ BIRTHDAY_CHANNEL_ID = (
BIRTHDAY_WINDOW_DAYS = int(os.getenv("BIRTHDAY_WINDOW_DAYS", "7")) BIRTHDAY_WINDOW_DAYS = int(os.getenv("BIRTHDAY_WINDOW_DAYS", "7"))
BASE_ROLE_IDS: list[int] = [1478304631930228779, 1478302278862766190] BASE_ROLE_IDS: list[int] = [1478304631930228779, 1478302278862766190]
# Team-section "divider" roles in the community guild. When a new team role is
# auto-created it is positioned directly under the divider for its game, so the
# role list stays grouped by game. Season-specific - update yearly (or override
# via CS2_DIVIDER_ROLE_ID / LOL_DIVIDER_ROLE_ID). 0 disables placement for that
# game (the role is still created, just left at the bottom of the list).
CS2_DIVIDER_ROLE_ID = _env_int("CS2_DIVIDER_ROLE_ID", 1498736834656604251)
LOL_DIVIDER_ROLE_ID = _env_int("LOL_DIVIDER_ROLE_ID", 1498736949706490017)
# Keyed by the canonical game code produced by core.sheets._detect_game.
TEAM_DIVIDER_ROLE_IDS: dict[str, int] = {
"CS2": CS2_DIVIDER_ROLE_ID,
"LoL": LOL_DIVIDER_ROLE_ID,
}
def _parse_admin_roles(raw: str) -> dict[int, set[int]]: def _parse_admin_roles(raw: str) -> dict[int, set[int]]:
"""Parse DISCORD_ADMIN_ROLES env var as "guild_id:role_id[:role_id...],guild_id:role_id...". """Parse DISCORD_ADMIN_ROLES env var as "guild_id:role_id[:role_id...],guild_id:role_id...".

View File

@@ -83,6 +83,15 @@ class TeamSyncSummary:
errors: list[str] = field(default_factory=list) errors: list[str] = field(default_factory=list)
@dataclass
class RepositionSummary:
"""Aggregate outcome of the opt-in existing-team-role reposition pass."""
scanned: int = 0 # existing team roles considered
moved: int = 0 # roles relocated under their divider
moves: list[str] = field(default_factory=list) # "role -> game" lines
errors: list[str] = field(default_factory=list)
def _format_nickname(full_name: str) -> str: def _format_nickname(full_name: str) -> str:
"""Format a nickname from a full name: first name + last name initial. """Format a nickname from a full name: first name + last name initial.
@@ -247,6 +256,35 @@ async def sync_member(
return result return result
async def _position_team_role_under_divider(
guild: discord.Guild,
role: discord.Role,
team_name: str,
result: TeamSyncResult,
) -> None:
"""Move a freshly-created team role directly under its game's divider role.
The team's game comes from the registration sheet; the divider role IDs come
from config (``TEAM_DIVIDER_ROLE_IDS``). Best-effort: if the game/divider is
unknown or the move is refused, the role just stays where it was created.
"""
game = sheets.get_game_for_team(team_name)
divider_id = config.TEAM_DIVIDER_ROLE_IDS.get(game) if game else None
if not divider_id:
return
divider = guild.get_role(divider_id)
if divider is None:
log.warning("Divider role %s for game %s not found in guild", divider_id, game)
return
try:
await role.edit(position=divider.position, reason="Team sync: paiguta tiimide alla")
log.info("Positioned team role %r under the %s divider", team_name, game)
except discord.Forbidden:
result.errors.append(f"Tiimirolli '{team_name}' paigutamiseks puudub õigus")
except discord.HTTPException as e:
result.errors.append(f"Tiimirolli '{team_name}' paigutamine ebaõnnestus: {e}")
async def sync_team_role( async def sync_team_role(
member: discord.Member, member: discord.Member,
guild: discord.Guild, guild: discord.Guild,
@@ -284,6 +322,8 @@ async def sync_team_role(
result.errors.append(f"Tiimirolli '{team_name}' loomiseks puudub õigus") result.errors.append(f"Tiimirolli '{team_name}' loomiseks puudub õigus")
except discord.HTTPException as e: except discord.HTTPException as e:
result.errors.append(f"Tiimirolli '{team_name}' loomine ebaõnnestus: {e}") result.errors.append(f"Tiimirolli '{team_name}' loomine ebaõnnestus: {e}")
if result.created and desired is not None:
await _position_team_role_under_divider(guild, desired, team_name, result)
# Team roles held but no longer registered for (switched teams / dropped out). # 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] to_remove = [r for r in member.roles if r.name in all_teams and r.name != team_name]
@@ -342,6 +382,69 @@ async def sync_all_team_roles(
return summary return summary
def _resolve_dividers(guild: discord.Guild) -> dict[str, discord.Role]:
"""Map each configured game code to its divider role present in the guild."""
dividers: dict[str, discord.Role] = {}
for game, rid in config.TEAM_DIVIDER_ROLE_IDS.items():
if not rid:
continue
role = guild.get_role(rid)
if role is not None:
dividers[game] = role
return dividers
async def reposition_team_roles(
guild: discord.Guild,
log: logging.Logger = log,
) -> RepositionSummary:
"""Move EXISTING team roles under their game's divider (opt-in cleanup).
Unlike creation-time placement this also relocates team roles that were made
before divider placement existed and are sitting at the bottom of the list.
Sheet-driven: a role's game (and thus its divider) comes from
``sheets.get_game_for_team``; roles whose team is no longer in the sheet, or
whose game/divider is unknown, are left untouched.
Idempotent: a role already within its section band (below its own divider and
above the next divider down) is skipped, so re-running moves nothing.
"""
summary = RepositionSummary()
teams = sheets.all_team_names()
dividers = _resolve_dividers(guild)
if not teams or not dividers:
return summary
for role in list(guild.roles):
if role.name not in teams:
continue
summary.scanned += 1
game = sheets.get_game_for_team(role.name)
divider = dividers.get(game) if game else None
if divider is None:
continue # unknown game / divider not in guild -> leave in place
# Section band = (highest divider below this one, this divider). A role
# already inside it is grouped correctly; only relocate outliers.
lower = max(
(d.position for d in dividers.values() if d.position < divider.position),
default=0,
)
if lower < role.position < divider.position:
continue
try:
await role.edit(position=divider.position, reason="Team sync: reposition")
summary.moved += 1
summary.moves.append(f"{role.name} -> {game}")
log.info("Repositioned team role %r under the %s divider", role.name, game)
except discord.Forbidden:
summary.errors.append(f"Tiimirolli '{role.name}' paigutamiseks puudub õigus")
except discord.HTTPException as e:
summary.errors.append(f"Tiimirolli '{role.name}' paigutamine ebaõnnestus: {e}")
return summary
async def announce_birthday( async def announce_birthday(
member: discord.Member, member: discord.Member,
bot: discord.Client, bot: discord.Client,

View File

@@ -275,10 +275,20 @@ _CITIZENSHIP_RE = re.compile(r"\(\s*[A-Za-z]{2,4}\s*\)")
_TEAM_NAME_HEADER = "team name" _TEAM_NAME_HEADER = "team name"
_LINEUP_HEADER_PREFIX = "lineup" _LINEUP_HEADER_PREFIX = "lineup"
# Canonical game code -> substrings that identify that game's section title.
# The sheet stacks a CS2 section and a LoL section, each introduced by a title
# row like "TipiLAN 2026 CS2 Registration Log"; teams are tagged with the game
# of the section they sit under so their Discord role can be placed accordingly.
_GAME_KEYWORDS: list[tuple[str, tuple[str, ...]]] = [
("CS2", ("cs2", "counter-strike", "counter strike", "csgo", "cs:go")),
("LoL", ("lol", "league of legends", "league")),
]
# Team-sheet caches (mirrors the member-roster cache above) # Team-sheet caches (mirrors the member-roster cache above)
_team_roster: dict[str, list[str]] = {} # team name -> [Discord username, ...] _team_roster: dict[str, list[str]] = {} # team name -> [Discord username, ...]
_team_by_username: dict[str, str] = {} # normalized username -> team name _team_by_username: dict[str, str] = {} # normalized username -> team name
_team_names: set[str] = set() # universe of all team names _team_names: set[str] = set() # universe of all team names
_team_game: dict[str, str] = {} # team name -> game code ("CS2"/"LoL")
def parse_lineup(cell: str) -> list[str]: def parse_lineup(cell: str) -> list[str]:
@@ -321,20 +331,34 @@ def _cell(row: list, idx: int) -> str:
return str(row[idx]) if 0 <= idx < len(row) else "" return str(row[idx]) if 0 <= idx < len(row) else ""
def parse_team_rosters(rows: list[list]) -> dict[str, list[str]]: def _detect_game(row: list) -> str | None:
"""Extract {team_name: [nickname, ...]} from a tab's raw rows. """Classify a row as a CS2 / LoL section title, or None if it's neither."""
text = " ".join(str(c) for c in row).lower()
for game, keys in _GAME_KEYWORDS:
if any(k in text for k in keys):
return game
return None
def _scan_sections(rows: list[list]):
"""Yield ``(team, players, game)`` for every team row across the sheet.
Scans for every header row that has both a 'Team Name' and a 'Lineup...' Scans for every header row that has both a 'Team Name' and a 'Lineup...'
column, then reads the rows beneath it (using that section's own column column, then reads the rows beneath it (using that section's own column
positions) until the team-name column goes blank or a new header appears. positions) until the team-name column goes blank or a new header appears.
Handles multiple stacked sections with differing layouts in one tab. Handles multiple stacked sections with differing layouts in one tab. The
most recent game section title seen (via :func:`_detect_game`) tags every
team in the block that follows.
""" """
rosters: dict[str, list[str]] = {} current_game: str | None = None
i, n = 0, len(rows) i, n = 0, len(rows)
while i < n: while i < n:
name_col = _find_col(rows[i], lambda c: c.strip().lower() == _TEAM_NAME_HEADER) name_col = _find_col(rows[i], lambda c: c.strip().lower() == _TEAM_NAME_HEADER)
lineup_col = _find_col(rows[i], lambda c: c.strip().lower().startswith(_LINEUP_HEADER_PREFIX)) lineup_col = _find_col(rows[i], lambda c: c.strip().lower().startswith(_LINEUP_HEADER_PREFIX))
if name_col is None or lineup_col is None: if name_col is None or lineup_col is None:
game = _detect_game(rows[i]) # title / meta row - may name the game
if game:
current_game = game
i += 1 i += 1
continue continue
i += 1 # move past the header into the data block i += 1 # move past the header into the data block
@@ -343,12 +367,28 @@ def parse_team_rosters(rows: list[list]) -> dict[str, list[str]]:
if not team or team.lower() == _TEAM_NAME_HEADER: if not team or team.lower() == _TEAM_NAME_HEADER:
break # blank team-name (or a new header) ends this section break # blank team-name (or a new header) ends this section
players = parse_lineup(_cell(rows[i], lineup_col)) players = parse_lineup(_cell(rows[i], lineup_col))
yield team, players, current_game
i += 1
def parse_team_rosters(rows: list[list]) -> dict[str, list[str]]:
"""Extract {team_name: [nickname, ...]} from a tab's raw rows."""
rosters: dict[str, list[str]] = {}
for team, players, _game in _scan_sections(rows):
if players: if players:
rosters.setdefault(team, []).extend(players) rosters.setdefault(team, []).extend(players)
i += 1
return rosters return rosters
def parse_team_games(rows: list[list]) -> dict[str, str]:
"""Extract {team_name: game_code} from a tab's raw rows (first game wins)."""
games: dict[str, str] = {}
for team, _players, game in _scan_sections(rows):
if game and team not in games:
games[team] = game
return games
def build_username_index(rosters: dict[str, list[str]]) -> dict[str, str]: def build_username_index(rosters: dict[str, list[str]]) -> dict[str, str]:
"""Invert {team: [usernames]} into {normalized username: team}. """Invert {team: [usernames]} into {normalized username: team}.
@@ -371,19 +411,24 @@ def build_username_index(rosters: dict[str, list[str]]) -> dict[str, str]:
def _refresh_teams_sync() -> dict[str, list[str]]: def _refresh_teams_sync() -> dict[str, list[str]]:
global _team_roster, _team_by_username, _team_names global _team_roster, _team_by_username, _team_names, _team_game
creds = Credentials.from_service_account_file(config.GOOGLE_CREDS_PATH, scopes=SCOPES) creds = Credentials.from_service_account_file(config.GOOGLE_CREDS_PATH, scopes=SCOPES)
client = gspread.authorize(creds) client = gspread.authorize(creds)
spreadsheet = client.open_by_key(config.TEAM_SHEET_ID) spreadsheet = client.open_by_key(config.TEAM_SHEET_ID)
rosters: dict[str, list[str]] = {} rosters: dict[str, list[str]] = {}
games: dict[str, str] = {}
for ws in spreadsheet.worksheets(): for ws in spreadsheet.worksheets():
for team, players in parse_team_rosters(ws.get_all_values()).items(): vals = ws.get_all_values()
for team, players in parse_team_rosters(vals).items():
rosters.setdefault(team, []).extend(players) rosters.setdefault(team, []).extend(players)
for team, game in parse_team_games(vals).items():
games.setdefault(team, game)
_team_roster = rosters _team_roster = rosters
_team_by_username = build_username_index(rosters) _team_by_username = build_username_index(rosters)
_team_names = set(rosters) _team_names = set(rosters)
_team_game = games
return rosters return rosters
@@ -408,6 +453,11 @@ def all_team_names() -> set[str]:
return set(_team_names) return set(_team_names)
def get_game_for_team(team: str) -> str | None:
"""Return the game code ('CS2'/'LoL') a team is registered under, or None."""
return _team_game.get(team)
def get_team_rosters() -> dict[str, list[str]]: def get_team_rosters() -> dict[str, list[str]]:
"""Current {team: [usernames]} cache (mainly for diagnostics/tests).""" """Current {team: [usernames]} cache (mainly for diagnostics/tests)."""
return _team_roster return _team_roster

View File

@@ -82,6 +82,7 @@ CMD: dict[str, str] = {
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
OPT: dict[str, str] = { OPT: dict[str, str] = {
"teamsync_reposition": "Paiguta ka olemasolevad tiimirollid nende mängu jaotise alla",
"admin_kasutaja": "Kasutaja", "admin_kasutaja": "Kasutaja",
"admin_põhjus": "Põhjus (saadetakse kasutajale DM kaudu)", "admin_põhjus": "Põhjus (saadetakse kasutajale DM kaudu)",
"admincoins_kogus": "Positiivne = anna, negatiivne = võta", "admincoins_kogus": "Positiivne = anna, negatiivne = võta",

View File

@@ -113,4 +113,7 @@ TEAMSYNC_UI: dict[str, str] = {
"no_changes": "✨ Kõik tiimirollid olid juba korras.", "no_changes": "✨ Kõik tiimirollid olid juba korras.",
"changes_header": "**Muudatused:**", "changes_header": "**Muudatused:**",
"changes_more": "... ja {count} rohkem", "changes_more": "... ja {count} rohkem",
"reposition_header": "**Ümberpaigutus:**",
"repositioned": "📦 Ümber paigutatud rolle: {count}",
"reposition_none": "✨ Olemasolevad tiimirollid olid juba õiges sektsioonis.",
} }

View File

@@ -13,6 +13,7 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent))
import config # noqa: E402
from core import member_sync, sheets # noqa: E402 from core import member_sync, sheets # noqa: E402
from tests.conftest import run # noqa: E402 from tests.conftest import run # noqa: E402
@@ -87,6 +88,15 @@ def test_parse_team_rosters_ignores_non_table_content():
assert sheets.parse_team_rosters([["just", "some", "prose"], ["more"]]) == {} assert sheets.parse_team_rosters([["just", "some", "prose"], ["more"]]) == {}
def test_parse_team_games_tags_each_team_with_its_section():
# Teams inherit the game of the stacked section they sit under.
assert sheets.parse_team_games(SHEET_ROWS) == {
"Piirivalvurid": "CS2",
"GENESIS": "CS2",
"Pushing 30s": "LoL",
}
def test_build_username_index_is_case_insensitive(): def test_build_username_index_is_case_insensitive():
index = sheets.build_username_index({"GENESIS": ["Kapa", "neaQ"]}) index = sheets.build_username_index({"GENESIS": ["Kapa", "neaQ"]})
assert index == {"kapa": "GENESIS", "neaq": "GENESIS"} assert index == {"kapa": "GENESIS", "neaq": "GENESIS"}
@@ -95,9 +105,14 @@ def test_build_username_index_is_case_insensitive():
# --- sync_team_role behaviour (roster-independent) ------------------------- # --- sync_team_role behaviour (roster-independent) -------------------------
class FakeRole: class FakeRole:
def __init__(self, rid: int, name: str): def __init__(self, rid: int, name: str, position: int = 0):
self.id = rid self.id = rid
self.name = name self.name = name
self.position = position
async def edit(self, position=None, reason=None):
if position is not None:
self.position = position
def __eq__(self, other): def __eq__(self, other):
return isinstance(other, FakeRole) and other.id == self.id return isinstance(other, FakeRole) and other.id == self.id
@@ -128,6 +143,9 @@ class FakeGuild:
self._next = 9000 self._next = 9000
self.created: list[str] = [] 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): async def create_role(self, name, reason=None):
self._next += 1 self._next += 1
role = FakeRole(self._next, name) role = FakeRole(self._next, name)
@@ -222,3 +240,79 @@ def test_sync_all_team_roles_aggregates_and_skips_bots(monkeypatch):
assert summary.assigned == 1 assert summary.assigned == 1
assert summary.removed == 0 assert summary.removed == 0
assert summary.changes == ["kapa: +GENESIS"] assert summary.changes == ["kapa: +GENESIS"]
def test_new_team_role_is_positioned_under_its_game_divider(monkeypatch):
divider = FakeRole(500, "===== COUNTER-STRIKE 2 2026 =====", position=10)
member = FakeMember(1, "tft", roles=[])
guild = FakeGuild([divider], members=[member])
monkeypatch.setattr(sheets, "get_team_for_username", lambda n: "NEWTEAM")
monkeypatch.setattr(sheets, "all_team_names", lambda: {"NEWTEAM"})
monkeypatch.setattr(sheets, "get_game_for_team", lambda t: "CS2")
monkeypatch.setattr(config, "TEAM_DIVIDER_ROLE_IDS", {"CS2": 500})
result = run(member_sync.sync_team_role(member, guild))
assert result.created == "NEWTEAM"
new_role = next(r for r in guild.roles if r.name == "NEWTEAM")
assert new_role.position == 10 # slotted at the divider
def test_new_team_role_without_known_divider_is_left_in_place(monkeypatch):
member = FakeMember(1, "tft", roles=[])
guild = FakeGuild([], members=[member])
monkeypatch.setattr(sheets, "get_team_for_username", lambda n: "NEWTEAM")
monkeypatch.setattr(sheets, "all_team_names", lambda: {"NEWTEAM"})
monkeypatch.setattr(sheets, "get_game_for_team", lambda t: None) # game unknown
monkeypatch.setattr(config, "TEAM_DIVIDER_ROLE_IDS", {"CS2": 500})
result = run(member_sync.sync_team_role(member, guild))
assert result.created == "NEWTEAM" # still created, just not moved
new_role = next(r for r in guild.roles if r.name == "NEWTEAM")
assert new_role.position == 0 # default, untouched
# --- reposition_team_roles (opt-in cleanup of existing roles) ---------------
def test_reposition_moves_out_of_section_roles_and_skips_placed_ones(monkeypatch):
cs2_div = FakeRole(100, "CS2 divider", position=20)
lol_div = FakeRole(200, "LoL divider", position=10)
genesis = FakeRole(1, "GENESIS", position=2) # CS2 team stuck at bottom
pushing = FakeRole(2, "Pushing 30s", position=5) # LoL team already in band
unrelated = FakeRole(3, "Moderator", position=30) # not a team - ignored
guild = FakeGuild([cs2_div, lol_div, genesis, pushing, unrelated])
games = {"GENESIS": "CS2", "Pushing 30s": "LoL"}
monkeypatch.setattr(sheets, "all_team_names", lambda: {"GENESIS", "Pushing 30s"})
monkeypatch.setattr(sheets, "get_game_for_team", lambda t: games.get(t))
monkeypatch.setattr(config, "TEAM_DIVIDER_ROLE_IDS", {"CS2": 100, "LoL": 200})
repo = run(member_sync.reposition_team_roles(guild))
assert repo.scanned == 2 # only the two team roles
assert repo.moved == 1 # only GENESIS was out of place
assert repo.moves == ["GENESIS -> CS2"]
assert genesis.position == 20 # moved under the CS2 divider
assert pushing.position == 5 # already in its band, untouched
assert unrelated.position == 30 # non-team role never considered
def test_reposition_leaves_role_whose_team_is_not_in_sheet(monkeypatch):
cs2_div = FakeRole(100, "CS2 divider", position=20)
lol_div = FakeRole(200, "LoL divider", position=10)
ghost = FakeRole(1, "GhostTeam", position=2) # a team role, but gone from sheet
guild = FakeGuild([cs2_div, lol_div, ghost])
# Team no longer registered -> not in all_team_names, so never scanned/moved.
monkeypatch.setattr(sheets, "all_team_names", lambda: {"GENESIS"})
monkeypatch.setattr(sheets, "get_game_for_team", lambda t: None)
monkeypatch.setattr(config, "TEAM_DIVIDER_ROLE_IDS", {"CS2": 100, "LoL": 200})
repo = run(member_sync.reposition_team_roles(guild))
assert repo.scanned == 0
assert repo.moved == 0
assert ghost.position == 2 # left exactly where it was