"""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. """ 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 # --- 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_build_username_index_is_case_insensitive(): index = sheets.build_username_index({"GENESIS": ["Kapa", "neaQ"]}) assert index == {"kapa": "GENESIS", "neaq": "GENESIS"} # --- sync_member team behaviour ------------------------------------------- class FakeRole: def __init__(self, rid: int, name: str): self.id = rid self.name = name 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): self.id = uid self.name = name self.nick = None 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) 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): self.roles = list(roles) 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 @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): 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]) 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)) 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 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): genesis = FakeRole(3, "GENESIS") member = FakeMember(1, "kapa", roles=[]) guild = FakeGuild(team_env + [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)) assert guild.created == [] # did NOT create a duplicate assert "GENESIS" in result.roles_added assert genesis in member.roles def test_sync_strips_team_role_when_not_registered(team_env, monkeypatch): old_team = FakeRole(1, "OldTeam") member = FakeMember(1, "ghost", roles=[old_team]) guild = FakeGuild(team_env + [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)) assert result.roles_removed == ["OldTeam"] assert old_team not in member.roles def test_sync_no_team_sheet_is_noop(team_env, monkeypatch): keeper = FakeRole(2, "Member") member = FakeMember(1, "someone", roles=[keeper]) guild = FakeGuild(team_env + [keeper]) # Empty caches = feature switched off. 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)) assert result.roles_removed == [] assert guild.created == [] assert keeper in member.roles