"""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 roster-independent add/remove/ auto-create behaviour of sync_team_role, using lightweight fakes for discord + the sheets cache. """ from __future__ import annotations import sys from pathlib import Path 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 # --- parse_lineup ---------------------------------------------------------- def test_parse_lineup_strips_citizenship_and_splits(): cell = "TFT (EST), nqmm (EST), sn1rk (EST), Kevka (EST), Kalatexx (EST)" assert sheets.parse_lineup(cell) == ["TFT", "nqmm", "sn1rk", "Kevka", "Kalatexx"] def test_parse_lineup_keeps_names_containing_commas_and_semicolons(): # A single player's descriptive name contains a ';' - must stay one name. cell = "Onu Klaus ; vahepeal ka mõni teine tegelane (EST), Lurban (EST)" assert sheets.parse_lineup(cell) == [ "Onu Klaus ; vahepeal ka mõni teine tegelane", "Lurban", ] def test_parse_lineup_handles_odd_usernames(): cell = "-acc +vac (EST), m (EST), M1X3RRRRRR (EST)" assert sheets.parse_lineup(cell) == ["-acc +vac", "m", "M1X3RRRRRR"] def test_parse_lineup_mixed_citizenship(): cell = "imp (LAT), milteg (EST), Freesies (EST)" assert sheets.parse_lineup(cell) == ["imp", "milteg", "Freesies"] def test_parse_lineup_empty(): assert sheets.parse_lineup("") == [] assert sheets.parse_lineup(" ") == [] def test_parse_lineup_fallback_without_citizenship(): assert sheets.parse_lineup("alice, bob") == ["alice", "bob"] # --- parse_team_rosters (multi-section sheet) ------------------------------ # Mirrors the real sheet: merged title rows, a header row, team rows, a blank # separator, then a SECOND section with a different column count. SHEET_ROWS = [ ["", "", "", "", "", "", ""], ["name", "members", "vrs ranking", "registration_date", "game", "", ""], ["[merged] TipiLAN 2026 CS2 Registration Log"] + [""] * 6, ["No", "Team Name", "Lineup (nickname, citizenship)", "VRS Ranking", "Registration Timestamp", "Status", "Participation confirmed?"], ["1", "Piirivalvurid", "TFT (EST), nqmm (EST)", "N/A", "01.05.2026 15:07", "Confirmed", "Yes"], ["2", "GENESIS", "kapa (EST), neaQ (EST)", "N/A", "01.05.2026 15:24", "Confirmed", "Yes"], ["", "", "", "", "", "", ""], ["[merged] TipiLAN 2026 LoL Registration Log"] + [""] * 4, ["No", "Team Name", "Lineup (nickname, citizenship)", "Registration Timestamp (dd.mm.yyyy hh:mm)", "Confirmation Status"], ["1", "Pushing 30s", "Onu Klaus (EST), Lurban (EST)", "01.05.2026 21:40", ""], ["", "", "", "", ""], ] def test_parse_team_rosters_multiple_sections(): rosters = sheets.parse_team_rosters(SHEET_ROWS) assert rosters == { "Piirivalvurid": ["TFT", "nqmm"], "GENESIS": ["kapa", "neaQ"], "Pushing 30s": ["Onu Klaus", "Lurban"], } def test_parse_team_rosters_ignores_non_table_content(): # No header row anywhere -> nothing extracted, no crash. 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(): index = sheets.build_username_index({"GENESIS": ["Kapa", "neaQ"]}) assert index == {"kapa": "GENESIS", "neaq": "GENESIS"} # --- sync_team_role behaviour (roster-independent) ------------------------- class FakeRole: def __init__(self, rid: int, name: str, position: int = 0): self.id = rid 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): return isinstance(other, FakeRole) and other.id == self.id def __hash__(self): return hash(self.id) class FakeMember: def __init__(self, uid: int, name: str, roles, bot: bool = False): self.id = uid self.name = name self.display_name = name self.bot = bot self.roles = list(roles) async def add_roles(self, *roles, reason=None): self.roles.extend(roles) async def remove_roles(self, *roles, reason=None): self.roles = [r for r in self.roles if r not in roles] class FakeGuild: 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) self.roles.append(role) self.created.append(name) return role 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([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_team_role(member, guild)) 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(monkeypatch): genesis = FakeRole(3, "GENESIS") member = FakeMember(1, "kapa", roles=[]) 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_team_role(member, guild)) assert guild.created == [] # did NOT create a duplicate assert result.created is None assert result.added == "GENESIS" assert genesis in member.roles def test_sync_strips_team_role_when_not_registered(monkeypatch): old_team = FakeRole(1, "OldTeam") member = FakeMember(1, "ghost", roles=[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_team_role(member, guild)) assert result.removed == ["OldTeam"] assert result.added is None assert old_team not in member.roles def test_sync_no_team_sheet_is_noop(monkeypatch): keeper = FakeRole(2, "Member") member = FakeMember(1, "someone", roles=[keeper]) guild = FakeGuild([keeper]) # 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_team_role(member, guild)) 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"] 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