forked from sass/tipibot
The team-role feature was aimed at the wrong bot. Tournament participants live in the economy/community guild, but team-role sync had been bolted onto the dev bot's roster sync (sync_member), which bails out for anyone missing from the internal member sheet - so it could never reach its actual audience. Decouple it: keep the (roster-independent) team-sheet parsing, pull the team wiring off the dev/member-sync path, and re-home it on the economy bot. - core/member_sync: revert sync_member to add-only (drop team block + SyncResult.roles_removed); add roster-independent sync_team_role and a whole-guild sync_all_team_roles returning a reporting summary. Still only ever touches role NAMES present in the team sheet. - commands/economy_team_commands: new admin-only /teamsync command. - bot.py: hourly team_sync_hourly task (economy-only, no-op unless TEAM_SHEET_ID is set; first tick at boot covers startup load); register /teamsync under the economy profile; drop the dev-side startup load. - commands/dev_member_commands: /check no longer refreshes teams or reports removed roles. - strings + .env.example: TEAMSYNC_UI, CMD[teamsync], document TEAM_SHEET_ID. - tests: retarget sync tests to sync_team_role; add sync_all_team_roles case. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R6JZkyszyDFuFtk25WBbcR
225 lines
8.0 KiB
Python
225 lines
8.0 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 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))
|
|
|
|
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_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 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"]
|