Match members by Discord username against the lineup nicknames in the separate registration spreadsheet (TEAM_SHEET_ID) and give each their team's role. One team per person: switching teams removes the old team role, and a team with no Discord role yet is auto-created. Only role names present in the sheet are ever touched, so organisation/field/base roles are never at risk; the feature is a no-op when TEAM_SHEET_ID is unset. The sheet is not a single table (merged rows, stacked CS2/LoL sections with different layouts), so it is parsed via raw-row scanning rather than get_all_records. Citizenship markers like "(EST)" are used only as player delimiters and discarded. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
226 lines
7.8 KiB
Python
226 lines
7.8 KiB
Python
"""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
|