11"""Unit tests for the team allocation logic using pytest and Faker."""
22
3+ import random
34from unittest import mock
45
56import pandas as pd
910from team_former .make_teams import allocate_teams
1011
1112
13+ def generate_random_preferences (
14+ student_ids , p_pos = 0.4 , p_neg = 0.3 , max_pos = 3 , max_neg = 2
15+ ):
16+ """Generate random positive and negative preferences for students."""
17+ prefs_with = {s : [] for s in student_ids }
18+ prefs_not_with = {s : [] for s in student_ids }
19+
20+ for s in student_ids :
21+ others = [o for o in student_ids if o != s ]
22+ if random .random () < p_pos and others :
23+ prefs_with [s ] = random .sample (
24+ others , k = random .randint (1 , min (max_pos , len (others )))
25+ )
26+ if random .random () < p_neg and others :
27+ prefs_not_with [s ] = random .sample (
28+ others , k = random .randint (1 , min (max_neg , len (others )))
29+ )
30+ return prefs_with , prefs_not_with
31+
32+
1233@pytest .fixture
1334def fake_student_df_fixture ():
14- """Generate a fake student dataframe with Faker ."""
35+ """Fixture to generate a fake student DataFrame with preferences ."""
1536 fake = Faker ()
1637 Faker .seed (1234 )
38+ random .seed (1234 )
39+
40+ n = 100
41+ student_ids = [f"S{ i + 1 } " for i in range (n )]
42+ pos_prefs , neg_prefs = generate_random_preferences (student_ids )
1743
1844 students = []
19- for _ in range ( 100 ) :
45+ for sid in student_ids :
2046 students .append (
2147 {
48+ "Student_ID" : sid ,
2249 "first_name" : fake .first_name (),
2350 "last_name" : fake .last_name (),
2451 "email" : fake .email (),
@@ -34,65 +61,143 @@ def fake_student_df_fixture():
3461 2 ,
3562 ),
3663 "lab" : fake .random_int (min = 1 , max = 4 ),
64+ "Prefer_With" : ", " .join (pos_prefs [sid ]) if pos_prefs [sid ] else "" ,
65+ "Prefer_Not_With" : ", " .join (neg_prefs [sid ]) if neg_prefs [sid ] else "" ,
3766 }
3867 )
39-
4068 return pd .DataFrame (students )
4169
4270
43- def test_allocate_teams_returns_df (df_in = fake_student_df_fixture ):
71+ def report_wam_balance (df_out ):
72+ """Report per-team WAM averages and optionally check balance."""
73+ team_groups = df_out .groupby ("team" )
74+ team_wams = team_groups ["wam" ].mean ()
75+ overall_mean = df_out ["wam" ].mean ()
76+
77+ print ("\n 📊 WAM balance report per team:" )
78+ for team , avg_wam in team_wams .items ():
79+ print (f" Team { team } : avg WAM = { avg_wam :.2f} " )
80+ print (f"\n 🎯 Overall mean WAM: { overall_mean :.2f} " )
81+ print (f"⚖️ Max deviation: { abs (team_wams - overall_mean ).max ():.2f} " )
82+
83+
84+ def test_allocate_teams_returns_df (request ):
4485 """Check that allocate_teams returns a valid DataFrame with a team column."""
45- with mock .patch ("pandas.read_excel" , return_value = df_in ), mock .patch (
86+ fake_df = request .getfixturevalue ("fake_student_df_fixture" )
87+ with mock .patch ("pandas.read_excel" , return_value = fake_df ), mock .patch (
4688 "pandas.DataFrame.to_excel"
4789 ):
48-
4990 df_out = allocate_teams (
5091 input_file = "fake.xlsx" ,
5192 sheet_name = 0 ,
5293 output_file = "output.xlsx" ,
5394 max_solve_time = 40 ,
5495 wam_weight = 0.05 ,
96+ pos_pref_weight = 0.8 ,
97+ neg_pref_weight = 0.8 ,
5598 min_team_size = 3 ,
5699 max_team_size = 5 ,
57100 )
58-
59101 assert isinstance (df_out , pd .DataFrame )
60102 assert "team" in df_out .columns
61- assert len (df_out ) == len (df_in )
103+ assert len (df_out ) == len (fake_df )
62104 assert df_out ["team" ].notna ().all ()
63105
64106
65- def test_teams_have_valid_sizes (df_in = fake_student_df_fixture ):
66- """Verify each team is within the size limits."""
67- with mock .patch ("pandas.read_excel" , return_value = df_in ), mock .patch (
107+ def test_teams_have_valid_sizes (request ):
108+ """Verify each team is within the size limits and report WAM balance."""
109+ fake_df = request .getfixturevalue ("fake_student_df_fixture" )
110+ with mock .patch ("pandas.read_excel" , return_value = fake_df ), mock .patch (
68111 "pandas.DataFrame.to_excel"
69112 ):
70-
71113 df_out = allocate_teams (
72114 input_file = "fake.xlsx" ,
73115 sheet_name = 0 ,
74116 output_file = "output.xlsx" ,
75117 max_solve_time = 40 ,
76118 wam_weight = 0.05 ,
119+ pos_pref_weight = 0.8 ,
120+ neg_pref_weight = 0.8 ,
77121 min_team_size = 3 ,
78122 max_team_size = 5 ,
79123 )
80-
81124 team_sizes = df_out .groupby ("team" ).size ()
82125 assert (team_sizes >= 3 ).all ()
83126 assert (team_sizes <= 5 ).all ()
127+ report_wam_balance (df_out )
84128
85129
86- def test_fake_student_df_content (df_in = fake_student_df_fixture ):
87- """Verify the fake data fixture is correct."""
88- assert len (df_in ) == 100
89- assert set (df_in .columns ) == {
130+ def test_fake_student_df_content (request ):
131+ """Verify the fake data fixture content and columns."""
132+ fake_df = request .getfixturevalue ("fake_student_df_fixture" )
133+ assert len (fake_df ) == 100
134+ expected_cols = {
135+ "Student_ID" ,
90136 "first_name" ,
91137 "last_name" ,
92138 "email" ,
93139 "gender" ,
94140 "wam" ,
95141 "lab" ,
142+ "Prefer_With" ,
143+ "Prefer_Not_With" ,
96144 }
97- assert df_in ["lab" ].between (1 , 4 ).all ()
98- assert df_in ["wam" ].between (50 , 90 ).all ()
145+ assert set (fake_df .columns ) == expected_cols
146+ assert fake_df ["lab" ].between (1 , 4 ).all ()
147+ assert fake_df ["wam" ].between (50 , 90 ).all ()
148+
149+
150+ @pytest .mark .filterwarnings ("ignore:R0914" )
151+ def test_preferences_satisfaction (request ):
152+ """Check how many preferences are satisfied in the allocation."""
153+ fake_df = request .getfixturevalue ("fake_student_df_fixture" )
154+ with mock .patch ("pandas.read_excel" , return_value = fake_df ), mock .patch (
155+ "pandas.DataFrame.to_excel"
156+ ):
157+ df_out = allocate_teams (
158+ input_file = "fake.xlsx" ,
159+ sheet_name = 0 ,
160+ output_file = "output.xlsx" ,
161+ max_solve_time = 40 ,
162+ wam_weight = 0.05 ,
163+ pos_pref_weight = 0.8 ,
164+ neg_pref_weight = 0.8 ,
165+ min_team_size = 3 ,
166+ max_team_size = 5 ,
167+ )
168+
169+ team_map = df_out .set_index ("Student_ID" )["team" ].to_dict ()
170+
171+ pos_prefs = []
172+ neg_prefs = []
173+
174+ for _ , row in fake_df .iterrows ():
175+ student = row ["Student_ID" ].strip ()
176+
177+ if pd .notna (row ["Prefer_With" ]) and row ["Prefer_With" ].strip ():
178+ preferred = [
179+ s .strip () for s in row ["Prefer_With" ].split ("," ) if s .strip ()
180+ ]
181+ for p in preferred :
182+ pos_prefs .append ((student , p ))
183+
184+ if pd .notna (row ["Prefer_Not_With" ]) and row ["Prefer_Not_With" ].strip ():
185+ not_preferred = [
186+ s .strip () for s in row ["Prefer_Not_With" ].split ("," ) if s .strip ()
187+ ]
188+ for np in not_preferred :
189+ neg_prefs .append ((student , np ))
190+
191+ pos_satisfied = sum (
192+ a in team_map and b in team_map and team_map [a ] == team_map [b ]
193+ for a , b in pos_prefs
194+ )
195+ neg_satisfied = sum (
196+ a in team_map and b in team_map and team_map [a ] != team_map [b ]
197+ for a , b in neg_prefs
198+ )
199+
200+ assert pos_satisfied > 0 , "No positive preferences satisfied"
201+ assert neg_satisfied > 0 , "No negative preferences satisfied"
202+ print (f"Positive preferences satisfied: { pos_satisfied } /{ len (pos_prefs )} " )
203+ print (f"Negative preferences satisfied: { neg_satisfied } /{ len (neg_prefs )} " )
0 commit comments