| | """ |
| | Constraint tests for the employee scheduling quickstart. |
| | |
| | Each constraint is tested with both penalizing and non-penalizing scenarios. |
| | """ |
| | from solverforge_legacy.solver.test import ConstraintVerifier |
| |
|
| | from employee_scheduling.domain import Employee, Shift, EmployeeSchedule |
| | from employee_scheduling.constraints import ( |
| | define_constraints, |
| | required_skill, |
| | no_overlapping_shifts, |
| | at_least_10_hours_between_two_shifts, |
| | one_shift_per_day, |
| | unavailable_employee, |
| | undesired_day_for_employee, |
| | desired_day_for_employee, |
| | balance_employee_shift_assignments, |
| | ) |
| |
|
| | from datetime import date, datetime, time, timedelta |
| | import pytest |
| |
|
| | |
| | DAY_1 = date(2021, 2, 1) |
| | DAY_2 = date(2021, 2, 2) |
| | DAY_3 = date(2021, 2, 3) |
| | DAY_START_TIME = datetime.combine(DAY_1, time(9, 0)) |
| | DAY_END_TIME = datetime.combine(DAY_1, time(17, 0)) |
| | AFTERNOON_START_TIME = datetime.combine(DAY_1, time(13, 0)) |
| | AFTERNOON_END_TIME = datetime.combine(DAY_1, time(21, 0)) |
| |
|
| | constraint_verifier = ConstraintVerifier.build( |
| | define_constraints, EmployeeSchedule, Shift |
| | ) |
| |
|
| |
|
| | |
| | |
| | |
| |
|
| | class TestRequiredSkill: |
| | """Tests for the required_skill constraint.""" |
| |
|
| | def test_penalized_when_employee_lacks_skill(self): |
| | """Employee without required skill should be penalized.""" |
| | employee = Employee(name="Amy") |
| | shift = Shift( |
| | id="1", |
| | start=DAY_START_TIME, |
| | end=DAY_END_TIME, |
| | location="Location", |
| | required_skill="Driving", |
| | employee=employee, |
| | ) |
| | constraint_verifier.verify_that(required_skill).given( |
| | employee, shift |
| | ).penalizes(1) |
| |
|
| | def test_not_penalized_when_employee_has_skill(self): |
| | """Employee with required skill should not be penalized.""" |
| | employee = Employee(name="Amy", skills={"Driving"}) |
| | shift = Shift( |
| | id="1", |
| | start=DAY_START_TIME, |
| | end=DAY_END_TIME, |
| | location="Location", |
| | required_skill="Driving", |
| | employee=employee, |
| | ) |
| | constraint_verifier.verify_that(required_skill).given( |
| | employee, shift |
| | ).penalizes(0) |
| |
|
| | def test_not_penalized_when_employee_has_multiple_skills(self): |
| | """Employee with multiple skills including required should not be penalized.""" |
| | employee = Employee(name="Amy", skills={"Driving", "First Aid", "Cooking"}) |
| | shift = Shift( |
| | id="1", |
| | start=DAY_START_TIME, |
| | end=DAY_END_TIME, |
| | location="Location", |
| | required_skill="First Aid", |
| | employee=employee, |
| | ) |
| | constraint_verifier.verify_that(required_skill).given( |
| | employee, shift |
| | ).penalizes(0) |
| |
|
| | def test_penalized_when_employee_has_different_skills(self): |
| | """Employee with skills but not the required one should be penalized.""" |
| | employee = Employee(name="Amy", skills={"Cooking", "Cleaning"}) |
| | shift = Shift( |
| | id="1", |
| | start=DAY_START_TIME, |
| | end=DAY_END_TIME, |
| | location="Location", |
| | required_skill="Driving", |
| | employee=employee, |
| | ) |
| | constraint_verifier.verify_that(required_skill).given( |
| | employee, shift |
| | ).penalizes(1) |
| |
|
| |
|
| | |
| | |
| | |
| |
|
| | class TestNoOverlappingShifts: |
| | """Tests for the no_overlapping_shifts constraint.""" |
| |
|
| | def test_penalized_when_shifts_fully_overlap(self): |
| | """Same employee with fully overlapping shifts should be penalized.""" |
| | employee = Employee(name="Amy") |
| | shift1 = Shift( |
| | id="1", |
| | start=DAY_START_TIME, |
| | end=DAY_END_TIME, |
| | location="Location A", |
| | required_skill="Skill", |
| | employee=employee, |
| | ) |
| | shift2 = Shift( |
| | id="2", |
| | start=DAY_START_TIME, |
| | end=DAY_END_TIME, |
| | location="Location B", |
| | required_skill="Skill", |
| | employee=employee, |
| | ) |
| | |
| | constraint_verifier.verify_that(no_overlapping_shifts).given( |
| | employee, shift1, shift2 |
| | ).penalizes_by(480) |
| |
|
| | def test_penalized_when_shifts_partially_overlap(self): |
| | """Same employee with partially overlapping shifts should be penalized by overlap duration.""" |
| | employee = Employee(name="Amy") |
| | shift1 = Shift( |
| | id="1", |
| | start=DAY_START_TIME, |
| | end=DAY_END_TIME, |
| | location="Location A", |
| | required_skill="Skill", |
| | employee=employee, |
| | ) |
| | shift2 = Shift( |
| | id="2", |
| | start=AFTERNOON_START_TIME, |
| | end=AFTERNOON_END_TIME, |
| | location="Location B", |
| | required_skill="Skill", |
| | employee=employee, |
| | ) |
| | |
| | constraint_verifier.verify_that(no_overlapping_shifts).given( |
| | employee, shift1, shift2 |
| | ).penalizes_by(240) |
| |
|
| | def test_not_penalized_when_different_employees(self): |
| | """Different employees with overlapping shifts should not be penalized.""" |
| | employee1 = Employee(name="Amy") |
| | employee2 = Employee(name="Beth") |
| | shift1 = Shift( |
| | id="1", |
| | start=DAY_START_TIME, |
| | end=DAY_END_TIME, |
| | location="Location A", |
| | required_skill="Skill", |
| | employee=employee1, |
| | ) |
| | shift2 = Shift( |
| | id="2", |
| | start=DAY_START_TIME, |
| | end=DAY_END_TIME, |
| | location="Location B", |
| | required_skill="Skill", |
| | employee=employee2, |
| | ) |
| | constraint_verifier.verify_that(no_overlapping_shifts).given( |
| | employee1, employee2, shift1, shift2 |
| | ).penalizes(0) |
| |
|
| | def test_not_penalized_when_shifts_dont_overlap(self): |
| | """Same employee with non-overlapping shifts should not be penalized.""" |
| | employee = Employee(name="Amy") |
| | shift1 = Shift( |
| | id="1", |
| | start=DAY_START_TIME, |
| | end=DAY_END_TIME, |
| | location="Location A", |
| | required_skill="Skill", |
| | employee=employee, |
| | ) |
| | shift2 = Shift( |
| | id="2", |
| | start=DAY_START_TIME + timedelta(days=1), |
| | end=DAY_END_TIME + timedelta(days=1), |
| | location="Location B", |
| | required_skill="Skill", |
| | employee=employee, |
| | ) |
| | constraint_verifier.verify_that(no_overlapping_shifts).given( |
| | employee, shift1, shift2 |
| | ).penalizes(0) |
| |
|
| |
|
| | |
| | |
| | |
| |
|
| | class TestOneShiftPerDay: |
| | """Tests for the one_shift_per_day constraint.""" |
| |
|
| | def test_penalized_when_two_shifts_same_day(self): |
| | """Employee with two shifts on same day should be penalized.""" |
| | employee = Employee(name="Amy") |
| | shift1 = Shift( |
| | id="1", |
| | start=datetime.combine(DAY_1, time(6, 0)), |
| | end=datetime.combine(DAY_1, time(10, 0)), |
| | location="Location A", |
| | required_skill="Skill", |
| | employee=employee, |
| | ) |
| | shift2 = Shift( |
| | id="2", |
| | start=datetime.combine(DAY_1, time(18, 0)), |
| | end=datetime.combine(DAY_1, time(22, 0)), |
| | location="Location B", |
| | required_skill="Skill", |
| | employee=employee, |
| | ) |
| | constraint_verifier.verify_that(one_shift_per_day).given( |
| | employee, shift1, shift2 |
| | ).penalizes(1) |
| |
|
| | def test_not_penalized_when_shifts_different_days(self): |
| | """Employee with shifts on different days should not be penalized.""" |
| | employee = Employee(name="Amy") |
| | shift1 = Shift( |
| | id="1", |
| | start=DAY_START_TIME, |
| | end=DAY_END_TIME, |
| | location="Location A", |
| | required_skill="Skill", |
| | employee=employee, |
| | ) |
| | shift2 = Shift( |
| | id="2", |
| | start=DAY_START_TIME + timedelta(days=1), |
| | end=DAY_END_TIME + timedelta(days=1), |
| | location="Location B", |
| | required_skill="Skill", |
| | employee=employee, |
| | ) |
| | constraint_verifier.verify_that(one_shift_per_day).given( |
| | employee, shift1, shift2 |
| | ).penalizes(0) |
| |
|
| | def test_not_penalized_when_different_employees_same_day(self): |
| | """Different employees with shifts on same day should not be penalized.""" |
| | employee1 = Employee(name="Amy") |
| | employee2 = Employee(name="Beth") |
| | shift1 = Shift( |
| | id="1", |
| | start=DAY_START_TIME, |
| | end=DAY_END_TIME, |
| | location="Location A", |
| | required_skill="Skill", |
| | employee=employee1, |
| | ) |
| | shift2 = Shift( |
| | id="2", |
| | start=DAY_START_TIME, |
| | end=DAY_END_TIME, |
| | location="Location B", |
| | required_skill="Skill", |
| | employee=employee2, |
| | ) |
| | constraint_verifier.verify_that(one_shift_per_day).given( |
| | employee1, employee2, shift1, shift2 |
| | ).penalizes(0) |
| |
|
| |
|
| | |
| | |
| | |
| |
|
| | class TestAtLeast10HoursBetweenShifts: |
| | """Tests for the at_least_10_hours_between_two_shifts constraint.""" |
| |
|
| | def test_penalized_when_less_than_10_hours_gap(self): |
| | """Employee with less than 10 hours between shifts should be penalized.""" |
| | employee = Employee(name="Amy") |
| | shift1 = Shift( |
| | id="1", |
| | start=DAY_START_TIME, |
| | end=DAY_END_TIME, |
| | location="Location A", |
| | required_skill="Skill", |
| | employee=employee, |
| | ) |
| | shift2 = Shift( |
| | id="2", |
| | start=AFTERNOON_END_TIME, |
| | end=DAY_START_TIME + timedelta(days=1), |
| | location="Location B", |
| | required_skill="Skill", |
| | employee=employee, |
| | ) |
| | |
| | constraint_verifier.verify_that(at_least_10_hours_between_two_shifts).given( |
| | employee, shift1, shift2 |
| | ).penalizes_by(360) |
| |
|
| | def test_penalized_when_no_gap(self): |
| | """Back-to-back shifts should be penalized by full 600 minutes.""" |
| | employee = Employee(name="Amy") |
| | shift1 = Shift( |
| | id="1", |
| | start=DAY_START_TIME, |
| | end=DAY_END_TIME, |
| | location="Location A", |
| | required_skill="Skill", |
| | employee=employee, |
| | ) |
| | shift2 = Shift( |
| | id="2", |
| | start=DAY_END_TIME, |
| | end=DAY_START_TIME + timedelta(days=1), |
| | location="Location B", |
| | required_skill="Skill", |
| | employee=employee, |
| | ) |
| | constraint_verifier.verify_that(at_least_10_hours_between_two_shifts).given( |
| | employee, shift1, shift2 |
| | ).penalizes_by(600) |
| |
|
| | def test_not_penalized_when_exactly_10_hours_gap(self): |
| | """Employee with exactly 10 hours between shifts should not be penalized.""" |
| | employee = Employee(name="Amy") |
| | shift1 = Shift( |
| | id="1", |
| | start=DAY_START_TIME, |
| | end=DAY_END_TIME, |
| | location="Location A", |
| | required_skill="Skill", |
| | employee=employee, |
| | ) |
| | shift2 = Shift( |
| | id="2", |
| | start=DAY_END_TIME + timedelta(hours=10), |
| | end=DAY_START_TIME + timedelta(days=1), |
| | location="Location B", |
| | required_skill="Skill", |
| | employee=employee, |
| | ) |
| | constraint_verifier.verify_that(at_least_10_hours_between_two_shifts).given( |
| | employee, shift1, shift2 |
| | ).penalizes(0) |
| |
|
| | def test_not_penalized_when_different_employees(self): |
| | """Different employees with back-to-back shifts should not be penalized.""" |
| | employee1 = Employee(name="Amy") |
| | employee2 = Employee(name="Beth") |
| | shift1 = Shift( |
| | id="1", |
| | start=DAY_START_TIME, |
| | end=DAY_END_TIME, |
| | location="Location A", |
| | required_skill="Skill", |
| | employee=employee1, |
| | ) |
| | shift2 = Shift( |
| | id="2", |
| | start=AFTERNOON_END_TIME, |
| | end=DAY_START_TIME + timedelta(days=1), |
| | location="Location B", |
| | required_skill="Skill", |
| | employee=employee2, |
| | ) |
| | constraint_verifier.verify_that(at_least_10_hours_between_two_shifts).given( |
| | employee1, employee2, shift1, shift2 |
| | ).penalizes(0) |
| |
|
| |
|
| | |
| | |
| | |
| |
|
| | class TestUnavailableEmployee: |
| | """Tests for the unavailable_employee constraint.""" |
| |
|
| | def test_penalized_when_shift_on_unavailable_day(self): |
| | """Employee scheduled on unavailable day should be penalized by shift duration.""" |
| | employee = Employee(name="Amy", unavailable_dates={DAY_1}) |
| | shift = Shift( |
| | id="1", |
| | start=DAY_START_TIME, |
| | end=DAY_END_TIME, |
| | location="Location", |
| | required_skill="Skill", |
| | employee=employee, |
| | ) |
| | |
| | constraint_verifier.verify_that(unavailable_employee).given( |
| | employee, shift |
| | ).penalizes_by(480) |
| |
|
| | def test_penalized_proportionally_for_multi_day_shift(self): |
| | """Multi-day shift crossing unavailable day should be penalized proportionally.""" |
| | employee = Employee(name="Amy", unavailable_dates={DAY_1}) |
| | shift = Shift( |
| | id="1", |
| | start=DAY_START_TIME - timedelta(days=1), |
| | end=DAY_END_TIME, |
| | location="Location", |
| | required_skill="Skill", |
| | employee=employee, |
| | ) |
| | |
| | constraint_verifier.verify_that(unavailable_employee).given( |
| | employee, shift |
| | ).penalizes_by(1020) |
| |
|
| | def test_not_penalized_when_shift_on_different_day(self): |
| | """Employee scheduled on available day should not be penalized.""" |
| | employee = Employee(name="Amy", unavailable_dates={DAY_1}) |
| | shift = Shift( |
| | id="1", |
| | start=DAY_START_TIME + timedelta(days=1), |
| | end=DAY_END_TIME + timedelta(days=1), |
| | location="Location", |
| | required_skill="Skill", |
| | employee=employee, |
| | ) |
| | constraint_verifier.verify_that(unavailable_employee).given( |
| | employee, shift |
| | ).penalizes(0) |
| |
|
| | def test_not_penalized_when_different_employee(self): |
| | """Different employee (without unavailable dates) should not be penalized.""" |
| | employee1 = Employee(name="Amy", unavailable_dates={DAY_1}) |
| | employee2 = Employee(name="Beth") |
| | shift = Shift( |
| | id="1", |
| | start=DAY_START_TIME, |
| | end=DAY_END_TIME, |
| | location="Location", |
| | required_skill="Skill", |
| | employee=employee2, |
| | ) |
| | constraint_verifier.verify_that(unavailable_employee).given( |
| | employee1, employee2, shift |
| | ).penalizes(0) |
| |
|
| | def test_penalized_for_multiple_unavailable_days(self): |
| | """Shift crossing multiple unavailable days should be penalized for both.""" |
| | employee = Employee(name="Amy", unavailable_dates={DAY_1, DAY_3}) |
| | shift = Shift( |
| | id="1", |
| | start=DAY_START_TIME, |
| | end=DAY_END_TIME, |
| | location="Location", |
| | required_skill="Skill", |
| | employee=employee, |
| | ) |
| | |
| | constraint_verifier.verify_that(unavailable_employee).given( |
| | employee, shift |
| | ).penalizes_by(480) |
| |
|
| |
|
| | |
| | |
| | |
| |
|
| | class TestUndesiredDayForEmployee: |
| | """Tests for the undesired_day_for_employee constraint (soft).""" |
| |
|
| | def test_penalized_when_shift_on_undesired_day(self): |
| | """Employee scheduled on undesired day should be penalized.""" |
| | employee = Employee(name="Amy", undesired_dates={DAY_1}) |
| | shift = Shift( |
| | id="1", |
| | start=DAY_START_TIME, |
| | end=DAY_END_TIME, |
| | location="Location", |
| | required_skill="Skill", |
| | employee=employee, |
| | ) |
| | constraint_verifier.verify_that(undesired_day_for_employee).given( |
| | employee, shift |
| | ).penalizes_by(480) |
| |
|
| | def test_not_penalized_when_shift_on_different_day(self): |
| | """Employee scheduled on non-undesired day should not be penalized.""" |
| | employee = Employee(name="Amy", undesired_dates={DAY_1}) |
| | shift = Shift( |
| | id="1", |
| | start=DAY_START_TIME + timedelta(days=1), |
| | end=DAY_END_TIME + timedelta(days=1), |
| | location="Location", |
| | required_skill="Skill", |
| | employee=employee, |
| | ) |
| | constraint_verifier.verify_that(undesired_day_for_employee).given( |
| | employee, shift |
| | ).penalizes(0) |
| |
|
| | def test_not_penalized_when_different_employee(self): |
| | """Different employee without undesired dates should not be penalized.""" |
| | employee1 = Employee(name="Amy", undesired_dates={DAY_1}) |
| | employee2 = Employee(name="Beth") |
| | shift = Shift( |
| | id="1", |
| | start=DAY_START_TIME, |
| | end=DAY_END_TIME, |
| | location="Location", |
| | required_skill="Skill", |
| | employee=employee2, |
| | ) |
| | constraint_verifier.verify_that(undesired_day_for_employee).given( |
| | employee1, employee2, shift |
| | ).penalizes(0) |
| |
|
| |
|
| | |
| | |
| | |
| |
|
| | class TestDesiredDayForEmployee: |
| | """Tests for the desired_day_for_employee constraint (soft reward).""" |
| |
|
| | def test_rewarded_when_shift_on_desired_day(self): |
| | """Employee scheduled on desired day should be rewarded.""" |
| | employee = Employee(name="Amy", desired_dates={DAY_1}) |
| | shift = Shift( |
| | id="1", |
| | start=DAY_START_TIME, |
| | end=DAY_END_TIME, |
| | location="Location", |
| | required_skill="Skill", |
| | employee=employee, |
| | ) |
| | constraint_verifier.verify_that(desired_day_for_employee).given( |
| | employee, shift |
| | ).rewards_with(480) |
| |
|
| | def test_not_rewarded_when_shift_on_different_day(self): |
| | """Employee scheduled on non-desired day should not be rewarded.""" |
| | employee = Employee(name="Amy", desired_dates={DAY_1}) |
| | shift = Shift( |
| | id="1", |
| | start=DAY_START_TIME + timedelta(days=1), |
| | end=DAY_END_TIME + timedelta(days=1), |
| | location="Location", |
| | required_skill="Skill", |
| | employee=employee, |
| | ) |
| | constraint_verifier.verify_that(desired_day_for_employee).given( |
| | employee, shift |
| | ).rewards(0) |
| |
|
| | def test_not_rewarded_when_different_employee(self): |
| | """Different employee without desired dates should not be rewarded.""" |
| | employee1 = Employee(name="Amy", desired_dates={DAY_1}) |
| | employee2 = Employee(name="Beth") |
| | shift = Shift( |
| | id="1", |
| | start=DAY_START_TIME, |
| | end=DAY_END_TIME, |
| | location="Location", |
| | required_skill="Skill", |
| | employee=employee2, |
| | ) |
| | constraint_verifier.verify_that(desired_day_for_employee).given( |
| | employee1, employee2, shift |
| | ).rewards(0) |
| |
|
| |
|
| | |
| | |
| | |
| |
|
| | class TestBalanceEmployeeShiftAssignments: |
| | """Tests for the balance_employee_shift_assignments constraint.""" |
| |
|
| | def test_no_penalty_when_no_shifts(self): |
| | """No shifts assigned should have zero imbalance.""" |
| | employee1 = Employee(name="Amy") |
| | employee2 = Employee(name="Beth") |
| | constraint_verifier.verify_that(balance_employee_shift_assignments).given( |
| | employee1, employee2 |
| | ).penalizes_by(0) |
| |
|
| | def test_penalized_when_unbalanced(self): |
| | """Only one employee with shifts should be penalized (imbalanced).""" |
| | employee1 = Employee(name="Amy") |
| | employee2 = Employee(name="Beth") |
| | shift = Shift( |
| | id="1", |
| | start=DAY_START_TIME, |
| | end=DAY_END_TIME, |
| | location="Location", |
| | required_skill="Skill", |
| | employee=employee1, |
| | ) |
| | constraint_verifier.verify_that(balance_employee_shift_assignments).given( |
| | employee1, employee2, shift |
| | ).penalizes_by_more_than(0) |
| |
|
| | def test_no_penalty_when_balanced(self): |
| | """Equal shifts per employee should have zero imbalance.""" |
| | employee1 = Employee(name="Amy") |
| | employee2 = Employee(name="Beth") |
| | shift1 = Shift( |
| | id="1", |
| | start=DAY_START_TIME, |
| | end=DAY_END_TIME, |
| | location="Location", |
| | required_skill="Skill", |
| | employee=employee1, |
| | ) |
| | shift2 = Shift( |
| | id="2", |
| | start=DAY_START_TIME, |
| | end=DAY_END_TIME, |
| | location="Location", |
| | required_skill="Skill", |
| | employee=employee2, |
| | ) |
| | constraint_verifier.verify_that(balance_employee_shift_assignments).given( |
| | employee1, employee2, shift1, shift2 |
| | ).penalizes_by(0) |
| |
|