""" 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 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], tmp_state: state.State, per_group: int): """Inner function to validate the main output of the groups function""" groups = matching.members_to_groups(matchees, tmp_state, per_group) # We should always have one group assert len(groups) # Log the groups to history # This will validate the internals tmp_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""" tmp_state = state.State(state._EMPTY_DICT) members_to_groups_validate(matchees, tmp_state, 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""" tmp_state = state.State(state._EMPTY_DICT) # Replay the history for d in history_data: tmp_state.log_groups(d["groups"], d["ts"]) groups = members_to_groups_validate(matchees, tmp_state, 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 cumulative_state = state.State(state._EMPTY_DICT) 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, cumulative_state, per_group) cumulative_state.log_groups( groups, datetime.now() - timedelta(days=num_history-i)) def test_auth_scopes(): tmp_state = state.State(state._EMPTY_DICT) id = "1" assert not tmp_state.get_user_has_scope(id, state.AuthScope.MATCHER) id = "2" tmp_state.set_user_scope(id, state.AuthScope.MATCHER) assert tmp_state.get_user_has_scope(id, state.AuthScope.MATCHER) # Validate the state by constucting a new one _ = state.State(tmp_state._dict)