""" Test functions for the matching module """ import discord import pytest import random import matchy.matching as matching import matchy.state as state import copy import itertools from datetime import datetime, timedelta @pytest.fixture(autouse=True) def clean_state(): """Ensure every single one of these tests has a clean state""" state.State = state._State(state._EMPTY_DICT) def test_protocols(): """Verify the protocols we're using match the discord ones""" assert isinstance(discord.Member, matching.Member) assert isinstance(discord.Guild, matching.Guild) assert isinstance(discord.Role, matching.Role) assert isinstance(Member, matching.Member) assert isinstance(Role, matching.Role) class Role(): def __init__(self, id: int): self._id = id @property def id(self) -> int: return self._id @property def name(self) -> str: pass class Member(): def __init__(self, id: int, roles: list[Role] = []): self._id = id self._roles = roles @property def mention(self) -> str: return f"<@{self._id}>" @property def display_name(self) -> str: return f"{self._id}" @property def roles(self) -> list[Role]: return self._roles @roles.setter def roles(self, roles: list[Role]): self._roles = roles @property def id(self) -> int: return self._id def members_to_groups_validate(matchees: list[Member], per_group: int): """Inner function to validate the main output of the groups function""" groups = matching.members_to_groups(matchees, per_group) # We should always have one group assert len(groups) # Log the groups to history # This will validate the internals state.State.log_groups(groups) # Ensure each group contains within the bounds of expected members for group in groups: if len(matchees) >= per_group: assert len(group) >= per_group else: assert len(group) == len(matchees) assert len(group) < per_group*2 # TODO: We could be more strict here return groups @pytest.mark.parametrize("matchees, per_group", [ # Simplest test possible ([Member(1)], 1), # More requested than we have ([Member(1)], 2), # A selection of hyper-simple checks to validate core functionality ([Member(1)] * 100, 3), ([Member(1)] * 12, 5), ([Member(1)] * 11, 2), ([Member(1)] * 356, 8), ], ids=['single', "larger_groups", "100_members", "5_group", "pairs", "356_big_groups"]) def test_members_to_groups_no_history(matchees, per_group): """Test simple group matching works""" members_to_groups_validate(matchees, per_group) def items_found_in_lists(list_of_lists, items): """validates if any sets of items are found in individual lists""" for sublist in list_of_lists: if all(item in sublist for item in items): return True return False @pytest.mark.parametrize("history_data, matchees, per_group, checks", [ # Slightly more difficult test ( # Describe a history where we previously matched up some people and ensure they don't get rematched [ { "ts": datetime.now() - timedelta(days=1), "groups": [ [Member(1), Member(2)], [Member(3), Member(4)], ] } ], [ Member(1), Member(2), Member(3), Member(4), ], 2, [ lambda groups: not items_found_in_lists( groups, [Member(1), Member(2)]), lambda groups: not items_found_in_lists( groups, [Member(3), Member(4)]) ] ), # Feed the system an "impossible" test # The function should fall back to ignoring history and still give us something ( [ { "ts": datetime.now() - timedelta(days=1), "groups": [ [ Member(1), Member(2), Member(3) ], [ Member(4), Member(5), Member(6) ], ] } ], [ Member(1, [Role(1), Role(2), Role(3), Role(4)]), Member(2, [Role(1), Role(2), Role(3), Role(4)]), Member(3, [Role(1), Role(2), Role(3), Role(4)]), Member(4, [Role(1), Role(2), Role(3), Role(4)]), Member(5, [Role(1), Role(2), Role(3), Role(4)]), Member(6, [Role(1), Role(2), Role(3), Role(4)]), ], 3, [ # Nothing specific to validate ] ), # Specific test pulled out of the stress test ( [ { "ts": datetime.now() - timedelta(days=4), "groups": [ [Member(i) for i in [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]] ] }, { "ts": datetime.now() - timedelta(days=5), "groups": [ [Member(i) for i in [1, 2, 3, 4, 5, 6, 7, 8]] ] } ], [Member(i) for i in [1, 2, 11, 4, 12, 3, 7, 5, 8, 10, 9, 6]], 3, [ # Nothing specific to validate ] ), # Silly example that failued due to bad role logic ( [ # No history ], [ # print([(m.id, [r.id for r in m.roles]) for m in matchees]) to get the below Member(i, [Role(r) for r in roles]) for (i, roles) in [ (4, [1, 2, 3, 4, 5, 6, 7, 8]), (8, [1]), (9, [1, 2, 3, 4, 5]), (6, [1, 2, 3]), (11, [1, 2, 3]), (7, [1, 2, 3, 4, 5, 6, 7]), (1, [1, 2, 3, 4]), (5, [1, 2, 3, 4, 5]), (12, [1, 2, 3, 4]), (10, [1]), (13, [1, 2, 3, 4, 5, 6]), (2, [1, 2, 3, 4, 5, 6]), (3, [1, 2, 3, 4, 5, 6, 7]) ] ], 2, [ # Nothing else ] ), # Another weird one pulled out of the stress test ( [ # print([(str(h["ts"]), [[f"Member({gm.id})" for gm in g] for g in h["groups"]]) for h in history_data]) {"ts": datetime.strptime(ts, r"%Y-%m-%d %H:%M:%S.%f"), "groups": [ [Member(m) for m in group] for group in groups]} for (ts, groups) in [ ( '2024-07-07 20:25:56.313993', [ [1, 2, 3, 4, 5], [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], [1], [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], [1, 2, 3, 4, 5, 6, 7, 8] ] ), ( '2024-07-13 20:25:56.313993', [ [1, 2], [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18], [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], [1] ] ), ( '2024-06-29 20:25:56.313993', [ [1, 2, 3, 4, 5], [1, 2, 3, 4, 5, 6, 7], [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20], [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17], [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20] ] ), ( '2024-06-25 20:25:56.313993', [ [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18], [1, 2], [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19], [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], [1, 2] ] ), ( '2024-07-04 20:25:56.313993', [ [1, 2, 3, 4, 5], [1, 2, 3], [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], [1, 2, 3, 4, 5, 6, 7] ] ), ( '2024-07-16 20:25:56.313993', [ [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], [1, 2, 3, 4, 5, 6, 7, 8, 9], [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20], [1, 2, 3, 4, 5, 6], [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18] ] ) ] ], [ # print([(m.id, [r.id for r in m.roles]) for m in matchees]) to get the below Member(i, [Role(r) for r in roles]) for (i, roles) in [ (10, [1, 2, 3]), (4, [1, 2, 3]), (5, [1, 2]), (13, [1, 2]), (3, [1, 2, 3, 4]), (14, [1]), (6, [1, 2, 3, 4]), (11, [1]), (9, [1]), (1, [1, 2, 3]), (16, [1, 2]), (15, [1, 2]), (2, [1, 2, 3]), (7, [1, 2, 3]), (12, [1, 2]), (8, [1, 2, 3, 4]) ] ], 5, [ # Nothing ] ) ], ids=['simple_history', 'fallback', 'example_1', 'example_2', 'example_3']) def test_unique_regressions(history_data, matchees, per_group, checks): """Test a bunch of unqiue failures that happened in the past""" # Replay the history for d in history_data: state.State.log_groups(d["groups"], d["ts"]) groups = members_to_groups_validate(matchees, per_group) # Run the custom validate functions for check in checks: assert check(groups) def random_chunk(li, min_chunk, max_chunk, rand): """ "Borrowed" from https://stackoverflow.com/questions/21439011/best-way-to-split-a-list-into-randomly-sized-chunks """ it = iter(li) while True: nxt = list(itertools.islice(it, rand.randint(min_chunk, max_chunk))) if nxt: yield nxt else: break # Generate a large set of "interesting" tests that replay a fake history onto random people # Increase these numbers for some extreme programming @pytest.mark.parametrize("per_group, num_members, num_history", ( (per_group, num_members, num_history) # Most of the time groups are gonna be from 2 to 5 for per_group in range(2, 5) # Going lower than 8 members doesn't give the bot much of a chance # And it will fail to not fall back sometimes # That's probably OK frankly for num_members in range(8, 32, 5) # Throw up to 7 histories at the algorithmn for num_history in range(0, 8))) def test_stess_random_groups(per_group, num_members, num_history): """Run a randomised test based on the input""" # Seed the random based on the inputs paird with primes # Ensures the test has interesting fake data, but is stable rand = random.Random(per_group*3 + num_members*5 + num_history*7) # Start with a list of all possible members possible_members = [Member(i) for i in range(num_members*2)] for member in possible_members: # Give each member 3 random roles from 1-7 member.roles = [Role(i) for i in rand.sample(range(1, 8), 3)] # For each history item match up groups and log those for i in range(num_history+1): # Grab the num of members and replay rand.shuffle(possible_members) members = copy.deepcopy(possible_members[:num_members]) groups = members_to_groups_validate(members, per_group) state.State.log_groups( groups, datetime.now() - timedelta(days=num_history-i)) def test_auth_scopes(): id = "1" assert not state.State.get_user_has_scope(id, state.AuthScope.MATCHER) id = "2" state.State.set_user_scope(id, state.AuthScope.MATCHER) assert state.State.get_user_has_scope(id, state.AuthScope.MATCHER) # Validate the state by constucting a new one _ = state._State(state.State._dict)