"""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"]]) == {} # --- parse_team_sections (which game/year a block of teams came from) ------ def test_parse_team_sections_tags_each_block_with_its_title(): sections = sheets.parse_team_sections(SHEET_ROWS) assert [(s.title, sorted(s.rosters)) for s in sections] == [ ("[merged] TipiLAN 2026 CS2 Registration Log", ["GENESIS", "Piirivalvurid"]), ("[merged] TipiLAN 2026 LoL Registration Log", ["Pushing 30s"]), ] def test_parse_team_sections_title_does_not_leak_to_untitled_block(): rows = [ ["[merged] TipiLAN 2026 CS2 Registration Log"] + [""] * 2, ["No", "Team Name", "Lineup (nickname)"], ["1", "GENESIS", "kapa (EST)"], ["", "", ""], ["No", "Team Name", "Lineup (nickname)"], # second block, no title above it ["1", "Nameless", "someone (EST)"], ] titles = [s.title for s in sheets.parse_team_sections(rows)] assert titles == ["[merged] TipiLAN 2026 CS2 Registration Log", ""] # --- resolve_divider (section title -> configured divider role) ------------ DIVIDERS = { "cs2_2026": 1498736834656604251, "lol_2026": 1498736949706490017, } def test_resolve_divider_matches_game_and_year(): assert sheets.resolve_divider( "[merged] TipiLAN 2026 CS2 Registration Log", DIVIDERS ) == 1498736834656604251 assert sheets.resolve_divider( "[merged] TipiLAN 2026 LoL Registration Log", DIVIDERS ) == 1498736949706490017 def test_resolve_divider_year_scoped_key_ignores_other_years(): assert sheets.resolve_divider("TipiLAN 2025 CS2 Registration Log", DIVIDERS) is None def test_resolve_divider_prefers_the_most_specific_match(): dividers = {"cs2": 111, "cs2_2026": 222} assert sheets.resolve_divider("TipiLAN 2026 CS2 Log", dividers) == 222 assert sheets.resolve_divider("TipiLAN 2025 CS2 Log", dividers) == 111 def test_resolve_divider_no_config_or_no_title_is_none(): assert sheets.resolve_divider("TipiLAN 2026 CS2 Log", {}) is None assert sheets.resolve_divider("", DIVIDERS) is None def test_title_survives_notice_rows_below_it(): # Real sheets stack the game/year title above single-cell notice rows; the # title's keywords must survive so resolve_divider still matches (regression: # notice rows used to overwrite the title, yielding a None divider). rows = [ ["TipiLAN 2026 CS2 Registration Log"] + [""] * 4, ["This log is updated automatically."] + [""] * 4, ["If a team from the Top 32 withdraws, ..."] + [""] * 4, ["No", "Team Name", "Lineup (nickname, citizenship)", "", ""], ["1", "GENESIS", "kapa (EST)", "", ""], ] section = sheets.parse_team_sections(rows)[0] assert "CS2" in section.title and "2026" in section.title assert sheets.resolve_divider(section.title, DIVIDERS) == 1498736834656604251 # --- plan_team_positions (pure role-ordering maths) ------------------------ def test_plan_moves_teams_directly_under_their_divider(): # Ascending = bottom-up, so the Discord UI renders this list reversed. ordered = ["@everyone", "60hz", "999", "Raid", "CS2-2026", "LOL-2026", "Bot"] plan = member_sync.plan_team_positions( ordered, {"Bot"}, {"CS2-2026": ["60hz", "999", "Raid"]} ) final = sorted(set(ordered), key=lambda n: plan.get(n, ordered.index(n))) # Teams end up just below the divider, alphabetical downwards in the UI. assert final == ["@everyone", "Raid", "999", "60hz", "CS2-2026", "LOL-2026", "Bot"] def test_plan_groups_each_divider_separately(): ordered = ["@everyone", "Alpha", "Pushing 30s", "CS2-2026", "LOL-2026", "Bot"] plan = member_sync.plan_team_positions( ordered, {"Bot"}, {"CS2-2026": ["Alpha"], "LOL-2026": ["Pushing 30s"]}, ) final = sorted(set(ordered), key=lambda n: plan.get(n, ordered.index(n))) assert final == ["@everyone", "Alpha", "CS2-2026", "Pushing 30s", "LOL-2026", "Bot"] def test_plan_is_a_noop_when_already_positioned(): ordered = ["@everyone", "Raid", "999", "60hz", "CS2-2026", "Bot"] assert member_sync.plan_team_positions( ordered, {"Bot"}, {"CS2-2026": ["60hz", "999", "Raid"]} ) == {} def test_plan_skips_missing_divider_and_leaves_its_teams_alone(): ordered = ["@everyone", "60hz", "CS2-2026", "Bot"] plan = member_sync.plan_team_positions( ordered, {"Bot"}, {"LOL-2026": ["60hz"]} # divider not in the guild ) assert plan == {} def test_plan_never_moves_managed_roles(): ordered = ["@everyone", "60hz", "CS2-2026", "Nitro", "Bot"] plan = member_sync.plan_team_positions( ordered, {"Nitro", "Bot"}, {"CS2-2026": ["60hz", "Nitro"]} ) assert "Nitro" not in plan and "Bot" not in plan 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): 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, 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] = [] 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 get_role(self, rid): return next((r for r in self.roles if r.id == rid), None) 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_grants_game_divider_role(monkeypatch): genesis = FakeRole(3, "GENESIS") cs2_div = FakeRole(100, "====== CS2 2026 ======") member = FakeMember(1, "kapa", roles=[]) guild = FakeGuild([genesis, cs2_div]) monkeypatch.setattr(sheets, "get_team_for_username", lambda n: "GENESIS") monkeypatch.setattr(sheets, "all_team_names", lambda: {"GENESIS"}) monkeypatch.setattr(sheets, "get_team_dividers", lambda: {"GENESIS": 100}) monkeypatch.setattr(config, "TEAM_DIVIDERS", {"cs2_2026": 100, "lol_2026": 200}) result = run(member_sync.sync_team_role(member, guild)) assert result.divider_added == "====== CS2 2026 ======" assert cs2_div in member.roles def test_sync_swaps_divider_role_on_game_switch(monkeypatch): genesis = FakeRole(3, "GENESIS") cs2_div = FakeRole(100, "CS2") lol_div = FakeRole(200, "LoL") member = FakeMember(1, "kapa", roles=[lol_div]) # was LoL, now on a CS2 team guild = FakeGuild([genesis, cs2_div, lol_div]) monkeypatch.setattr(sheets, "get_team_for_username", lambda n: "GENESIS") monkeypatch.setattr(sheets, "all_team_names", lambda: {"GENESIS"}) monkeypatch.setattr(sheets, "get_team_dividers", lambda: {"GENESIS": 100}) monkeypatch.setattr(config, "TEAM_DIVIDERS", {"cs2_2026": 100, "lol_2026": 200}) result = run(member_sync.sync_team_role(member, guild)) assert result.divider_added == "CS2" assert result.divider_removed == ["LoL"] ids = {r.id for r in member.roles} assert 100 in ids and 200 not in ids 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"]