From 451851c9558a8873751903dac08298c58beb82ff Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Wed, 1 Sep 2021 22:53:28 +0200 Subject: [PATCH 1/4] [WEI] Add a small test for the WEI algorithm with a few people Signed-off-by: Yohann D'ANELLO --- apps/wei/tests/test_wei_algorithm_2021.py | 65 +++++++++++++++++++++++ apps/wei/tests/test_wei_registration.py | 5 -- 2 files changed, 65 insertions(+), 5 deletions(-) create mode 100644 apps/wei/tests/test_wei_algorithm_2021.py diff --git a/apps/wei/tests/test_wei_algorithm_2021.py b/apps/wei/tests/test_wei_algorithm_2021.py new file mode 100644 index 00000000..c64c4c9e --- /dev/null +++ b/apps/wei/tests/test_wei_algorithm_2021.py @@ -0,0 +1,65 @@ +import random + +from django.contrib.auth.models import User +from django.test import TestCase + +from wei.forms.surveys.wei2021 import WEIBusInformation2021, WEISurvey2021, WORDS, WEISurveyInformation2021 +from wei.models import Bus, WEIClub, WEIRegistration + + +class TestWEIAlgorithm(TestCase): + """ + Run some tests to ensure that the WEI algorithm is working well. + """ + fixtures = ('initial',) + + def setUp(self): + """ + Create some test data, with one WEI and 10 buses with random score attributions. + """ + self.wei = WEIClub.objects.create( + name="WEI 2021", + email="wei2021@example.com", + date_start='2021-09-17', + date_end='2021-09-19', + ) + + self.buses = [] + for i in range(10): + bus = Bus.objects.create(wei=self.wei, name=f"Bus {i}", size=50) + self.buses.append(bus) + information = WEIBusInformation2021(bus) + for word in WORDS: + information.scores[word] = random.randint(0, 101) + information.save() + bus.save() + + def test_survey_algorithm_small(self): + """ + There are only a few people in each bus, ensure that each person has its best bus + """ + # Add a few users + for i in range(50): + user = User.objects.create(username=f"user{i}") + registration = WEIRegistration.objects.create( + user=user, + wei=self.wei, + first_year=True, + birth_date='2000-01-01', + ) + information = WEISurveyInformation2021(registration) + for j in range(1, 21): + setattr(information, f'word{j}', random.choice(WORDS)) + information.step = 20 + information.save(registration) + registration.save() + + # Run algorithm + WEISurvey2021.get_algorithm_class()().run_algorithm() + + # Ensure that everyone has its first choice + for r in WEIRegistration.objects.filter(wei=self.wei).all(): + survey = WEISurvey2021(r) + preferred_bus = survey.ordered_buses()[0][0] + chosen_bus = survey.information.get_selected_bus() + self.assertEqual(preferred_bus, chosen_bus) diff --git a/apps/wei/tests/test_wei_registration.py b/apps/wei/tests/test_wei_registration.py index 71eded3a..2a49a5d8 100644 --- a/apps/wei/tests/test_wei_registration.py +++ b/apps/wei/tests/test_wei_registration.py @@ -3,7 +3,6 @@ import subprocess from datetime import timedelta, date -from unittest import skip from api.tests import TestAPI from django.conf import settings @@ -813,10 +812,6 @@ class TestWEISurveyAlgorithm(TestCase): ) CurrentSurvey(self.registration).save() - @skip # FIXME Write good unit tests - def test_survey_algorithm(self): - CurrentSurvey.get_algorithm_class()().run_algorithm() - class TestWeiAPI(TestAPI): def setUp(self) -> None: From 74ab4df9fe66d3720cbdaf5101dd7fd0206ec62d Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Thu, 2 Sep 2021 01:36:37 +0200 Subject: [PATCH 2/4] [WEI] Extreme test with full buses and quality constraints Signed-off-by: Yohann D'ANELLO --- apps/wei/forms/surveys/base.py | 3 +- apps/wei/forms/surveys/wei2021.py | 3 ++ apps/wei/tests/test_wei_algorithm_2021.py | 46 ++++++++++++++++++++++- 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/apps/wei/forms/surveys/base.py b/apps/wei/forms/surveys/base.py index ec0bc980..030f9078 100644 --- a/apps/wei/forms/surveys/base.py +++ b/apps/wei/forms/surveys/base.py @@ -53,7 +53,8 @@ class WEIBusInformation: def free_seats(self, surveys: List["WEISurvey"] = None): size = self.bus.size already_occupied = WEIMembership.objects.filter(bus=self.bus).count() - valid_surveys = sum(1 for survey in surveys if survey.information.valid) if surveys else 0 + valid_surveys = sum(1 for survey in surveys if survey.information.valid + and survey.information.get_selected_bus() == self.bus) if surveys else 0 return size - already_occupied - valid_surveys def has_free_seats(self, surveys=None): diff --git a/apps/wei/forms/surveys/wei2021.py b/apps/wei/forms/surveys/wei2021.py index 49c1c628..2a9d5d27 100644 --- a/apps/wei/forms/surveys/wei2021.py +++ b/apps/wei/forms/surveys/wei2021.py @@ -190,6 +190,9 @@ class WEISurveyAlgorithm2021(WEISurveyAlgorithm): # If it does not exist, choose the next bus. least_preferred_survey.free() least_preferred_survey.save() + free_surveys.append(least_preferred_survey) survey.select_bus(bus) survey.save() break + else: + raise ValueError(f"User {survey.registration.user} has no free seat") diff --git a/apps/wei/tests/test_wei_algorithm_2021.py b/apps/wei/tests/test_wei_algorithm_2021.py index c64c4c9e..ccac4c9d 100644 --- a/apps/wei/tests/test_wei_algorithm_2021.py +++ b/apps/wei/tests/test_wei_algorithm_2021.py @@ -1,3 +1,4 @@ +import math import random from django.contrib.auth.models import User @@ -26,7 +27,7 @@ class TestWEIAlgorithm(TestCase): self.buses = [] for i in range(10): - bus = Bus.objects.create(wei=self.wei, name=f"Bus {i}", size=50) + bus = Bus.objects.create(wei=self.wei, name=f"Bus {i}", size=10) self.buses.append(bus) information = WEIBusInformation2021(bus) for word in WORDS: @@ -39,7 +40,7 @@ class TestWEIAlgorithm(TestCase): There are only a few people in each bus, ensure that each person has its best bus """ # Add a few users - for i in range(50): + for i in range(10): user = User.objects.create(username=f"user{i}") registration = WEIRegistration.objects.create( user=user, @@ -63,3 +64,44 @@ class TestWEIAlgorithm(TestCase): preferred_bus = survey.ordered_buses()[0][0] chosen_bus = survey.information.get_selected_bus() self.assertEqual(preferred_bus, chosen_bus) + + def test_survey_algorithm_full(self): + """ + Buses are full of first year people, ensure that they are happy + """ + # Add a lot of users + for i in range(95): + user = User.objects.create(username=f"user{i}") + registration = WEIRegistration.objects.create( + user=user, + wei=self.wei, + first_year=True, + birth_date='2000-01-01', + ) + information = WEISurveyInformation2021(registration) + for j in range(1, 21): + setattr(information, f'word{j}', random.choice(WORDS)) + information.step = 20 + information.save(registration) + registration.save() + + # Run algorithm + WEISurvey2021.get_algorithm_class()().run_algorithm() + + penalty = 0 + # Ensure that everyone seems to be happy + # We attribute a penalty for each user that didn't have its first choice + # The penalty is the square of the distance between the score of the preferred bus + # and the score of the attributed bus + # We consider it acceptable if the mean of this distance is lower than 5 % + for r in WEIRegistration.objects.filter(wei=self.wei).all(): + survey = WEISurvey2021(r) + chosen_bus = survey.information.get_selected_bus() + buses = survey.ordered_buses() + score = min(v for bus, v in buses if bus == chosen_bus) + max_score = buses[0][1] + penalty += (max_score - score) ** 2 + + self.assertLessEqual(max_score - score, 20) # Always less than 20 % of tolerance + + self.assertLessEqual(penalty / 100, 25) # Tolerance of 5 % From e452b7acbf6558e047901a5e04ad359684571b5e Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Thu, 2 Sep 2021 09:53:27 +0200 Subject: [PATCH 3/4] [WEI] Allow a tolerance of 25 % Signed-off-by: Yohann D'ANELLO --- apps/wei/tests/test_wei_algorithm_2021.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/wei/tests/test_wei_algorithm_2021.py b/apps/wei/tests/test_wei_algorithm_2021.py index ccac4c9d..cbc06f4e 100644 --- a/apps/wei/tests/test_wei_algorithm_2021.py +++ b/apps/wei/tests/test_wei_algorithm_2021.py @@ -102,6 +102,6 @@ class TestWEIAlgorithm(TestCase): max_score = buses[0][1] penalty += (max_score - score) ** 2 - self.assertLessEqual(max_score - score, 20) # Always less than 20 % of tolerance + self.assertLessEqual(max_score - score, 25) # Always less than 25 % of tolerance self.assertLessEqual(penalty / 100, 25) # Tolerance of 5 % From d36edfc063a82d47b96faa24811d049a4be9cfdf Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Thu, 2 Sep 2021 13:44:18 +0200 Subject: [PATCH 4/4] Linting Signed-off-by: Yohann D'ANELLO --- apps/wei/forms/surveys/wei2021.py | 8 ++++---- apps/wei/management/commands/wei_algorithm.py | 2 +- apps/wei/tests/test_wei_algorithm_2021.py | 8 +++++--- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/apps/wei/forms/surveys/wei2021.py b/apps/wei/forms/surveys/wei2021.py index 2a9d5d27..8d5adfad 100644 --- a/apps/wei/forms/surveys/wei2021.py +++ b/apps/wei/forms/surveys/wei2021.py @@ -16,7 +16,7 @@ WORDS = [ 'Fanfare', 'Fracassage', 'Féria', 'Hard rock', 'Hoeggarden', 'House', 'Huit-six', 'IPA', 'Inclusif', 'Inferno', 'Introverti', 'Jager bomb', 'Jazz', 'Jeux d\'alcool', 'Jeux de rôles', 'Jeux vidéo', 'Jul', 'Jus de fruit', 'Karaoké', 'LGBTQI+', 'Lady Gaga', 'Loup garou', 'Morning beer', 'Métal', 'Nuit blanche', 'Ovalie', 'Psychedelic', - 'Pétanque', 'Rave', 'Reggae', 'Rhum', 'Ricard', 'Rock', 'Rosé', 'Rétro', 'Séducteur', 'Techno', 'Thérapie taxi', + 'Pétanque', 'Rave', 'Reggae', 'Rhum', 'Ricard', 'Rock', 'Rosé', 'Rétro', 'Séducteur', 'Techno', 'Thérapie taxi', 'Théâtre', 'Trap', 'Turn up', 'Underground', 'Volley', 'Wati B', 'Zinédine Zidane', ] @@ -45,9 +45,9 @@ class WEISurveyForm2021(forms.Form): rng = Random(information.seed) words = [] - for _ in range(information.step + 1): + for _ignored in range(information.step + 1): # Generate N times words - words = [rng.choice(WORDS) for _ in range(10)] + words = [rng.choice(WORDS) for _ignored2 in range(10)] words = [(w, w) for w in words] if self.data: self.fields["word"].choices = [(w, w) for w in WORDS] @@ -162,7 +162,7 @@ class WEISurveyAlgorithm2021(WEISurveyAlgorithm): while free_surveys: # Some students are not affected survey = free_surveys[0] buses = survey.ordered_buses() # Preferences of the student - for bus, _ in buses: + for bus, _ignored in buses: if self.get_bus_information(bus).has_free_seats(surveys): # Selected bus has free places. Put student in the bus survey.select_bus(bus) diff --git a/apps/wei/management/commands/wei_algorithm.py b/apps/wei/management/commands/wei_algorithm.py index 152ca813..558dfae4 100644 --- a/apps/wei/management/commands/wei_algorithm.py +++ b/apps/wei/management/commands/wei_algorithm.py @@ -5,7 +5,7 @@ from argparse import ArgumentParser, FileType from django.core.management import BaseCommand from django.db import transaction -from wei.forms import CurrentSurvey +from ...forms import CurrentSurvey class Command(BaseCommand): diff --git a/apps/wei/tests/test_wei_algorithm_2021.py b/apps/wei/tests/test_wei_algorithm_2021.py index cbc06f4e..e1aab59b 100644 --- a/apps/wei/tests/test_wei_algorithm_2021.py +++ b/apps/wei/tests/test_wei_algorithm_2021.py @@ -1,11 +1,13 @@ -import math +# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + import random from django.contrib.auth.models import User from django.test import TestCase -from wei.forms.surveys.wei2021 import WEIBusInformation2021, WEISurvey2021, WORDS, WEISurveyInformation2021 -from wei.models import Bus, WEIClub, WEIRegistration +from ..forms.surveys.wei2021 import WEIBusInformation2021, WEISurvey2021, WORDS, WEISurveyInformation2021 +from ..models import Bus, WEIClub, WEIRegistration class TestWEIAlgorithm(TestCase):