matchy/tests/matching_test.py
Marc Di Luzio 69005ef498 Convert State to global
This was just getting too painful to manage, especially passing around these state objects
2024-08-17 14:58:19 +01:00

408 lines
13 KiB
Python

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