feat(teams): place new team roles under their game's divider role

Auto-created team roles now land directly under the CS2 / LoL "divider" role
in the community guild's role list, keeping it grouped by game.

- config: CS2_DIVIDER_ROLE_ID / LOL_DIVIDER_ROLE_ID (season-specific, env
  overridable) exposed as TEAM_DIVIDER_ROLE_IDS keyed by game code.
- core/sheets: tag each team with its game (CS2/LoL) from the section title it
  sits under; shared _scan_sections generator feeds both parse_team_rosters and
  the new parse_team_games; get_game_for_team accessor + _team_game cache.
- core/member_sync: on creating a team role, position it at its game divider's
  slot (best-effort; left in place if game/divider unknown or move refused).
- .env.example + tests for game tagging and role placement.

Each participant is in exactly one game, so one role per team is unambiguous.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R6JZkyszyDFuFtk25WBbcR
This commit is contained in:
Rene Arumetsa
2026-08-28 15:51:48 +03:00
parent a4645e19c5
commit e179a35fc4
5 changed files with 160 additions and 9 deletions

View File

@@ -13,6 +13,7 @@ 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
@@ -87,6 +88,15 @@ def test_parse_team_rosters_ignores_non_table_content():
assert sheets.parse_team_rosters([["just", "some", "prose"], ["more"]]) == {}
def test_parse_team_games_tags_each_team_with_its_section():
# Teams inherit the game of the stacked section they sit under.
assert sheets.parse_team_games(SHEET_ROWS) == {
"Piirivalvurid": "CS2",
"GENESIS": "CS2",
"Pushing 30s": "LoL",
}
def test_build_username_index_is_case_insensitive():
index = sheets.build_username_index({"GENESIS": ["Kapa", "neaQ"]})
assert index == {"kapa": "GENESIS", "neaq": "GENESIS"}
@@ -95,9 +105,14 @@ def test_build_username_index_is_case_insensitive():
# --- sync_team_role behaviour (roster-independent) -------------------------
class FakeRole:
def __init__(self, rid: int, name: str):
def __init__(self, rid: int, name: str, position: int = 0):
self.id = rid
self.name = name
self.position = position
async def edit(self, position=None, reason=None):
if position is not None:
self.position = position
def __eq__(self, other):
return isinstance(other, FakeRole) and other.id == self.id
@@ -128,6 +143,9 @@ class FakeGuild:
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)
@@ -222,3 +240,36 @@ def test_sync_all_team_roles_aggregates_and_skips_bots(monkeypatch):
assert summary.assigned == 1
assert summary.removed == 0
assert summary.changes == ["kapa: +GENESIS"]
def test_new_team_role_is_positioned_under_its_game_divider(monkeypatch):
divider = FakeRole(500, "===== COUNTER-STRIKE 2 2026 =====", position=10)
member = FakeMember(1, "tft", roles=[])
guild = FakeGuild([divider], members=[member])
monkeypatch.setattr(sheets, "get_team_for_username", lambda n: "NEWTEAM")
monkeypatch.setattr(sheets, "all_team_names", lambda: {"NEWTEAM"})
monkeypatch.setattr(sheets, "get_game_for_team", lambda t: "CS2")
monkeypatch.setattr(config, "TEAM_DIVIDER_ROLE_IDS", {"CS2": 500})
result = run(member_sync.sync_team_role(member, guild))
assert result.created == "NEWTEAM"
new_role = next(r for r in guild.roles if r.name == "NEWTEAM")
assert new_role.position == 10 # slotted at the divider
def test_new_team_role_without_known_divider_is_left_in_place(monkeypatch):
member = FakeMember(1, "tft", roles=[])
guild = FakeGuild([], members=[member])
monkeypatch.setattr(sheets, "get_team_for_username", lambda n: "NEWTEAM")
monkeypatch.setattr(sheets, "all_team_names", lambda: {"NEWTEAM"})
monkeypatch.setattr(sheets, "get_game_for_team", lambda t: None) # game unknown
monkeypatch.setattr(config, "TEAM_DIVIDER_ROLE_IDS", {"CS2": 500})
result = run(member_sync.sync_team_role(member, guild))
assert result.created == "NEWTEAM" # still created, just not moved
new_role = next(r for r in guild.roles if r.name == "NEWTEAM")
assert new_role.position == 0 # default, untouched