Files
tipibot/tests/test_team_sync.py
Rene Arumetsa d48a436e26 feat(teams): resolve team dividers by role ID instead of name
Divider placement matched the divider role by its exact Discord name, so
renaming the role in Discord silently broke positioning. Switch the
TEAM_DIVIDER_<SUFFIX> config to hold a role ID; resolve the ID to the
role's current name in apply_team_role_positions and keep the existing
name-based ordering maths downstream unchanged.

- config._parse_team_dividers now parses values as ints (rejects non-ints)
- resolve_divider / _team_divider cache / get_team_dividers return IDs
- apply_team_role_positions resolves each ID via guild.get_role once
- .env.example documents IDs and ships the CS2/LoL divider role IDs
- resolve_divider tests updated to assert IDs

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPRsW4tazVtYi2jNzVQkre
2026-09-03 22:34:34 +03:00

327 lines
12 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"]]) == {}
# --- 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
# --- 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 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"]