diff --git a/README.rst b/README.rst index 21ea1be..edc6bda 100644 --- a/README.rst +++ b/README.rst @@ -97,11 +97,11 @@ The ``Matching`` object +++++++++++++++++++++++ This matching is not a standard Python dictionary, though it does largely look -and behave like one. It is in fact an instance of the ``Matching`` class: +and behave like one. It is in fact an instance of the ``SingleMatching`` class: >>> matching = game.matching >>> type(matching) - + This dictionary-like object is primarily useful as a teaching device that eases the process of manipulating a matching after a solution has been found. @@ -116,9 +116,9 @@ Despite passing dictionaries of strings here, the matching displays instances of >>> matching = game.matching >>> for suitor in matching: ... print(type(suitor)) - - - + + + This is because ``create_from_dictionaries`` creates instances of the appropriate player classes first and passes them to the game class. Using diff --git a/docs/tutorials/project_allocation/main.ipynb b/docs/tutorials/project_allocation/main.ipynb index ca965f6..7408e42 100644 --- a/docs/tutorials/project_allocation/main.ipynb +++ b/docs/tutorials/project_allocation/main.ipynb @@ -1054,7 +1054,7 @@ { "data": { "text/plain": [ - "" + "" ] }, "execution_count": 27, @@ -1172,7 +1172,7 @@ { "data": { "text/plain": [ - "" + "" ] }, "execution_count": 30, @@ -1235,7 +1235,7 @@ "for project, project_students in matching.items():\n", " for student in project_students:\n", " inverted_matching[student.name] = project.name\n", - " student_preference_of_matching.append(student.pref_names.index(project.name))" + " student_preference_of_matching.append(student._pref_names.index(project.name))" ] }, { @@ -1547,7 +1547,7 @@ { "data": { "text/plain": [ - "" + "" ] }, "execution_count": 36, diff --git a/src/matching/__init__.py b/src/matching/__init__.py index e336986..8464834 100644 --- a/src/matching/__init__.py +++ b/src/matching/__init__.py @@ -2,14 +2,23 @@ import sys -from .game import BaseGame -from .matching import Matching -from .player import Player -from .version import __version__ - if not sys.warnoptions: import warnings warnings.simplefilter("always") -__all__ = ["BaseGame", "Matching", "Player", "__version__"] +from .base import BaseGame, BaseMatching, BasePlayer +from .matchings import MultipleMatching, SingleMatching +from .players import Player +from .version import __version__ + +__all__ = [ + "BaseGame", + "BaseMatching", + "BasePlayer", + "Matching", + "MultipleMatching", + "Player", + "SingleMatching", + "__version__", +] diff --git a/src/matching/algorithms/stable_roommates.py b/src/matching/algorithms/stable_roommates.py index f17868f..670a5b9 100644 --- a/src/matching/algorithms/stable_roommates.py +++ b/src/matching/algorithms/stable_roommates.py @@ -42,6 +42,7 @@ def locate_all_or_nothing_cycle(player): lasts.append(their_worst) player = their_worst + if lasts.count(player) > 1: break diff --git a/src/matching/base.py b/src/matching/base.py new file mode 100644 index 0000000..9e66c33 --- /dev/null +++ b/src/matching/base.py @@ -0,0 +1,260 @@ +""" Abstract base classes for inheritance. """ +import abc +import warnings + +from matching.exceptions import PlayerExcludedWarning, PreferencesChangedWarning + + +class BasePlayer: + """An abstract base class to represent a player within a matching game. + + Parameters + ---------- + name : object + An identifier. This should be unique and descriptive. + + Attributes + ---------- + prefs : List[BasePlayer] + The player's preferences. Defaults to ``None`` and is updated using the + ``set_prefs`` method. + matching : Optional[BasePlayer] + The current match of the player. ``None`` if not currently matched. + _pref_names : Optional[List] + A list of the names in ``prefs``. Updates with ``prefs`` via + ``set_prefs`` method. + _original_prefs : Optional[List[BasePlayer]] + The original set of player preferences. Defaults to ``None`` and does + not update after the first ``set_prefs`` method call. + """ + + def __init__(self, name): + + self.name = name + self.prefs = [] + self.matching = None + + self._pref_names = [] + self._original_prefs = None + + def __repr__(self): + + return str(self.name) + + def _forget(self, other): + """Forget another player by removing them from the player's preference + list.""" + + prefs = self.prefs[:] + prefs.remove(other) + self.prefs = prefs + + def unmatched_message(self): + + return f"{self} is unmatched." + + def not_in_preferences_message(self, other): + + return ( + f"{self} is matched to {other} but they do not appear in their " + f"preference list: {self.prefs}." + ) + + def set_prefs(self, players): + """ Set the player's preferences to be a list of players. """ + + self.prefs = players + self._pref_names = [player.name for player in players] + + if self._original_prefs is None: + self._original_prefs = players[:] + + def prefers(self, player, other): + """Determines whether the player prefers a player over some other + player.""" + + prefs = self._original_prefs + return prefs.index(player) < prefs.index(other) + + @abc.abstractmethod + def _match(self, other): + """A placeholder function for assigning the player to be matched to + some other player.""" + + @abc.abstractmethod + def _unmatch(self, other): + """A placeholder function for unassigning the player from its match + with some other player.""" + + @abc.abstractmethod + def get_favourite(self): + """A placeholder function for getting the player's favourite, feasible + player.""" + + @abc.abstractmethod + def get_successors(self): + """A placeholder function for getting the logically feasible + 'successors' of the player.""" + + @abc.abstractmethod + def check_if_match_is_unacceptable(self): + """A placeholder for chacking the acceptability of the current + match(es) of the player.""" + + +class BaseGame(metaclass=abc.ABCMeta): + """An abstract base class for facilitating various matching games. + + Parameters + ---------- + clean + Defaults to :code:`False`. If :code:`True`, when passing a set of + players to create a game instance, they will be automatically cleaned. + + Attributes + ---------- + matching + After solving the game, a :code:`Matching` object is found here. + Otherwise, :code:`None`. + blocking_pairs + After checking the stability of the game instance, a list of any pairs + that block the stability of the matching is found here. Otherwise, + :code:`None`. + """ + + def __init__(self, clean=False): + + self.matching = None + self.blocking_pairs = None + self.clean = clean + + def _remove_player(self, player, player_party, other_party): + """Remove a player from the game instance as well as any relevant + player preference lists.""" + + party = vars(self)[player_party][:] + party.remove(player) + vars(self)[player_party].remove(player) + for other in vars(self)[other_party]: + if player in other.prefs: + other._forget(player) + + def _check_inputs_player_prefs_unique(self, party): + """Check that each player in :code:`party` has not ranked another + player more than once. If so, and :code:`clean` is :code:`True`, then + take the first instance they appear in the preference list.""" + + for player in vars(self)[party]: + unique_prefs = [] + for other in player.prefs: + if other not in unique_prefs: + unique_prefs.append(other) + else: + warnings.warn( + PreferencesChangedWarning( + f"{player} has ranked {other} multiple times." + ) + ) + + if self.clean: + player.set_prefs(unique_prefs) + + def _check_inputs_player_prefs_all_in_party(self, party, other_party): + """Check that each player in :code:`party` has ranked only players in + :code:`other_party`. If :code:`clean`, then forget any extra + preferences.""" + + players = vars(self)[party] + others = vars(self)[other_party] + for player in players: + + for other in player.prefs: + if other not in others: + warnings.warn( + PreferencesChangedWarning( + f"{player} has ranked a non-{other_party[:-1]}: " + f"{other}." + ) + ) + if self.clean: + player._forget(other) + + def _check_inputs_player_prefs_nonempty(self, party, other_party): + """Make sure that each player in :code:`party` has a nonempty + preference list of players in :code:`other_party`. If :code:`clean`, + remove any such player.""" + + for player in vars(self)[party]: + + if not player.prefs: + warnings.warn( + PlayerExcludedWarning( + f"{player} has an empty preference list." + ) + ) + if self.clean: + self._remove_player(player, party, other_party) + + @abc.abstractmethod + def solve(self): + """ Placeholder for solving the given matching game. """ + + @abc.abstractmethod + def check_stability(self): + """ Placeholder for checking the stability of the current matching. """ + + @abc.abstractmethod + def check_validity(self): + """ Placeholder for checking the validity of the current matching. """ + + +class BaseMatching(dict, metaclass=abc.ABCMeta): + """An abstract base class for the storing and updating of a matching. + + Attributes + ---------- + dictionary : dict or None + If not ``None``, a dictionary mapping a ``Player`` to one of: ``None``, + a single ``Player`` or a list of ``Player`` instances. + """ + + def __init__(self, dictionary=None): + + self._data = {} + if dictionary is not None: + self._data.update(dictionary) + + super().__init__(self._data) + + def __repr__(self): + + return repr(self._data) + + def keys(self): + + return self._data.keys() + + def values(self): + + return self._data.values() + + def __getitem__(self, player): + + return self._data[player] + + @abc.abstractmethod + def __setitem__(self, player, new_match): + """ A placeholder function for how to update the matching. """ + + def _check_player_in_keys(self, player): + """ Raise an error if :code:`player` is not in the dictionary. """ + + if player not in self._data.keys(): + raise ValueError(f"{player} is not a key in this matching.") + + def _check_new_valid_type(self, new, types): + """Raise an error is :code:`new` is not an instance of one of + :code:`types`.""" + + if not isinstance(new, types): + raise ValueError(f"{new} is not one of {types} and is not valid.") diff --git a/src/matching/game.py b/src/matching/game.py deleted file mode 100644 index 803972a..0000000 --- a/src/matching/game.py +++ /dev/null @@ -1,108 +0,0 @@ -""" The base game class for facilitating and solving matching games. """ - -import abc -import warnings - -from matching.exceptions import PlayerExcludedWarning, PreferencesChangedWarning - - -class BaseGame(metaclass=abc.ABCMeta): - """An abstract base class for facilitating various matching games. - - Attributes - ---------- - matching : None - Initialised to be :code:`None`. After solving the game, - a :code:`Matching` object is found here. - blocking_pairs : None - Initialised to be :code:`None`. After solving and checking the stability - of the game instance, a list of any pairs that block the stability of - the matching. - clean : bool - Defaults to :code:`False`. When passing a set of players to create a - game instance, this allows for the automatic cleaning of the players. - """ - - def __init__(self, clean=False): - - self.matching = None - self.blocking_pairs = None - self.clean = clean - - def _remove_player(self, player, player_party, other_party): - """ Remove a player from the game and any relevant preference lists. """ - - party = vars(self)[player_party][:] - party.remove(player) - vars(self)[player_party].remove(player) - for other in vars(self)[other_party]: - if player in other.prefs: - other._forget(player) - - def _check_inputs_player_prefs_unique(self, party): - """Check that each player in :code:`party` has not ranked another - player more than once. If so, and :code:`clean` is :code:`True`, then - take the first instance they appear in the preference list.""" - - for player in vars(self)[party]: - unique_prefs = [] - for other in player.prefs: - if other not in unique_prefs: - unique_prefs.append(other) - else: - warnings.warn( - PreferencesChangedWarning( - f"{player} has ranked {other} multiple times." - ) - ) - - if self.clean: - player.set_prefs(unique_prefs) - - def _check_inputs_player_prefs_all_in_party(self, party, other_party): - """Check that each player in :code:`party` has ranked only players in - :code:`other_party`. If :code:`clean`, then forget any extra - preferences.""" - - players = vars(self)[party] - others = vars(self)[other_party] - for player in players: - - for other in player.prefs: - if other not in others: - warnings.warn( - PreferencesChangedWarning( - f"{player} has ranked a non-{other_party[:-1]}: " - f"{other}." - ) - ) - if self.clean: - player._forget(other) - - def _check_inputs_player_prefs_nonempty(self, party, other_party): - """Make sure that each player in :code:`party` has a nonempty - preference list of players in :code:`other_party`. If :code:`clean`, - remove any such player.""" - - for player in vars(self)[party]: - - if not player.prefs: - warnings.warn( - PlayerExcludedWarning( - f"{player} has an empty preference list." - ) - ) - if self.clean: - self._remove_player(player, party, other_party) - - @abc.abstractmethod - def solve(self): - """ Placeholder for solving the given matching game. """ - - @abc.abstractmethod - def check_stability(self): - """ Placeholder for checking the stability of the current matching. """ - - @abc.abstractmethod - def check_validity(self): - """ Placeholder for checking the validity of the current matching. """ diff --git a/src/matching/games/hospital_resident.py b/src/matching/games/hospital_resident.py index 807e3cc..97aa6fe 100644 --- a/src/matching/games/hospital_resident.py +++ b/src/matching/games/hospital_resident.py @@ -2,7 +2,7 @@ import copy import warnings -from matching import BaseGame, Matching +from matching import BaseGame, MultipleMatching from matching import Player as Resident from matching.algorithms import hospital_resident from matching.exceptions import ( @@ -84,7 +84,7 @@ def solve(self, optimal="resident"): """Solve the instance of HR using either the resident- or hospital-oriented algorithm. Return the matching.""" - self.matching = Matching( + self.matching = MultipleMatching( hospital_resident(self.residents, self.hospitals, optimal) ) return self.matching diff --git a/src/matching/games/stable_marriage.py b/src/matching/games/stable_marriage.py index d513f10..3da0cd3 100644 --- a/src/matching/games/stable_marriage.py +++ b/src/matching/games/stable_marriage.py @@ -1,8 +1,7 @@ """ The SM game class and supporting functions. """ - import copy -from matching import BaseGame, Matching, Player +from matching import BaseGame, Player, SingleMatching from matching.algorithms import stable_marriage from matching.exceptions import MatchingError @@ -52,7 +51,7 @@ def solve(self, optimal="suitor"): """Solve the instance of SM using either the suitor- or reviewer-oriented Gale-Shapley algorithm. Return the matching.""" - self.matching = Matching( + self.matching = SingleMatching( stable_marriage(self.suitors, self.reviewers, optimal) ) return self.matching diff --git a/src/matching/games/stable_roommates.py b/src/matching/games/stable_roommates.py index de322df..f9c60fa 100644 --- a/src/matching/games/stable_roommates.py +++ b/src/matching/games/stable_roommates.py @@ -1,8 +1,7 @@ """ The SR game class and supporting functions. """ - import copy -from matching import BaseGame, Matching, Player +from matching import BaseGame, Player, SingleMatching from matching.algorithms import stable_roommates from matching.exceptions import MatchingError @@ -43,7 +42,7 @@ def solve(self): """Solve the instance of SR using Irving's algorithm. Return the matching.""" - self.matching = Matching(stable_roommates(self.players)) + self.matching = SingleMatching(stable_roommates(self.players)) return self.matching def check_validity(self): diff --git a/src/matching/games/student_allocation.py b/src/matching/games/student_allocation.py index bc1e92e..133d658 100644 --- a/src/matching/games/student_allocation.py +++ b/src/matching/games/student_allocation.py @@ -1,8 +1,8 @@ -""" The SA solver and algorithm. """ +""" The SA game class and supporting functions. """ import copy import warnings -from matching import Matching +from matching import MultipleMatching from matching import Player as Student from matching.algorithms import student_allocation from matching.exceptions import ( @@ -122,7 +122,7 @@ def solve(self, optimal="student"): """Solve the instance of SA using either the student- or supervisor-optimal algorithm.""" - self.matching = Matching( + self.matching = MultipleMatching( student_allocation( self.students, self.projects, self.supervisors, optimal ) diff --git a/src/matching/matching.py b/src/matching/matching.py deleted file mode 100644 index 5027437..0000000 --- a/src/matching/matching.py +++ /dev/null @@ -1,63 +0,0 @@ -""" A dictionary-like object for matchings. """ - -from .player import Player - - -class Matching(dict): - """A class to store, and allow for the easy updating of, matchings found by - a game solver. - - Attributes - ---------- - dictionary : dict or None - If not ``None``, a dictionary mapping a ``Player`` to one of: ``None``, - a single ``Player`` or a list of ``Player`` instances. - """ - - def __init__(self, dictionary=None): - - self.__data = {} - if dictionary is not None: - self.__data.update(dictionary) - - super().__init__(self.__data) - - def __repr__(self): - - return repr(self.__data) - - def __getitem__(self, player): - - return self.__data[player] - - def __setitem__(self, player, new_match): - - if player not in self.__data.keys(): - raise ValueError(f"{player} is not a key in this matching.") - - if isinstance(new_match, Player): - new_match.matching = player - player.matching = new_match - - elif new_match is None: - player.matching = new_match - - elif isinstance(new_match, (list, tuple)) and all( - [isinstance(new, Player) for new in new_match] - ): - player.matching = new_match - for new in new_match: - new.matching = player - - else: - raise ValueError(f"{new_match} is not a valid match.") - - self.__data[player] = new_match - - def keys(self): - - return self.__data.keys() - - def values(self): - - return self.__data.values() diff --git a/src/matching/matchings.py b/src/matching/matchings.py new file mode 100644 index 0000000..ada55e3 --- /dev/null +++ b/src/matching/matchings.py @@ -0,0 +1,59 @@ +""" A collection of dictionary-like objects for storing matchings. """ +from matching import BaseMatching +from matching.players import Player + + +class SingleMatching(BaseMatching): + """A dictionary-like object for storing and updating a matching with + singular matches such as those in an instance of SM or SR. + + Parameters + ---------- + dictionary + The dictionary of matches. Made up of :code:`Player, Optional[Player]` + key, value pairs. + """ + + def __init__(self, dictionary): + + super().__init__(dictionary) + + def __setitem__(self, player, new): + + self._check_player_in_keys(player) + self._check_new_valid_type(new, (type(None), Player)) + + player.matching = new + if isinstance(new, Player): + new.matching = player + + self._data[player] = new + + +class MultipleMatching(BaseMatching): + """A dictionary-like object for storing and updating a matching with + multiple matches such as those in an instance of HR or SA. + + Parameters + ---------- + dictionary + The dictionary of matches. Made up of :code:`Hospital, List[Player]` + key, value pairs. + """ + + def __init__(self, dictionary): + + super().__init__(dictionary) + + def __setitem__(self, player, new): + + self._check_player_in_keys(player) + self._check_new_valid_type(new, (list, tuple)) + for other in new: + self._check_new_valid_type(other, Player) + + player.matching = new + for other in new: + other.matching = player + + self._data[player] = new diff --git a/src/matching/players/__init__.py b/src/matching/players/__init__.py index e5e5eb2..609980b 100644 --- a/src/matching/players/__init__.py +++ b/src/matching/players/__init__.py @@ -1,7 +1,8 @@ """ Top-level imports for the `matching.players` subpackage. """ from .hospital import Hospital +from .player import Player from .project import Project from .supervisor import Supervisor -__all__ = ["Hospital", "Project", "Supervisor"] +__all__ = ["Hospital", "Player", "Project", "Supervisor"] diff --git a/src/matching/players/hospital.py b/src/matching/players/hospital.py index 3f67588..0f5d357 100644 --- a/src/matching/players/hospital.py +++ b/src/matching/players/hospital.py @@ -1,9 +1,9 @@ """ The Hospital class for use in instances of HR. """ -from matching import Player +from matching import BasePlayer -class Hospital(Player): +class Hospital(BasePlayer): """A class to represent a hospital in an instance of HR. Also used as a parent class to ``Project`` and ``Supervisor``. diff --git a/src/matching/player.py b/src/matching/players/player.py similarity index 58% rename from src/matching/player.py rename to src/matching/players/player.py index b4777fd..2982186 100644 --- a/src/matching/player.py +++ b/src/matching/players/player.py @@ -1,7 +1,9 @@ """ The base Player class for use in various games. """ +from matching import BasePlayer -class Player: + +class Player(BasePlayer): """A class to represent a player within the matching game. Parameters @@ -23,26 +25,6 @@ class Player: The original set of player preferences. """ - def __init__(self, name): - - self.name = name - self.prefs = None - self.pref_names = None - self.matching = None - self._original_prefs = None - - def __repr__(self): - - return str(self.name) - - def _forget(self, other): - """Forget another player by removing them from the player's preference - list.""" - - prefs = self.prefs[:] - prefs.remove(other) - self.prefs = prefs - def _match(self, other): """ Assign the player to be matched to some other player. """ @@ -53,24 +35,6 @@ def _unmatch(self): self.matching = None - def unmatched_message(self): - - return f"{self} is unmatched." - - def not_in_preferences_message(self, other): - - return ( - f"{self} is matched to {other} but they do not appear in their " - f"preference list: {self.prefs}." - ) - - def set_prefs(self, players): - """ Set the player's preferences to be a list of players. """ - - self.prefs = players - self.pref_names = [player.name for player in players] - self._original_prefs = players[:] - def get_favourite(self): """ Get the player's favourite player. """ @@ -82,13 +46,6 @@ def get_successors(self): idx = self.prefs.index(self.matching) return self.prefs[idx + 1 :] - def prefers(self, player, other): - """Determines whether the player prefers a player over some other - player.""" - - prefs = self._original_prefs - return prefs.index(player) < prefs.index(other) - def check_if_match_is_unacceptable(self, unmatched_okay=False): """Check the acceptability of the current match, with the stipulation that being unmatched is okay (or not).""" diff --git a/src/matching/players/supervisor.py b/src/matching/players/supervisor.py index 610b9f9..ebd0a02 100644 --- a/src/matching/players/supervisor.py +++ b/src/matching/players/supervisor.py @@ -49,7 +49,7 @@ def set_prefs(self, students): projects.""" self.prefs = students - self.pref_names = [student.name for student in students] + self._pref_names = [student.name for student in students] self._original_prefs = students[:] for project in self.projects: diff --git a/tests/unit/__init__.py b/tests/base/__init__.py similarity index 100% rename from tests/unit/__init__.py rename to tests/base/__init__.py diff --git a/tests/base/test_game.py b/tests/base/test_game.py new file mode 100644 index 0000000..2c910e1 --- /dev/null +++ b/tests/base/test_game.py @@ -0,0 +1,126 @@ +""" Tests for the BaseGame class. """ +import warnings + +from hypothesis import given +from hypothesis.strategies import booleans + +from matching import BaseGame, Player +from matching.exceptions import PlayerExcludedWarning, PreferencesChangedWarning + +from .util import player_others + + +class DummyGame(BaseGame): + def solve(self): + pass + + def check_stability(self): + pass + + def check_validity(self): + pass + + +@given(clean=booleans()) +def test_init(clean): + """ Make a BaseGame instance and test it has the correct attributes. """ + + game = DummyGame(clean) + + assert isinstance(game, BaseGame) + assert game.matching is None + assert game.blocking_pairs is None + assert game.clean is clean + + +@given(player_others=player_others()) +def test_remove_player(player_others): + """ Test that a player can be removed from a game and its players. """ + + player, others = player_others + + player.set_prefs(others) + for other in others: + other.set_prefs([player]) + + game = DummyGame() + game.players = [player] + game.others = others + + game._remove_player(player, "players", "others") + assert player not in game.players + assert all(player not in other.prefs for other in game.others) + + +@given(player_others=player_others(), clean=booleans()) +def test_check_inputs_player_prefs_unique(player_others, clean): + """ Test that a game can verify its players have unique preferences. """ + + player, others = player_others + + player.set_prefs(others + others[:1]) + + game = DummyGame(clean) + game.players = [player] + + with warnings.catch_warnings(record=True) as w: + game._check_inputs_player_prefs_unique("players") + + message = w[-1].message + assert isinstance(message, PreferencesChangedWarning) + assert str(message).startswith(player.name) + assert others[0].name in str(message) + if clean: + assert player._pref_names == [o.name for o in others] + + +@given(player_others=player_others(), clean=booleans()) +def test_check_inputs_player_prefs_all_in_party(player_others, clean): + """ " Test that a game can verify its players have only got preferences in + the correct party.""" + + player, others = player_others + + outsider = Player("foo") + player.set_prefs([outsider]) + + game = DummyGame(clean) + game.players = [player] + game.others = others + + with warnings.catch_warnings(record=True) as w: + game._check_inputs_player_prefs_all_in_party("players", "others") + + message = w[-1].message + assert isinstance(message, PreferencesChangedWarning) + assert str(message).startswith(player.name) + assert "non-other" in str(message) + assert outsider.name in str(message) + if clean: + assert outsider not in player.prefs + + +@given(player_others=player_others(), clean=booleans()) +def test_check_inputs_player_prefs_nonempty(player_others, clean): + """ " Test that a game can verify its players have got nonempty preference + lists.""" + + player, others = player_others + + player.set_prefs(others) + other = others[0] + + game = DummyGame(clean) + game.players = [player] + game.others = [other] + + with warnings.catch_warnings(record=True) as w: + game._check_inputs_player_prefs_nonempty("others", "players") + + message = w[-1].message + assert isinstance(message, PlayerExcludedWarning) + assert str(message).startswith(other.name) + + if clean: + assert other not in game.others + assert player.prefs == others[1:] diff --git a/tests/base/test_matching.py b/tests/base/test_matching.py new file mode 100644 index 0000000..8b5497e --- /dev/null +++ b/tests/base/test_matching.py @@ -0,0 +1,95 @@ +""" Tests for the BaseMatching class. """ +import pytest +from hypothesis import given +from hypothesis.strategies import dictionaries, text + +from matching import BaseMatching + +DICTIONARIES = given( + dictionary=dictionaries( + keys=text(), + values=text(), + min_size=1, + max_size=3, + ) +) + + +@DICTIONARIES +def test_init(dictionary): + """ Make a matching and check their attributes are correct. """ + + matching = BaseMatching() + assert matching == {} + + matching = BaseMatching(dictionary) + assert matching == dictionary + + +@DICTIONARIES +def test_repr(dictionary): + """ Check that a matching is represented by a normal dictionary. """ + + matching = BaseMatching() + assert repr(matching) == "{}" + + matching = BaseMatching(dictionary) + assert repr(matching) == str(dictionary) + + +@DICTIONARIES +def test_keys(dictionary): + """ Check a matching can have its `keys` accessed. """ + + matching = BaseMatching() + assert list(matching.keys()) == [] + + matching = BaseMatching(dictionary) + assert list(matching.keys()) == list(dictionary.keys()) + + +@DICTIONARIES +def test_values(dictionary): + """ Check a matching can have its `values` accessed. """ + + matching = BaseMatching() + assert list(matching.values()) == [] + + matching = BaseMatching(dictionary) + assert list(matching.values()) == list(dictionary.values()) + + +@DICTIONARIES +def test_getitem(dictionary): + """ Check that you can access items in a matching correctly. """ + + matching = BaseMatching(dictionary) + for (mkey, mval), (dkey, dval) in zip(matching.items(), dictionary.items()): + assert matching[mkey] == mval + assert (mkey, mval) == (dkey, dval) + + +@DICTIONARIES +def test_setitem_check_player_in_keys(dictionary): + """Check that a `ValueError` is raised if trying to add a new item to a + matching.""" + + key = list(dictionary.keys())[0] + matching = BaseMatching(dictionary) + assert matching._check_player_in_keys(key) is None + + with pytest.raises(ValueError): + matching._check_player_in_keys(key + "foo") + + +@DICTIONARIES +def test_setitem_check_new_valid_type(dictionary): + """Check that a `ValueError` is raised if a new match is not one of the + provided types.""" + + val = list(dictionary.values())[0] + matching = BaseMatching(dictionary) + assert matching._check_new_valid_type(val, str) is None + + with pytest.raises(ValueError): + matching._check_new_valid_type(val, float) diff --git a/tests/base/test_player.py b/tests/base/test_player.py new file mode 100644 index 0000000..0d22f31 --- /dev/null +++ b/tests/base/test_player.py @@ -0,0 +1,113 @@ +""" Tests for the BasePlayer class. """ +from hypothesis import given +from hypothesis.strategies import text + +from matching import BasePlayer + +from .util import player_others + + +@given(name=text()) +def test_init(name): + """ Make a Player instance and test that their attributes are correct. """ + + player = BasePlayer(name) + assert player.name == name + assert player.prefs == [] + assert player.matching is None + assert player._pref_names == [] + assert player._original_prefs is None + + +@given(name=text()) +def test_repr(name): + """Test that a Player instance is represented by the string version of + their name.""" + + player = BasePlayer(name) + assert repr(player) == name + + player = BasePlayer(0) + assert repr(player) == str(0) + + +@given(name=text()) +def test_unmatched_message(name): + """Test that a Player instance can return a message saying they are + unmatched. This is could be a lie.""" + + player = BasePlayer(name) + + message = player.unmatched_message() + assert message.startswith(name) + assert "unmatched" in message + + +@given(player_others=player_others()) +def test_not_in_preferences_message(player_others): + """Test that a Player instance can return a message saying they are matched + to another player who does not appear in their preferences. This could be a + lie.""" + + player, others = player_others + + other = others.pop() + player.set_prefs(others) + message = player.not_in_preferences_message(other) + assert message.startswith(player.name) + assert str(player.prefs) in message + assert other.name in message + + +@given(player_others=player_others()) +def test_set_prefs(player_others): + """ Test that a Player instance can set its preferences correctly. """ + + player, others = player_others + + player.set_prefs(others) + assert player.prefs == others + assert player._pref_names == [o.name for o in others] + assert player._original_prefs == others + + +@given(player_others=player_others()) +def test_keep_original_prefs(player_others): + """Test that a Player instance keeps a record of their original preference + list even when their preferences are updated.""" + + player, others = player_others + + player.set_prefs(others) + player.set_prefs([]) + assert player.prefs == [] + assert player._pref_names == [] + assert player._original_prefs == others + + +@given(player_others=player_others()) +def test_forget(player_others): + """ Test that a Player instance can forget another player. """ + + player, others = player_others + player.set_prefs(others) + + for i, other in enumerate(others[:-1]): + player._forget(other) + assert player.prefs == others[i + 1 :] + + player._forget(others[-1]) + assert player.prefs == [] + assert player._original_prefs == others + + +@given(player_others=player_others()) +def test_prefers(player_others): + """Test that a Player instance can compare its preference between two + players.""" + + player, others = player_others + + player.set_prefs(others) + for i, other in enumerate(others[:-1]): + assert player.prefers(other, others[i + 1]) diff --git a/tests/base/util.py b/tests/base/util.py new file mode 100644 index 0000000..5998e75 --- /dev/null +++ b/tests/base/util.py @@ -0,0 +1,22 @@ +""" Useful functions for base class tests. """ +from hypothesis.strategies import composite, integers, text + +from matching import BasePlayer + + +@composite +def player_others( + draw, + player_name_from=text(), + other_names_from=text(), + min_size=1, + max_size=10, +): + """A custom strategy for creating a player and a set of other players, all + of whom are `BasePlayer` instances.""" + + size = draw(integers(min_value=min_size, max_value=max_size)) + player = BasePlayer(draw(player_name_from)) + others = [BasePlayer(draw(other_names_from)) for _ in range(size)] + + return player, others diff --git a/tests/hospital_resident/params.py b/tests/hospital_resident/params.py deleted file mode 100644 index c64da66..0000000 --- a/tests/hospital_resident/params.py +++ /dev/null @@ -1,109 +0,0 @@ -""" Toolbox for HR tests. """ - -import itertools as it -from collections import defaultdict - -import numpy as np -from hypothesis import given -from hypothesis.strategies import booleans, integers, lists, sampled_from - -from matching import Player as Resident -from matching.games import HospitalResident -from matching.players import Hospital - - -def make_players(resident_names, hospital_names, capacities): - """ Given some names and capacities, make a set of players for HR. """ - - residents = [Resident(name) for name in resident_names] - hospitals = [ - Hospital(name, capacity) - for name, capacity in zip(hospital_names, capacities) - ] - - possible_prefs = get_possible_prefs(hospitals) - logged_prefs = {} - for resident in residents: - prefs = possible_prefs[np.random.randint(len(possible_prefs))] - resident.set_prefs(prefs) - for hospital in prefs: - try: - logged_prefs[hospital] += [resident] - except KeyError: - logged_prefs[hospital] = [resident] - - for hospital, resids in logged_prefs.items(): - hospital.set_prefs(np.random.permutation(resids).tolist()) - - return residents, [hosp for hosp in hospitals if hosp.prefs is not None] - - -def get_possible_prefs(players): - """Generate the list of all possible non-empty preference lists made from a - list of players.""" - - all_ordered_subsets = { - tuple(set(sub)) for sub in it.product(players, repeat=len(players)) - } - - possible_prefs = [ - list(perm) - for sub in all_ordered_subsets - for perm in it.permutations(sub) - ] - - return possible_prefs - - -def make_game(resident_names, hospital_names, capacities, seed, clean): - """ Make all of the residents and hospitals, and the match itself. """ - - np.random.seed(seed) - residents, hospitals = make_players( - resident_names, hospital_names, capacities - ) - game = HospitalResident(residents, hospitals, clean) - - return residents, hospitals, game - - -def make_prefs(resident_names, hospital_names, seed): - """ Make a valid set of preferences given a set of names. """ - - np.random.seed(seed) - resident_prefs, hospital_prefs = defaultdict(list), defaultdict(list) - possible_prefs = get_possible_prefs(hospital_names) - - for resident in resident_names: - prefs = possible_prefs[np.random.randint(len(possible_prefs))] - resident_prefs[resident].extend(prefs) - for hospital in prefs: - hospital_prefs[hospital].append(resident) - - for hospital in hospital_prefs: - np.random.shuffle(hospital_prefs[hospital]) - - return resident_prefs, hospital_prefs - - -HOSPITAL_RESIDENT = given( - resident_names=lists( - elements=sampled_from(["A", "B", "C", "D"]), - min_size=1, - max_size=4, - unique=True, - ), - hospital_names=lists( - elements=sampled_from(["X", "Y", "Z"]), - min_size=1, - max_size=3, - unique=True, - ), - capacities=lists( - elements=integers(min_value=2, max_value=4), - min_size=3, - max_size=3, - ), - seed=integers(min_value=0, max_value=2 ** 32 - 1), - clean=booleans(), -) diff --git a/tests/hospital_resident/test_algorithm.py b/tests/hospital_resident/test_algorithm.py index a7251d3..9db60a3 100644 --- a/tests/hospital_resident/test_algorithm.py +++ b/tests/hospital_resident/test_algorithm.py @@ -1,5 +1,6 @@ """ Tests for the Hospital-Resident algorithm. """ import numpy as np +from hypothesis import given from matching.algorithms.hospital_resident import ( hospital_optimal, @@ -7,22 +8,17 @@ resident_optimal, ) -from .params import HOSPITAL_RESIDENT, make_players +from .util import players -@HOSPITAL_RESIDENT -def test_hospital_resident( - resident_names, hospital_names, capacities, seed, clean -): - """Verify that the hospital-resident algorithm produces a valid solution +@given(players_=players()) +def test_hospital_resident(players_): + """Test that the hospital-resident algorithm produces a valid solution for an instance of HR.""" - np.random.seed(seed) - residents, hospitals = make_players( - resident_names, hospital_names, capacities - ) - matching = hospital_resident(residents, hospitals) + residents, hospitals = players_ + matching = hospital_resident(residents, hospitals) assert set(hospitals) == set(matching.keys()) matched_residents = {r for rs in matching.values() for r in rs} @@ -33,20 +29,16 @@ def test_hospital_resident( assert resident not in matched_residents -@HOSPITAL_RESIDENT -def test_resident_optimal( - resident_names, hospital_names, capacities, seed, clean -): - """Verify that the hospital-resident algorithm produces a valid, - resident-optimal matching for an instance of HR.""" +@given(players_=players()) +def test_resident_optimal(players_): + """Test that the resident-optimal algorithm produces a solution that is + indeed resident-optimal.""" - np.random.seed(seed) - residents, hospitals = make_players( - resident_names, hospital_names, capacities - ) - matching = resident_optimal(residents, hospitals) + residents, hospitals = players_ + matching = resident_optimal(residents, hospitals) assert set(hospitals) == set(matching.keys()) + assert all( [ r in set(residents) @@ -61,17 +53,14 @@ def test_resident_optimal( assert resident.prefs.index(resident.matching) == 0 -@HOSPITAL_RESIDENT -def test_hospital_optimal( - resident_names, hospital_names, capacities, seed, clean -): - """Verify that the hospital-resident algorithm produces a valid, - hospital-optimal matching for an instance of HR.""" +@given(players_=players()) +def test_hospital_optimal(players_): + """Verify that the hospital-optimal algorithm produces a solution that is + indeed hospital-optimal.""" - np.random.seed(seed) - _, hospitals = make_players(resident_names, hospital_names, capacities) - matching = hospital_optimal(hospitals) + _, hospitals = players_ + matching = hospital_optimal(hospitals) assert set(hospitals) == set(matching.keys()) for hospital, matches in matching.items(): diff --git a/tests/hospital_resident/test_solver.py b/tests/hospital_resident/test_solver.py index 6fc5e70..dff59b5 100644 --- a/tests/hospital_resident/test_solver.py +++ b/tests/hospital_resident/test_solver.py @@ -2,8 +2,10 @@ import warnings import pytest +from hypothesis import given +from hypothesis.strategies import booleans, sampled_from -from matching import Matching +from matching import MultipleMatching from matching import Player as Resident from matching.exceptions import ( MatchingError, @@ -13,70 +15,60 @@ from matching.games import HospitalResident from matching.players import Hospital -from .params import HOSPITAL_RESIDENT, make_game, make_prefs +from .util import connections, games, players -@HOSPITAL_RESIDENT -def test_init(resident_names, hospital_names, capacities, seed, clean): +@given(players=players(), clean=booleans()) +def test_init(players, clean): """Test that an instance of HospitalResident is created correctly when passed a set of players.""" - residents, hospitals, game = make_game( - resident_names, hospital_names, capacities, seed, clean - ) + residents, hospitals = players + + game = HospitalResident(residents, hospitals, clean) for resident, game_resident in zip(residents, game.residents): assert resident.name == game_resident.name - assert resident.pref_names == game_resident.pref_names + assert resident._pref_names == game_resident._pref_names for hospital, game_hospital in zip(hospitals, game.hospitals): assert hospital.name == game_hospital.name - assert hospital.pref_names == game_hospital.pref_names + assert hospital._pref_names == game_hospital._pref_names assert hospital.capacity == game_hospital.capacity assert all([resident.matching is None for resident in game.residents]) assert all([hospital.matching == [] for hospital in game.hospitals]) assert game.matching is None - assert game.clean is clean -@HOSPITAL_RESIDENT -def test_create_from_dictionaries( - resident_names, hospital_names, capacities, seed, clean -): +@given(connections=connections(), clean=booleans()) +def test_create_from_dictionaries(connections, clean): """Test that HospitalResident is created correctly when passed a set of dictionaries for each party.""" - resident_prefs, hospital_prefs = make_prefs( - resident_names, hospital_names, seed - ) + resident_prefs, hospital_prefs, capacities = connections - capacities_ = dict(zip(hospital_names, capacities)) game = HospitalResident.create_from_dictionaries( - resident_prefs, hospital_prefs, capacities_, clean + resident_prefs, hospital_prefs, capacities, clean ) for resident in game.residents: - assert resident.pref_names == resident_prefs[resident.name] + assert resident._pref_names == resident_prefs[resident.name] assert resident.matching is None for hospital in game.hospitals: - assert hospital.pref_names == hospital_prefs[hospital.name] - assert hospital.capacity == capacities_[hospital.name] + assert hospital._pref_names == hospital_prefs[hospital.name] + assert hospital.capacity == capacities[hospital.name] assert hospital.matching == [] assert game.matching is None assert game.clean is clean -@HOSPITAL_RESIDENT -def test_check_inputs(resident_names, hospital_names, capacities, seed, clean): +@given(game=games()) +def test_check_inputs(game): """ Test that inputs to an instance of HR can be verified. """ - _, _, game = make_game( - resident_names, hospital_names, capacities, seed, clean - ) - with warnings.catch_warnings(record=True) as w: game.check_inputs() @@ -85,18 +77,12 @@ def test_check_inputs(resident_names, hospital_names, capacities, seed, clean): assert game.hospitals == game._all_hospitals -@HOSPITAL_RESIDENT -def test_check_inputs_resident_prefs_all_hospitals( - resident_names, hospital_names, capacities, seed, clean -): +@given(game=games()) +def test_check_inputs_resident_prefs_all_hospitals(game): """Test that every resident has only hospitals in its preference list. If not, check that a warning is caught and the player's preferences are changed.""" - _, _, game = make_game( - resident_names, hospital_names, capacities, seed, clean - ) - resident = game.residents[0] resident.prefs = [Resident("foo")] with warnings.catch_warnings(record=True) as w: @@ -106,22 +92,16 @@ def test_check_inputs_resident_prefs_all_hospitals( assert isinstance(message, PreferencesChangedWarning) assert resident.name in str(message) assert "foo" in str(message) - if clean: + if game.clean: assert resident.prefs == [] -@HOSPITAL_RESIDENT -def test_check_inputs_hospital_prefs_all_residents( - resident_names, hospital_names, capacities, seed, clean -): +@given(game=games()) +def test_check_inputs_hospital_prefs_all_residents(game): """Test that every hospital has only residents in its preference list. If not, check that a warning is caught and the player's preferences are changed.""" - _, _, game = make_game( - resident_names, hospital_names, capacities, seed, clean - ) - hospital = game.hospitals[0] hospital.prefs = [Resident("foo")] with warnings.catch_warnings(record=True) as w: @@ -131,22 +111,16 @@ def test_check_inputs_hospital_prefs_all_residents( assert isinstance(message, PreferencesChangedWarning) assert hospital.name in str(message) assert "foo" in str(message) - if clean: + if game.clean: assert hospital.prefs == [] -@HOSPITAL_RESIDENT -def test_check_inputs_hospital_prefs_all_reciprocated( - resident_names, hospital_names, capacities, seed, clean -): +@given(game=games()) +def test_check_inputs_hospital_prefs_all_reciprocated(game): """Test that each hospital has ranked only those residents that have ranked it. If not, check that a warning is caught and the hospital has forgotten any such players.""" - _, _, game = make_game( - resident_names, hospital_names, capacities, seed, clean - ) - hospital = game.hospitals[0] resident = hospital.prefs[0] resident._forget(hospital) @@ -157,22 +131,16 @@ def test_check_inputs_hospital_prefs_all_reciprocated( assert isinstance(message, PreferencesChangedWarning) assert hospital.name in str(message) assert resident.name in str(message) - if clean: + if game.clean: assert resident not in hospital.prefs -@HOSPITAL_RESIDENT -def test_check_inputs_hospital_reciprocated_all_prefs( - resident_names, hospital_names, capacities, seed, clean -): +@given(game=games()) +def test_check_inputs_hospital_reciprocated_all_prefs(game): """Test that each hospital has ranked all those residents that have ranked it. If not, check that a warning is caught and any such resident has forgotten the hospital.""" - _, _, game = make_game( - resident_names, hospital_names, capacities, seed, clean - ) - hospital = game.hospitals[0] resident = hospital.prefs[0] hospital._forget(resident) @@ -185,21 +153,15 @@ def test_check_inputs_hospital_reciprocated_all_prefs( assert isinstance(message, PreferencesChangedWarning) assert hospital.name in str(message) assert resident.name in str(message) - if clean: + if game.clean: assert hospital not in resident.prefs -@HOSPITAL_RESIDENT -def test_check_inputs_resident_prefs_all_nonempty( - resident_names, hospital_names, capacities, seed, clean -): +@given(game=games()) +def test_check_inputs_resident_prefs_all_nonempty(game): """Test that every resident has a non-empty preference list. If not, check that a warning is caught and the player has been removed from the game.""" - _, _, game = make_game( - resident_names, hospital_names, capacities, seed, clean - ) - resident = game.residents[0] resident.prefs = [] with warnings.catch_warnings(record=True) as w: @@ -208,21 +170,15 @@ def test_check_inputs_resident_prefs_all_nonempty( message = w[-1].message assert isinstance(message, PlayerExcludedWarning) assert resident.name in str(message) - if clean: + if game.clean: assert resident not in game.residents -@HOSPITAL_RESIDENT -def test_check_inputs_hospital_prefs_all_nonempty( - resident_names, hospital_names, capacities, seed, clean -): +@given(game=games()) +def test_check_inputs_hospital_prefs_all_nonempty(game): """Test that every hospital has a non-empty preference list. If not, check that a warning is caught and the player has been removed from the game.""" - _, _, game = make_game( - resident_names, hospital_names, capacities, seed, clean - ) - hospital = game.hospitals[0] hospital.prefs = [] with warnings.catch_warnings(record=True) as w: @@ -231,22 +187,16 @@ def test_check_inputs_hospital_prefs_all_nonempty( message = w[-1].message assert isinstance(message, PlayerExcludedWarning) assert hospital.name in str(message) - if clean: + if game.clean: assert hospital not in game.hospitals -@HOSPITAL_RESIDENT -def test_check_inputs_hospital_capacity( - resident_names, hospital_names, capacities, seed, clean -): +@given(game=games()) +def test_check_inputs_hospital_capacity(game): """Test that each hospital has enough space to accommodate their largest project, but does not offer a surplus of spaces from their projects. Otherwise, raise an Exception.""" - _, _, game = make_game( - resident_names, hospital_names, capacities, seed, clean - ) - hospital = game.hospitals[0] capacity = hospital.capacity hospital.capacity = 0 @@ -257,68 +207,50 @@ def test_check_inputs_hospital_capacity( message = w[-1].message assert isinstance(message, PlayerExcludedWarning) assert hospital.name in str(message) - if clean: + if game.clean: assert hospital not in game.hospitals -@HOSPITAL_RESIDENT -def test_solve(resident_names, hospital_names, capacities, seed, clean): - """Test that HospitalResident can solve games correctly when passed - players.""" - - for optimal in ["resident", "hospital"]: - residents, hospitals, game = make_game( - resident_names, hospital_names, capacities, seed, clean - ) +@given(game=games(), optimal=sampled_from(["resident", "hospital"])) +def test_solve(game, optimal): + """ Test that HospitalResident can solve games correctly. """ - matching = game.solve(optimal) - assert isinstance(matching, Matching) + matching = game.solve(optimal) + assert isinstance(matching, MultipleMatching) - hospitals = sorted(hospitals, key=lambda h: h.name) - matching_keys = sorted(matching.keys(), key=lambda k: k.name) - for game_hospital, hospital in zip(matching_keys, hospitals): - assert game_hospital.name == hospital.name - assert game_hospital.pref_names == hospital.pref_names - assert game_hospital.capacity == hospital.capacity + hospitals = sorted(game.hospitals, key=lambda h: h.name) + matching_keys = sorted(matching.keys(), key=lambda k: k.name) + for game_hospital, hospital in zip(matching_keys, hospitals): + assert game_hospital.name == hospital.name + assert game_hospital._pref_names == hospital._pref_names + assert game_hospital.capacity == hospital.capacity - matched_residents = [ - resident for match in matching.values() for resident in match - ] + matched_residents = [ + resident for match in matching.values() for resident in match + ] - assert matched_residents != [] and set(matched_residents).issubset( - set(game.residents) - ) + assert matched_residents != [] and set(matched_residents).issubset( + set(game.residents) + ) - for resident in set(game.residents) - set(matched_residents): - assert resident.matching is None + for resident in set(game.residents) - set(matched_residents): + assert resident.matching is None -@HOSPITAL_RESIDENT -def test_check_validity( - resident_names, hospital_names, capacities, seed, clean -): +@given(game=games()) +def test_check_validity(game): """Test that HospitalResident finds a valid matching when the game is solved.""" - _, _, game = make_game( - resident_names, hospital_names, capacities, seed, clean - ) - game.solve() assert game.check_validity() -@HOSPITAL_RESIDENT -def test_check_for_unacceptable_matches_residents( - resident_names, hospital_names, capacities, seed, clean -): +@given(game=games()) +def test_check_for_unacceptable_matches_residents(game): """Test that HospitalResident recognises a valid matching requires each resident to have a preference of their match, if they have one.""" - _, _, game = make_game( - resident_names, hospital_names, capacities, seed, clean - ) - resident = game.residents[0] hospital = Hospital(name="foo", capacity=1) resident.matching = hospital @@ -337,17 +269,11 @@ def test_check_for_unacceptable_matches_residents( assert issue == error -@HOSPITAL_RESIDENT -def test_check_for_unacceptable_matches_hospitals( - resident_names, hospital_names, capacities, seed, clean -): +@given(game=games()) +def test_check_for_unacceptable_matches_hospitals(game): """Test that HospitalResident recognises a valid matching requires each hospital to have a preference of each of its matches, if any.""" - _, _, game = make_game( - resident_names, hospital_names, capacities, seed, clean - ) - hospital = game.hospitals[0] resident = Resident(name="foo") hospital.matching.append(resident) @@ -366,17 +292,11 @@ def test_check_for_unacceptable_matches_hospitals( assert issue == error -@HOSPITAL_RESIDENT -def test_check_for_oversubscribed_hospitals( - resident_names, hospital_names, capacities, seed, clean -): +@given(game=games()) +def test_check_for_oversubscribed_hospitals(game): """Test that HospitalResident recognises a valid matching requires all hospitals to not be oversubscribed.""" - _, _, game = make_game( - resident_names, hospital_names, capacities, seed, clean - ) - hospital = game.hospitals[0] hospital.matching = range(hospital.capacity + 1) diff --git a/tests/hospital_resident/util.py b/tests/hospital_resident/util.py new file mode 100644 index 0000000..c9af988 --- /dev/null +++ b/tests/hospital_resident/util.py @@ -0,0 +1,99 @@ +""" Strategies for HR tests. """ +from hypothesis.strategies import ( + booleans, + composite, + integers, + lists, + sampled_from, + text, +) + +from matching import Player as Resident +from matching.games import HospitalResident +from matching.players import Hospital + + +@composite +def names(draw, taken_from, size): + """ A strategy for getting player names. """ + + names = draw(lists(taken_from, min_size=size, max_size=size, unique=True)) + return names + + +@composite +def connections( + draw, + residents_from=text(), + hospitals_from=text(), + min_residents=1, + max_residents=5, + min_hospitals=1, + max_hospitals=3, +): + """ A custom strategy for making a set of connections between players. """ + + num_residents = draw(integers(min_residents, max_residents)) + num_hospitals = draw(integers(min_hospitals, max_hospitals)) + + resident_names = draw(names(residents_from, num_residents)) + hospital_names = draw(names(hospitals_from, num_hospitals)) + + resident_prefs = {} + hospital_prefs = {h: [] for h in hospital_names} + for resident in resident_names: + hospitals = draw( + lists(sampled_from(hospital_names), min_size=1, unique=True) + ) + resident_prefs[resident] = hospitals + for hospital in hospitals: + hospital_prefs[hospital].append(resident) + + capacities = {} + for hospital, residents in list(hospital_prefs.items()): + if residents: + capacities[hospital] = draw(integers(min_residents, max_residents)) + else: + del hospital_prefs[hospital] + + return resident_prefs, hospital_prefs, capacities + + +@composite +def players(draw, **kwargs): + """ A custom strategy for making a set of residents and hospitals. """ + + resident_prefs, hospital_prefs, capacities = draw(connections(**kwargs)) + + residents = [Resident(name) for name in resident_prefs] + hospitals = [Hospital(name, cap) for name, cap in capacities.items()] + + residents = _get_preferences(residents, hospitals, resident_prefs) + hospitals = _get_preferences(hospitals, residents, hospital_prefs) + + return residents, hospitals + + +def _get_preferences(party, others, preferences): + """ Get and assign preference instances. """ + + for player in party: + names = preferences[player.name] + prefs = [] + for name in names: + for other in others: + if other.name == name: + prefs.append(other) + break + + player.set_prefs(prefs) + + return party + + +@composite +def games(draw, clean=booleans(), **kwargs): + """ A custom strategy for making a game instance. """ + + residents, hospitals = draw(players(**kwargs)) + return HospitalResident(residents, hospitals, clean) diff --git a/tests/players/test_hospital.py b/tests/players/test_hospital.py index 90f222d..011f39b 100644 --- a/tests/players/test_hospital.py +++ b/tests/players/test_hospital.py @@ -18,11 +18,11 @@ def test_init(name, capacity): assert hospital.name == name assert hospital.capacity == capacity - assert hospital._original_capacity == capacity - assert hospital.prefs is None - assert hospital.pref_names is None - assert hospital._original_prefs is None + assert hospital.prefs == [] assert hospital.matching == [] + assert hospital._pref_names == [] + assert hospital._original_prefs is None + assert hospital._original_capacity == capacity @given(name=text(), capacity=capacity, pref_names=pref_names) diff --git a/tests/players/test_player.py b/tests/players/test_player.py index f3310d1..0fa1d4f 100644 --- a/tests/players/test_player.py +++ b/tests/players/test_player.py @@ -6,39 +6,6 @@ from matching import Player -@given(name=text()) -def test_init(name): - """ Make an instance of Player and check their attributes are correct. """ - - player = Player(name) - - assert player.name == name - assert player.prefs is None - assert player._original_prefs is None - assert player.matching is None - - -@given(name=text()) -def test_repr(name): - """ Verify that a Player instance is represented by their name. """ - - player = Player(name) - - assert repr(player) == name - - -@given(name=text(), pref_names=lists(text(), min_size=1)) -def test_set_prefs(name, pref_names): - """ Verify a Player can set its preferences correctly. """ - - player = Player(name) - others = [Player(other) for other in pref_names] - - player.set_prefs(others) - assert player.prefs == others - assert player._original_prefs == others - - @given(name=text(), pref_names=lists(text(), min_size=1)) def test_get_favourite(name, pref_names): """ Check the correct player is returned as the favourite of a player. """ @@ -74,23 +41,6 @@ def test_unmatch(name, pref_names): assert player.matching is None -@given(name=text(), pref_names=lists(text(), min_size=1)) -def test_forget(name, pref_names): - """ Test that a player can forget somebody. """ - - player = Player(name) - others = [Player(other) for other in pref_names] - - player.set_prefs(others) - for i, other in enumerate(others[:-1]): - player._forget(other) - assert player.prefs == others[i + 1 :] - - player._forget(others[-1]) - assert player.prefs == [] - assert player._original_prefs == others - - @given(name=text(), pref_names=lists(text(), min_size=1)) def test_get_successors(name, pref_names): """Test that the correct successors to another player in a player's @@ -108,19 +58,6 @@ def test_get_successors(name, pref_names): assert player.get_successors() == [] -@given(name=text(), pref_names=lists(text(), min_size=1, unique=True)) -def test_prefers(name, pref_names): - """Test that a comparison of preference between two other players can be - found for a player.""" - - player = Player(name) - others = [Player(other) for other in pref_names] - - player.set_prefs(others) - for i, other in enumerate(others[:-1]): - assert player.prefers(other, others[i + 1]) - - @given(name=text(), pref_names=lists(text(), min_size=1, unique=True)) def test_check_if_match_unacceptable(name, pref_names): """ Test that the acceptability of a match is caught correctly. """ diff --git a/tests/players/test_project.py b/tests/players/test_project.py index 693d730..55bed14 100644 --- a/tests/players/test_project.py +++ b/tests/players/test_project.py @@ -16,10 +16,10 @@ def test_init(name, capacity): assert project.name == name assert project.capacity == capacity assert project.supervisor is None - assert project.prefs is None - assert project.pref_names is None - assert project._original_prefs is None + assert project.prefs == [] assert project.matching == [] + assert project._pref_names == [] + assert project._original_prefs is None @given(name=text(), capacity=integers()) diff --git a/tests/players/test_supervisor.py b/tests/players/test_supervisor.py index 2cdc970..0eddf5f 100644 --- a/tests/players/test_supervisor.py +++ b/tests/players/test_supervisor.py @@ -16,10 +16,10 @@ def test_init(name, capacity): assert supervisor.name == name assert supervisor.capacity == capacity assert supervisor.projects == [] - assert supervisor.prefs is None - assert supervisor.pref_names is None - assert supervisor._original_prefs is None + assert supervisor.prefs == [] assert supervisor.matching == [] + assert supervisor._pref_names == [] + assert supervisor._original_prefs is None @given(name=text(), capacity=integers(), pref_names=lists(text(), min_size=1)) @@ -32,15 +32,16 @@ def test_set_prefs(name, capacity, pref_names): students = [] for sname in pref_names: student = Student(sname) - student.prefs = projects + student.set_prefs(projects) students.append(student) supervisor.projects = projects supervisor.set_prefs(students) assert supervisor.prefs == students - assert supervisor.pref_names == pref_names + assert supervisor._pref_names == pref_names assert supervisor._original_prefs == students + for project in supervisor.projects: assert project.prefs == students - assert project.pref_names == pref_names + assert project._pref_names == pref_names assert project._original_prefs == students diff --git a/tests/stable_marriage/test_solver.py b/tests/stable_marriage/test_solver.py index 083f9ff..c9040e1 100644 --- a/tests/stable_marriage/test_solver.py +++ b/tests/stable_marriage/test_solver.py @@ -1,7 +1,7 @@ """ Unit tests for the SM solver. """ import pytest -from matching import Matching, Player +from matching import Player, SingleMatching from matching.exceptions import MatchingError from matching.games import StableMarriage @@ -20,7 +20,7 @@ def test_init(player_names, seed): suitors + reviewers, game.suitors + game.reviewers ): assert player.name == game_player.name - assert player.pref_names == game_player.pref_names + assert player._pref_names == game_player._pref_names assert all( [player.matching is None for player in game.suitors + game.reviewers] @@ -37,11 +37,11 @@ def test_create_from_dictionaries(player_names, seed): game = StableMarriage.create_from_dictionaries(suitor_prefs, reviewer_prefs) for suitor in game.suitors: - assert suitor_prefs[suitor.name] == suitor.pref_names + assert suitor_prefs[suitor.name] == suitor._pref_names assert suitor.matching is None for reviewer in game.reviewers: - assert reviewer_prefs[reviewer.name] == reviewer.pref_names + assert reviewer_prefs[reviewer.name] == reviewer._pref_names assert reviewer.matching is None assert game.matching is None @@ -90,7 +90,7 @@ def test_solve(player_names, seed): game = StableMarriage(suitors, reviewers) matching = game.solve(optimal) - assert isinstance(matching, Matching) + assert isinstance(matching, SingleMatching) suitors = sorted(suitors, key=lambda s: s.name) reviewers = sorted(reviewers, key=lambda r: r.name) @@ -100,11 +100,11 @@ def test_solve(player_names, seed): for game_suitor, suitor in zip(matching_keys, suitors): assert game_suitor.name == suitor.name - assert game_suitor.pref_names == suitor.pref_names + assert game_suitor._pref_names == suitor._pref_names for game_reviewer, reviewer in zip(matching_values, reviewers): assert game_reviewer.name == reviewer.name - assert game_reviewer.pref_names == reviewer.pref_names + assert game_reviewer._pref_names == reviewer._pref_names @STABLE_MARRIAGE diff --git a/tests/stable_roommates/params.py b/tests/stable_roommates/params.py deleted file mode 100644 index e5927d4..0000000 --- a/tests/stable_roommates/params.py +++ /dev/null @@ -1,44 +0,0 @@ -""" Hypothesis decorators for SR tests. """ - -import numpy as np -from hypothesis import given -from hypothesis.strategies import integers, lists, sampled_from - -from matching import Player - - -def make_players(player_names, seed): - """ Given some names, make a valid set of players. """ - - np.random.seed(seed) - players = [Player(name) for name in player_names] - - for player in players: - player.set_prefs( - np.random.permutation([p for p in players if p != player]).tolist() - ) - - return players - - -def make_prefs(player_names, seed): - """ Given some names, make a valid set of preferences for the players. """ - - np.random.seed(seed) - player_prefs = { - name: np.random.permutation( - [p for p in player_names if p != name] - ).tolist() - for name in player_names - } - - return player_prefs - - -PLAYER_NAMES = lists( - sampled_from(["A", "B", "C", "D"]), min_size=4, max_size=4, unique=True -) - -STABLE_ROOMMATES = given( - player_names=PLAYER_NAMES, seed=integers(min_value=0, max_value=2 ** 32 - 1) -) diff --git a/tests/stable_roommates/test_algorithm.py b/tests/stable_roommates/test_algorithm.py index a15d2d9..af40928 100644 --- a/tests/stable_roommates/test_algorithm.py +++ b/tests/stable_roommates/test_algorithm.py @@ -1,5 +1,5 @@ """ Integration and unit tests for the SR algorithm. """ -from hypothesis import assume +from hypothesis import assume, given from matching.algorithms.stable_roommates import ( first_phase, @@ -9,32 +9,33 @@ stable_roommates, ) -from .params import STABLE_ROOMMATES, make_players +from .util import players -@STABLE_ROOMMATES -def test_first_phase(player_names, seed): +@given(players=players()) +def test_first_phase(players): """Verify that the first phase of the algorithm produces a valid set of reduced preference players.""" - players = make_players(player_names, seed) players = first_phase(players) + player_matched = {player: player.matching is not None for player in players} + assert sum(player_matched.values()) >= len(players) - 1 + for player in players: if player.matching is None: assert player.prefs == [] else: assert player.matching in player.prefs - assert {p.name for p in player.prefs}.issubset(player.pref_names) + assert {p.name for p in player.prefs}.issubset(player._pref_names) -@STABLE_ROOMMATES -def test_locate_all_or_nothing_cycle(player_names, seed): +@given(players=players()) +def test_locate_all_or_nothing_cycle(players): """Verify that a cycle of (least-preferred, second-choice) players can be identified from a set of players.""" - players = make_players(player_names, seed) player = players[-1] cycle = locate_all_or_nothing_cycle(player) @@ -43,24 +44,13 @@ def test_locate_all_or_nothing_cycle(player_names, seed): assert second.prefs.index(last) == len(second.prefs) - 1 -def status(players): - for player in players: - print( - f"{player.name:>5}", - f"{str(player.prefs):>30}", - f"{str(player.matching):>5}", - ) - - -@STABLE_ROOMMATES -def test_get_pairs_to_delete(player_names, seed): +@given(players=players()) +def test_get_pairs_to_delete(players): """Verify that all necessary pairs are identified to remove a cycle from the game.""" assert get_pairs_to_delete([]) == [] - players = make_players(player_names, seed) - players = first_phase(players) assume(any(len(p.prefs) > 1 for p in players)) @@ -79,12 +69,11 @@ def test_get_pairs_to_delete(player_names, seed): assert (right, other) in pairs or (other, right) in pairs -@STABLE_ROOMMATES -def test_second_phase(player_names, seed): +@given(players=players()) +def test_second_phase(players): """Verify that the second phase of the algorithm produces a valid set of players with appropriate matches.""" - players = make_players(player_names, seed) players = first_phase(players) assume(any(len(p.prefs) > 1 for p in players)) @@ -92,22 +81,21 @@ def test_second_phase(player_names, seed): for player in players: if player.prefs: - assert player.prefs == [player.matching] + assert player.prefs[0] == player.matching else: assert player.matching is None -@STABLE_ROOMMATES -def test_stable_roommates(player_names, seed): +@given(players=players()) +def test_stable_roommates(players): """ Verify that the algorithm can terminate with a valid matching. """ - players = make_players(player_names, seed) matching = stable_roommates(players) - if None in matching.values(): - assert all(val is None for val in matching.values()) + assert isinstance(matching, dict) - else: - for player, other in matching.items(): - assert player.prefs == [other] - assert other.matching == player + for player, match in matching.items(): + if match is None: + assert not player.prefs + else: + assert match == player.prefs[0] diff --git a/tests/stable_roommates/test_solver.py b/tests/stable_roommates/test_solver.py index 5bec8b3..e0a90d3 100644 --- a/tests/stable_roommates/test_solver.py +++ b/tests/stable_roommates/test_solver.py @@ -1,87 +1,82 @@ """ Unit tests for the SR solver. """ import pytest +from hypothesis import given -from matching import Matching +from matching import Player, SingleMatching from matching.exceptions import MatchingError from matching.games import StableRoommates -from .params import STABLE_ROOMMATES, make_players, make_prefs +from .util import connections, games, players -@STABLE_ROOMMATES -def test_init(player_names, seed): - """Test that the StableRoommates solver takes a set of preformed players - correctly.""" +@given(players=players()) +def test_init(players): + """Test that the StableRoommates solver has the correct attributes at + instantiation.""" - players = make_players(player_names, seed) game = StableRoommates(players) for player, game_player in zip(players, game.players): assert player.name == game_player.name - assert player.pref_names == game_player.pref_names + assert player._pref_names == game_player._pref_names - assert all([player.matching is None for player in game.players]) assert game.matching is None -@STABLE_ROOMMATES -def test_create_from_dictionary(player_names, seed): +@given(preferences=connections()) +def test_create_from_dictionary(preferences): """Test that StableRoommates solver can take a preference dictionary correctly.""" - player_prefs = make_prefs(player_names, seed) - game = StableRoommates.create_from_dictionary(player_prefs) + game = StableRoommates.create_from_dictionary(preferences) for player in game.players: - assert player_prefs[player.name] == player.pref_names + assert preferences[player.name] == player._pref_names assert player.matching is None assert game.matching is None -@STABLE_ROOMMATES -def test_check_inputs(player_names, seed): +@given(players=players()) +def test_check_inputs(players): """Test StableRoommates raises a ValueError when a player has not ranked all other players.""" - players = make_players(player_names, seed) players[0].prefs = players[0].prefs[:-1] with pytest.raises(Exception): StableRoommates(players) -@STABLE_ROOMMATES -def test_solve(player_names, seed): - """Test that StableRoommates can solve games correctly when passed a set of - players.""" +@given(game=games()) +def test_solve(game): + """Test that StableRoommates can solve games correctly.""" - players = make_players(player_names, seed) - game = StableRoommates(players) + print("THE GAME\n========") + for player in game.players: + print(player, player.prefs) matching = game.solve() - assert isinstance(matching, Matching) + assert isinstance(matching, SingleMatching) - players = sorted(players, key=lambda p: p.name) + players = sorted(game.players, key=lambda p: p.name) matching_keys = sorted(matching.keys(), key=lambda k: k.name) for game_player, player in zip(matching_keys, players): assert game_player.name == player.name - assert game_player.pref_names == player.pref_names + assert game_player._pref_names == player._pref_names for match in matching.values(): assert match is None or match in game.players -@STABLE_ROOMMATES -def test_check_validity(player_names, seed): +@given(game=games()) +def test_check_validity(game): """Test that StableRoommates can raise a ValueError if any players are left unmatched.""" - players = make_players(player_names, seed) - game = StableRoommates(players) - matching = game.solve() + if None in matching.values(): with pytest.raises(MatchingError): game.check_validity() @@ -93,8 +88,6 @@ def test_check_validity(player_names, seed): def test_stability(): """Test that StableRoommates can recognise whether a matching is stable.""" - from matching import Player - players = [Player("A"), Player("B"), Player("C"), Player("D")] a, b, c, d = players diff --git a/tests/stable_roommates/util.py b/tests/stable_roommates/util.py new file mode 100644 index 0000000..929aa3b --- /dev/null +++ b/tests/stable_roommates/util.py @@ -0,0 +1,59 @@ +""" Strategies for SR tests. """ + +from hypothesis.strategies import composite, integers, lists, permutations + +from matching import Player +from matching.games import StableRoommates + + +@composite +def connections(draw, players_from=integers(), min_players=4, max_players=10): + """ A strategy for making a set of connections between players. """ + + num_players = draw(integers(min_players, max_players)) + + players = draw( + lists( + players_from, + min_size=num_players, + max_size=num_players, + unique=True, + ) + ) + + preferences = {} + for player in players: + others = [p for p in players if p != player] + prefs = draw(permutations(others)) + preferences[player] = prefs + + return preferences + + +@composite +def players(draw, **kwargs): + """ A strategy for making a set of players. """ + + preferences = draw(connections(**kwargs)) + + players_ = [Player(name) for name in preferences] + for player in players_: + names = preferences[player.name] + prefs = [] + for name in names: + for other in players_: + if other.name == name: + prefs.append(other) + break + + player.set_prefs(prefs) + + return players_ + + +@composite +def games(draw, **kwargs): + """ A strategy for making an instance of SR. """ + + players_ = draw(players(**kwargs)) + return StableRoommates(players_) diff --git a/tests/student_allocation/test_solver.py b/tests/student_allocation/test_solver.py index a5bf193..268572f 100644 --- a/tests/student_allocation/test_solver.py +++ b/tests/student_allocation/test_solver.py @@ -3,7 +3,7 @@ import pytest -from matching import Matching +from matching import MultipleMatching from matching import Player as Student from matching.exceptions import ( CapacityChangedWarning, @@ -29,17 +29,17 @@ def test_init( for student, game_student in zip(students, game.students): assert student.name == game_student.name - assert student.pref_names == game_student.pref_names + assert student._pref_names == game_student._pref_names for project, game_project in zip(projects, game.projects): assert project.name == game_project.name - assert project.pref_names == game_project.pref_names + assert project._pref_names == game_project._pref_names assert project.capacity == game_project.capacity assert project.supervisor.name == game_project.supervisor.name for supervisor, game_supervisor in zip(supervisors, game.supervisors): assert supervisor.name == game_supervisor.name - assert supervisor.pref_names == game_supervisor.pref_names + assert supervisor._pref_names == game_supervisor._pref_names assert supervisor.capacity == game_supervisor.capacity supervisor_projects = [p.name for p in supervisor.projects] @@ -70,7 +70,7 @@ def test_create_from_dictionaries( ) for student in game.students: - assert student.pref_names == stud_prefs[student.name] + assert student._pref_names == stud_prefs[student.name] assert student.matching is None for project in game.projects: @@ -78,7 +78,7 @@ def test_create_from_dictionaries( assert project.matching == [] for supervisor in game.supervisors: - assert supervisor.pref_names == sup_prefs[supervisor.name] + assert supervisor._pref_names == sup_prefs[supervisor.name] assert supervisor.matching == [] assert game.matching is None @@ -328,13 +328,13 @@ def test_solve( ) matching = game.solve(optimal) - assert isinstance(matching, Matching) + assert isinstance(matching, MultipleMatching) projects = sorted(projects, key=lambda p: p.name) matching_keys = sorted(matching.keys(), key=lambda k: k.name) for game_project, project in zip(matching_keys, projects): assert game_project.name == project.name - assert game_project.pref_names == project.pref_names + assert game_project._pref_names == project._pref_names assert game_project.capacity == project.capacity assert game_project.supervisor.name == project.supervisor.name diff --git a/tests/test_matchings.py b/tests/test_matchings.py new file mode 100644 index 0000000..351468a --- /dev/null +++ b/tests/test_matchings.py @@ -0,0 +1,96 @@ +""" Tests for the matching classes. """ +from hypothesis import given +from hypothesis.strategies import composite, integers, lists, sampled_from, text + +from matching import MultipleMatching, SingleMatching +from matching.players import Hospital, Player + + +@composite +def singles(draw, names_from=text(), min_size=2, max_size=5): + """A custom strategy for generating a matching for `SingleMatching` out of + Player instances.""" + + size = draw(integers(min_value=min_size, max_value=max_size)) + players = [Player(draw(names_from)) for _ in range(size)] + + midpoint = size // 2 + keys, values = players[:midpoint], players[midpoint:] + dictionary = dict(zip(keys, values)) + + return dictionary + + +@composite +def multiples( + draw, + host_names_from=text(), + player_names_from=text(), + min_hosts=2, + max_hosts=5, + min_players=10, + max_players=20, +): + """A custom strategy for generating a matching for `MultipleMatching` out + of `Hospital` and lists of `Player` instances.""" + + num_hosts = draw(integers(min_value=min_hosts, max_value=max_hosts)) + num_players = draw(integers(min_value=min_players, max_value=max_players)) + + hosts = [ + Hospital(draw(host_names_from), max_players) for _ in range(num_hosts) + ] + players = [Player(draw(player_names_from)) for _ in range(num_players)] + + dictionary = {} + for host in hosts: + matches = draw(lists(sampled_from(players), min_size=0, unique=True)) + dictionary[host] = matches + + return dictionary + + +@given(dictionary=singles()) +def test_single_setitem_none(dictionary): + """Test that a player key in a `SingleMatching` instance can have its + value set to `None`.""" + + matching = SingleMatching(dictionary) + key = list(dictionary.keys())[0] + + matching[key] = None + assert matching[key] is None + assert key.matching is None + + +@given(dictionary=singles()) +def test_single_setitem_player(dictionary): + """Test that a player key in a `SingleMatching` instance can have its + value set to another player.""" + + matching = SingleMatching(dictionary) + key = list(dictionary.keys())[0] + val = list(dictionary.values())[-1] + + matching[key] = val + assert matching[key] == val + assert key.matching == val + assert val.matching == key + + +@given(dictionary=multiples()) +def test_multiple_setitem(dictionary): + """Test that a host player key in a `MultipleMatching` instance can have + its value set to a sublist of the matching's values.""" + + matching = MultipleMatching(dictionary) + host = list(dictionary.keys())[0] + players = list( + {player for players in dictionary.values() for player in players} + )[:-1] + + matching[host] = players + assert matching[host] == players + assert host.matching == players + for player in players: + assert player.matching == host diff --git a/tests/unit/test_game.py b/tests/unit/test_game.py deleted file mode 100644 index 8a77929..0000000 --- a/tests/unit/test_game.py +++ /dev/null @@ -1,83 +0,0 @@ -""" Tests for the BaseGame class. """ -import warnings - -import pytest -from hypothesis import given -from hypothesis.strategies import booleans, lists, text - -from matching import BaseGame, Player -from matching.exceptions import PreferencesChangedWarning - - -class DummyGame(BaseGame): - def solve(self): - raise NotImplementedError() - - def check_stability(self): - raise NotImplementedError() - - def check_validity(self): - raise NotImplementedError() - - -def test_init(): - """ Test the default parameters makes a valid instance of BaseGame. """ - - match = DummyGame() - - assert isinstance(match, BaseGame) - assert match.matching is None - assert match.blocking_pairs is None - - -@given( - name=text(), - other_names=lists(text(), min_size=1, unique=True), - clean=booleans(), -) -def test_check_inputs_player_prefs_unique(name, other_names, clean): - """ Test that a game can verify its players have unique preferences. """ - - player = Player(name) - others = [Player(other) for other in other_names] - player.set_prefs(others + others[:1]) - - game = DummyGame(clean) - game.players = [player] - - with warnings.catch_warnings(record=True) as w: - game._check_inputs_player_prefs_unique("players") - - message = w[-1].message - assert isinstance(message, PreferencesChangedWarning) - assert str(message).startswith(name) - assert others[0].name in str(message) - if clean: - assert player.pref_names == other_names - - -def test_no_solve(): - """Verify BaseGame raises a NotImplementedError when calling the `solve` - method.""" - - with pytest.raises(NotImplementedError): - match = DummyGame() - match.solve() - - -def test_no_check_stability(): - """Verify BaseGame raises a NotImplementedError when calling the - `check_stability` method.""" - - with pytest.raises(NotImplementedError): - match = DummyGame() - match.check_stability() - - -def test_no_check_validity(): - """Verify BaseGame raises a NotImplementError when calling the - `check_validity` method.""" - - with pytest.raises(NotImplementedError): - match = DummyGame() - match.check_validity() diff --git a/tests/unit/test_matching.py b/tests/unit/test_matching.py deleted file mode 100644 index 964722c..0000000 --- a/tests/unit/test_matching.py +++ /dev/null @@ -1,126 +0,0 @@ -""" Unit tests for the Matching class. """ - -import pytest - -from matching import Matching, Player - -suitors = [Player("A"), Player("B"), Player("C")] -reviewers = [Player(1), Player(2), Player(3)] - -suitors[0].set_prefs(reviewers) -suitors[1].set_prefs([reviewers[1], reviewers[0], reviewers[2]]) -suitors[2].set_prefs([reviewers[0], reviewers[2], reviewers[1]]) - -reviewers[0].set_prefs([suitors[1], suitors[0], suitors[2]]) -reviewers[1].set_prefs([suitors[1], suitors[0], suitors[2]]) -reviewers[2].set_prefs(suitors) - -dictionary = dict(zip(suitors, reviewers)) - - -def test_init(): - """Make an instance of the Matching class and check their attributes are - correct.""" - - matching = Matching() - assert matching == {} - - matching = Matching(dictionary) - assert matching == dictionary - - -def test_repr(): - """ Check that a Matching is represented by a normal dictionary. """ - - matching = Matching() - assert repr(matching) == "{}" - - matching = Matching(dictionary) - assert repr(matching) == str(dictionary) - - -def test_keys(): - """ Check a Matching can have its `keys` accessed. """ - - matching = Matching() - assert list(matching.keys()) == [] - - matching = Matching(dictionary) - assert list(matching.keys()) == suitors - - -def test_values(): - """ Check a Matching can have its `values` accessed. """ - - matching = Matching() - assert list(matching.values()) == [] - - matching = Matching(dictionary) - assert list(matching.values()) == reviewers - - -def test_getitem(): - """ Check that you can access items in a Matching correctly. """ - - matching = Matching(dictionary) - for key, val in matching.items(): - assert matching[key] == val - - -def test_setitem_key_error(): - """Check that a ValueError is raised if trying to add a new item to a - Matching.""" - - matching = Matching(dictionary) - - with pytest.raises(ValueError): - matching["foo"] = "bar" - - -def test_setitem_single(): - """Check that a key in Matching can have its value changed to another - Player instance.""" - - matching = Matching(dictionary) - suitor, reviewer = suitors[0], reviewers[-1] - - matching[suitor] = reviewer - assert matching[suitor] == reviewer - assert suitor.matching == reviewer - assert reviewer.matching == suitor - - -def test_setitem_none(): - """ Check can set item in Matching to be None. """ - - matching = Matching(dictionary) - suitor = suitors[0] - - matching[suitor] = None - assert matching[suitor] is None - assert suitor.matching is None - - -def test_setitem_multiple(): - """ Check can set item in Matching to be a group of Player instances. """ - - matching = Matching(dictionary) - suitor = suitors[0] - new_match = reviewers[:-1] - - matching[suitor] = new_match - assert set(matching[suitor]) == set(new_match) - for rev in new_match: - assert rev.matching == suitor - - -def test_setitem_val_error(): - """Check that a ValueError is raised if trying to set an item with some - illegal new matching.""" - - matching = Matching(dictionary) - suitor = suitors[0] - new_match = [1, 2, 3] - - with pytest.raises(ValueError): - matching[suitor] = new_match