Files
tipibot/tests/test_team_sync.py
Rene Arumetsa ce1ed28904 refactor(teams): move team-role sync to the economy/community bot
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
2026-08-28 15:04:23 +03:00

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"]