blackopsrepl/meeting-scheduling-python
1
1from solverforge_legacy.solver.test import ConstraintVerifier2 3from meeting_scheduling.domain import (4 Meeting,5 MeetingAssignment,6 MeetingSchedule,7 Person,8 PreferredAttendance,9 RequiredAttendance,10 Room,11 TimeGrain,12)13from meeting_scheduling.constraints import (14 define_constraints,15 room_conflict,16 avoid_overtime,17 required_attendance_conflict,18 required_room_capacity,19 start_and_end_on_same_day,20 required_and_preferred_attendance_conflict,21 preferred_attendance_conflict,22 room_stability,23)24 25 26DEFAULT_TIME_GRAINS = [27 TimeGrain(28 id=str(i + 1), grain_index=i, day_of_year=1, starting_minute_of_day=480 + i * 1529 )30 for i in range(8)31]32 33DEFAULT_ROOM = Room(id="1", name="Room 1", capacity=10)34SMALL_ROOM = Room(id="2", name="Small Room", capacity=1)35LARGE_ROOM = Room(id="3", name="Large Room", capacity=2)36ROOM_A = Room(id="4", name="Room A", capacity=10)37ROOM_B = Room(id="5", name="Room B", capacity=10)38 39 40constraint_verifier = ConstraintVerifier.build(41 define_constraints, MeetingSchedule, MeetingAssignment42)43 44 45def test_room_conflict_unpenalized():46 """Test that no penalty is applied when meetings in the same room do not overlap."""47 meeting1 = create_meeting(1)48 left_assignment = create_meeting_assignment(49 0, meeting1, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM50 )51 52 meeting2 = create_meeting(2)53 right_assignment = create_meeting_assignment(54 1, meeting2, DEFAULT_TIME_GRAINS[4], DEFAULT_ROOM55 )56 57 constraint_verifier.verify_that(room_conflict).given(58 left_assignment, right_assignment59 ).penalizes(0)60 61 62def test_room_conflict_penalized():63 """Test that a penalty is applied when meetings in the same room overlap."""64 meeting1 = create_meeting(1)65 left_assignment = create_meeting_assignment(66 0, meeting1, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM67 )68 69 meeting2 = create_meeting(2)70 right_assignment = create_meeting_assignment(71 1, meeting2, DEFAULT_TIME_GRAINS[2], DEFAULT_ROOM72 )73 74 constraint_verifier.verify_that(room_conflict).given(75 left_assignment, right_assignment76 ).penalizes_by(2)77 78 79def test_avoid_overtime_unpenalized():80 """Test that no penalty is applied when a meeting fits within available time grains (no overtime)."""81 meeting = create_meeting(1)82 meeting_assignment = create_meeting_assignment(83 0, meeting, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM84 )85 86 constraint_verifier.verify_that(avoid_overtime).given(87 meeting_assignment, *DEFAULT_TIME_GRAINS88 ).penalizes(0)89 90 91def test_avoid_overtime_penalized():92 """Test that a penalty is applied when a meeting exceeds available time grains (overtime)."""93 meeting = create_meeting(1)94 meeting_assignment = create_meeting_assignment(95 0, meeting, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM96 )97 98 constraint_verifier.verify_that(avoid_overtime).given(99 meeting_assignment100 ).penalizes_by(3)101 102 103def test_required_attendance_conflict_unpenalized():104 """Test that no penalty is applied when a person does not have overlapping required meetings."""105 person = create_person(1)106 107 left_meeting = create_meeting(1, duration=2)108 required_attendance1 = create_required_attendance(0, person, left_meeting)109 110 right_meeting = create_meeting(2, duration=2)111 required_attendance2 = create_required_attendance(1, person, right_meeting)112 113 left_assignment = create_meeting_assignment(114 0, left_meeting, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM115 )116 right_assignment = create_meeting_assignment(117 1, right_meeting, DEFAULT_TIME_GRAINS[2], DEFAULT_ROOM118 )119 120 constraint_verifier.verify_that(required_attendance_conflict).given(121 required_attendance1, required_attendance2, left_assignment, right_assignment122 ).penalizes(0)123 124 125def test_required_attendance_conflict_penalized():126 """Test that a penalty is applied when a person has overlapping required meetings."""127 person = create_person(1)128 129 left_meeting = create_meeting(1, duration=2)130 required_attendance1 = create_required_attendance(0, person, left_meeting)131 132 right_meeting = create_meeting(2, duration=2)133 required_attendance2 = create_required_attendance(1, person, right_meeting)134 135 left_assignment = create_meeting_assignment(136 0, left_meeting, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM137 )138 right_assignment = create_meeting_assignment(139 1, right_meeting, DEFAULT_TIME_GRAINS[1], DEFAULT_ROOM140 )141 142 constraint_verifier.verify_that(required_attendance_conflict).given(143 required_attendance1, required_attendance2, left_assignment, right_assignment144 ).penalizes_by(1)145 146 147def test_required_room_capacity_unpenalized():148 """Test that no penalty is applied when the room has enough capacity for all required and preferred attendees."""149 person1 = create_person(1)150 person2 = create_person(2)151 152 meeting = create_meeting(1, duration=2)153 create_required_attendance(0, person1, meeting)154 create_preferred_attendance(1, person2, meeting)155 156 meeting_assignment = create_meeting_assignment(157 0, meeting, DEFAULT_TIME_GRAINS[0], LARGE_ROOM158 )159 160 constraint_verifier.verify_that(required_room_capacity).given(161 meeting_assignment162 ).penalizes(0)163 164 165def test_required_room_capacity_penalized():166 """Test that a penalty is applied when the room does not have enough capacity for all required and preferred attendees."""167 person1 = create_person(1)168 person2 = create_person(2)169 170 meeting = create_meeting(1, duration=2)171 create_required_attendance(0, person1, meeting)172 create_preferred_attendance(1, person2, meeting)173 174 meeting_assignment = create_meeting_assignment(175 0, meeting, DEFAULT_TIME_GRAINS[0], SMALL_ROOM176 )177 178 constraint_verifier.verify_that(required_room_capacity).given(179 meeting_assignment180 ).penalizes_by(1)181 182 183def test_start_and_end_on_same_day_unpenalized():184 """Test that no penalty is applied when a meeting starts and ends on the same day."""185 # Need custom time grains with day_of_year=0 (DEFAULT_TIME_GRAINS use day_of_year=1)186 start_time_grain = TimeGrain(187 id="1", grain_index=0, day_of_year=0, starting_minute_of_day=480188 )189 end_time_grain = TimeGrain(190 id="2", grain_index=3, day_of_year=0, starting_minute_of_day=525191 ) # Same day192 193 meeting = create_meeting(1)194 meeting_assignment = create_meeting_assignment(195 0, meeting, start_time_grain, DEFAULT_ROOM196 )197 198 constraint_verifier.verify_that(start_and_end_on_same_day).given(199 meeting_assignment, end_time_grain200 ).penalizes(0)201 202 203def test_start_and_end_on_same_day_penalized():204 """Test that a penalty is applied when a meeting starts and ends on different days."""205 # Need custom time grains to test different days (start=day 0, end=day 1)206 start_time_grain = TimeGrain(207 id="1", grain_index=0, day_of_year=0, starting_minute_of_day=480208 )209 end_time_grain = TimeGrain(210 id="2", grain_index=3, day_of_year=1, starting_minute_of_day=525211 ) # Different day212 213 meeting = create_meeting(1)214 meeting_assignment = create_meeting_assignment(215 0, meeting, start_time_grain, DEFAULT_ROOM216 )217 218 constraint_verifier.verify_that(start_and_end_on_same_day).given(219 meeting_assignment, end_time_grain220 ).penalizes_by(1)221 222 223def test_multiple_constraint_violations():224 """Test that multiple constraints can be violated simultaneously."""225 person = create_person(1)226 227 left_meeting = create_meeting(1)228 required_attendance1 = create_required_attendance(0, person, left_meeting)229 left_assignment = create_meeting_assignment(230 0, left_meeting, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM231 )232 233 right_meeting = create_meeting(2)234 required_attendance2 = create_required_attendance(1, person, right_meeting)235 right_assignment = create_meeting_assignment(236 1, right_meeting, DEFAULT_TIME_GRAINS[2], DEFAULT_ROOM237 )238 239 constraint_verifier.verify_that(room_conflict).given(240 left_assignment, right_assignment241 ).penalizes_by(2)242 constraint_verifier.verify_that(required_attendance_conflict).given(243 required_attendance1, required_attendance2, left_assignment, right_assignment244 ).penalizes_by(2)245 246 247### Helper functions ###248 249 250def create_meeting(id, topic="Meeting", duration=4):251 """Helper to create a meeting with standard parameters."""252 return Meeting(id=str(id), topic=f"{topic} {id}", duration_in_grains=duration)253 254 255def create_meeting_assignment(id, meeting, time_grain, room):256 """Helper to create a meeting assignment."""257 return MeetingAssignment(258 id=str(id), meeting=meeting, starting_time_grain=time_grain, room=room259 )260 261 262def create_person(id):263 """Helper to create a person."""264 return Person(id=str(id), full_name=f"Person {id}")265 266 267def create_required_attendance(id, person, meeting):268 """Helper to create and link required attendance."""269 attendance = RequiredAttendance(id=str(id), person=person, meeting_id=meeting.id)270 meeting.required_attendances = [attendance]271 return attendance272 273 274def create_preferred_attendance(id, person, meeting):275 """Helper to create and link preferred attendance."""276 attendance = PreferredAttendance(id=str(id), person=person, meeting_id=meeting.id)277 meeting.preferred_attendances = [attendance]278 return attendance279 280 281# ========================================282# Required and Preferred Attendance Conflict Tests283# ========================================284 285 286def test_required_and_preferred_attendance_conflict_unpenalized():287 """Test no penalty when required and preferred meetings don't overlap."""288 person = create_person(1)289 290 # Meeting 1: grain 0-3 (duration=4), person required291 meeting1 = create_meeting(1, duration=4)292 attendance1 = create_required_attendance(0, person, meeting1)293 assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM)294 295 # Meeting 2: grain 4-7 (duration=4), person preferred296 meeting2 = create_meeting(2, duration=4)297 attendance2 = create_preferred_attendance(1, person, meeting2)298 assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[4], ROOM_A)299 300 constraint_verifier.verify_that(required_and_preferred_attendance_conflict).given(301 attendance1, attendance2, assignment1, assignment2302 ).penalizes_by(0)303 304 305def test_required_and_preferred_attendance_conflict_penalized():306 """Test penalty when person required at one meeting and preferred at overlapping meeting."""307 person = create_person(1)308 309 # Meeting 1: grain 0-3 (duration=4), person required310 meeting1 = create_meeting(1, duration=4)311 attendance1 = create_required_attendance(0, person, meeting1)312 assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM)313 314 # Meeting 2: grain 2-5 (duration=4), person preferred, overlaps grains 2-3 (2 grains)315 meeting2 = create_meeting(2, duration=4)316 attendance2 = create_preferred_attendance(1, person, meeting2)317 assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[2], ROOM_A)318 319 # Overlap: grains 2-3 = 2 grains320 constraint_verifier.verify_that(required_and_preferred_attendance_conflict).given(321 attendance1, attendance2, assignment1, assignment2322 ).penalizes_by(2)323 324 325# ========================================326# Preferred Attendance Conflict Tests327# ========================================328 329 330def test_preferred_attendance_conflict_unpenalized():331 """Test no penalty when preferred attendee has non-overlapping meetings."""332 person = create_person(1)333 334 # Meeting 1: grain 0-3 (duration=4), person preferred335 meeting1 = create_meeting(1, duration=4)336 attendance1 = create_preferred_attendance(0, person, meeting1)337 assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM)338 339 # Meeting 2: grain 4-7 (duration=4), person preferred340 meeting2 = create_meeting(2, duration=4)341 attendance2 = create_preferred_attendance(1, person, meeting2)342 assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[4], ROOM_A)343 344 constraint_verifier.verify_that(preferred_attendance_conflict).given(345 attendance1, attendance2, assignment1, assignment2346 ).penalizes_by(0)347 348 349def test_preferred_attendance_conflict_penalized():350 """Test penalty when person preferred at multiple overlapping meetings."""351 person = create_person(1)352 353 # Meeting 1: grain 0-3 (duration=4), person preferred354 meeting1 = create_meeting(1, duration=4)355 attendance1 = create_preferred_attendance(0, person, meeting1)356 assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM)357 358 # Meeting 2: grain 1-4 (duration=4), person preferred, overlaps grains 1-3 (3 grains)359 meeting2 = create_meeting(2, duration=4)360 attendance2 = create_preferred_attendance(1, person, meeting2)361 assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[1], ROOM_A)362 363 # Overlap: grains 1-3 = 3 grains364 constraint_verifier.verify_that(preferred_attendance_conflict).given(365 attendance1, attendance2, assignment1, assignment2366 ).penalizes_by(3)367 368 369# ========================================370# Room Stability Tests371# ========================================372 373 374def test_room_stability_same_room_no_penalty():375 """376 Test that no penalty is applied when a person attends consecutive377 meetings in the same room (stability is maintained).378 """379 person = create_person(1)380 381 # Meeting 1: time grain 0-1 (duration=2) in ROOM_A382 meeting1 = create_meeting(1, duration=2)383 attendance1 = create_required_attendance(0, person, meeting1)384 assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[0], ROOM_A)385 386 # Meeting 2: time grain 3-4 (duration=2) in ROOM_A (same room, gap of 1)387 meeting2 = create_meeting(2, duration=2)388 attendance2 = create_required_attendance(1, person, meeting2)389 assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[3], ROOM_A)390 391 # Same room should not penalize392 constraint_verifier.verify_that(room_stability).given(393 attendance1, attendance2, assignment1, assignment2394 ).penalizes(0)395 396 397def test_room_stability_different_room_with_required_attendance():398 """399 Test that a penalty is applied when a person with required attendance400 has to change rooms between closely scheduled meetings.401 Weighted penalty: back-to-back switches cost more than switches with gaps.402 """403 person = create_person(1)404 405 # Meeting 1: time grain 0-1 (duration=2) in ROOM_A406 left_grain_index = 0407 left_duration = 2408 meeting1 = create_meeting(1, duration=left_duration)409 attendance1 = create_required_attendance(0, person, meeting1)410 assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[left_grain_index], ROOM_A)411 412 # Meeting 2: time grain 3-4 (duration=2) in ROOM_B (different room)413 right_grain_index = 3414 meeting2 = create_meeting(2, duration=2)415 attendance2 = create_required_attendance(1, person, meeting2)416 assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[right_grain_index], ROOM_B)417 418 # Weighted penalty: 3 - gap, where gap = right_grain - left_duration - left_grain419 gap = right_grain_index - left_duration - left_grain_index420 expected_penalty = 3 - gap421 422 constraint_verifier.verify_that(room_stability).given(423 attendance1, attendance2, assignment1, assignment2424 ).penalizes_by(expected_penalty)425 426 427def test_room_stability_different_room_with_preferred_attendance():428 """429 Test that a penalty is applied when a person with preferred attendance430 has to change rooms between closely scheduled meetings.431 Weighted penalty applies to preferred attendance too.432 """433 person = create_person(1)434 435 # Meeting 1: time grain 0-1 (duration=2) in ROOM_A436 left_grain_index = 0437 left_duration = 2438 meeting1 = create_meeting(1, duration=left_duration)439 attendance1 = create_preferred_attendance(0, person, meeting1)440 assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[left_grain_index], ROOM_A)441 442 # Meeting 2: time grain 3-4 (duration=2) in ROOM_B (different room)443 right_grain_index = 3444 meeting2 = create_meeting(2, duration=2)445 attendance2 = create_preferred_attendance(1, person, meeting2)446 assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[right_grain_index], ROOM_B)447 448 # Weighted penalty: 3 - gap449 gap = right_grain_index - left_duration - left_grain_index450 expected_penalty = 3 - gap451 452 constraint_verifier.verify_that(room_stability).given(453 attendance1, attendance2, assignment1, assignment2454 ).penalizes_by(expected_penalty)455 456 457def test_room_stability_mixed_attendance_types():458 """459 Test that room stability penalty applies when mixing required and preferred460 attendance types for the same person.461 """462 person = create_person(1)463 464 # Meeting 1 with required attendance465 left_grain_index = 0466 left_duration = 2467 meeting1 = create_meeting(1, duration=left_duration)468 required_attendance = create_required_attendance(0, person, meeting1)469 assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[left_grain_index], ROOM_A)470 471 # Meeting 2 with preferred attendance472 right_grain_index = 3473 meeting2 = create_meeting(2, duration=2)474 preferred_attendance = create_preferred_attendance(1, person, meeting2)475 assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[right_grain_index], ROOM_B)476 477 # Weighted penalty: 3 - gap478 gap = right_grain_index - left_duration - left_grain_index479 expected_penalty = 3 - gap480 481 constraint_verifier.verify_that(room_stability).given(482 required_attendance, preferred_attendance, assignment1, assignment2483 ).penalizes_by(expected_penalty)484 485 486def test_room_stability_far_apart_meetings_no_penalty():487 """488 Test that no penalty is applied when meetings are far apart in time,489 even if they're in different rooms.490 """491 person = create_person(1)492 493 # Meeting 1: time grain 0-1 (duration=2) in ROOM_A494 meeting1 = create_meeting(1, duration=2)495 attendance1 = create_required_attendance(0, person, meeting1)496 assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[0], ROOM_A)497 498 # Meeting 2: time grain 6-7 (duration=2) in ROOM_B499 # gap = grain_index(6) - duration_in_grains(2) - grain_index(0) = 6 - 2 - 0 = 4 > 2500 meeting2 = create_meeting(2, duration=2)501 attendance2 = create_required_attendance(1, person, meeting2)502 assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[6], ROOM_B)503 504 # Far apart meetings should not penalize even with room change505 constraint_verifier.verify_that(room_stability).given(506 attendance1, attendance2, assignment1, assignment2507 ).penalizes(0)508 509 510def test_room_stability_different_people_no_penalty():511 """512 Test that no penalty is applied when different people have meetings513 in different rooms (room stability is per-person).514 """515 person1 = create_person(1)516 person2 = create_person(2)517 518 # Person 1's meeting in ROOM_A519 meeting1 = create_meeting(1, duration=2)520 attendance1 = create_required_attendance(0, person1, meeting1)521 assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[0], ROOM_A)522 523 # Person 2's meeting in ROOM_B (different person, should not affect stability)524 meeting2 = create_meeting(2, duration=2)525 attendance2 = create_required_attendance(1, person2, meeting2)526 assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[3], ROOM_B)527 528 # Different people should not trigger room stability penalty529 constraint_verifier.verify_that(room_stability).given(530 attendance1, attendance2, assignment1, assignment2531 ).penalizes(0)532 533 534def test_room_stability_back_to_back_highest_penalty():535 """536 Test that back-to-back room switches (gap=0) incur the highest penalty (3).537 This verifies the weighted penalty gradient: closer switches cost more.538 """539 person = create_person(1)540 541 # Meeting 1: grain 0-1 (duration=2) in ROOM_A542 left_grain_index = 0543 left_duration = 2544 meeting1 = create_meeting(1, duration=left_duration)545 attendance1 = create_required_attendance(0, person, meeting1)546 assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[left_grain_index], ROOM_A)547 548 # Meeting 2: grain 2-3 (immediately after) in ROOM_B549 right_grain_index = 2 # Starts right after meeting1 ends550 meeting2 = create_meeting(2, duration=2)551 attendance2 = create_required_attendance(1, person, meeting2)552 assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[right_grain_index], ROOM_B)553 554 # gap = 2 - 2 - 0 = 0, penalty = 3 - 0 = 3555 gap = right_grain_index - left_duration - left_grain_index556 expected_penalty = 3 - gap557 assert gap == 0, f"Test setup error: expected gap=0, got {gap}"558 assert expected_penalty == 3, f"Test setup error: expected penalty=3, got {expected_penalty}"559 560 constraint_verifier.verify_that(room_stability).given(561 attendance1, attendance2, assignment1, assignment2562 ).penalizes_by(expected_penalty)563 