Spaces:
Paused
Paused
import pytest | |
import sys | |
from datetime import date, timedelta | |
from decimal import Decimal | |
from timefold.solver.test import ConstraintVerifier | |
from timefold.solver.score import HardSoftDecimalScore | |
# Import standardized test utilities | |
from tests.test_utils import get_test_logger, create_test_results | |
# Initialize standardized test logger | |
logger = get_test_logger(__name__) | |
from src.constraint_solvers.timetable.constraints import ( | |
define_constraints, | |
required_skill, | |
no_overlapping_tasks, | |
task_within_schedule, | |
task_fits_in_schedule, | |
unavailable_employee, | |
maintain_project_task_order, | |
undesired_day_for_employee, | |
desired_day_for_employee, | |
balance_employee_task_assignments, | |
no_lunch_break_spanning, | |
no_weekend_scheduling, | |
) | |
from src.constraint_solvers.timetable.working_hours import task_spans_lunch_break | |
from src.constraint_solvers.timetable.domain import ( | |
Employee, | |
Task, | |
EmployeeSchedule, | |
ScheduleInfo, | |
) | |
class TestConstraints: | |
""" | |
Comprehensive test suite for all timetable constraints using ConstraintVerifier. | |
Each constraint is tested in isolation to verify correct behavior. | |
""" | |
def setup_method(self): | |
"""Set up common test data and ConstraintVerifier instance.""" | |
logger.debug("Setting up test constraints and data...") | |
self.constraint_verifier = ConstraintVerifier.build( | |
define_constraints, EmployeeSchedule, Task | |
) | |
# Create common test data using generator functions | |
self.dates = create_test_dates() | |
self.employees = create_standard_employees(self.dates) | |
self.schedule_info = create_schedule_info() | |
# Create shortcuts for commonly used employees | |
self.employee_alice = self.employees["alice"] | |
self.employee_bob = self.employees["bob"] | |
self.employee_charlie = self.employees["charlie"] | |
logger.debug(f"Created {len(self.employees)} test employees and schedule info") | |
# ==================== HARD CONSTRAINT TESTS ==================== | |
def test_required_skill_constraint_violation(self): | |
"""Test that tasks requiring skills not possessed by assigned employee are penalized.""" | |
logger.debug("Testing required skill constraint violation...") | |
task = create_task( | |
task_id="task1", | |
description="Python Development", | |
required_skill="Python", | |
employee=self.employee_bob, # Bob doesn't have Python skill | |
) | |
( | |
self.constraint_verifier.verify_that(required_skill) | |
.given(task, self.employee_bob, self.schedule_info) | |
.penalizes_by(1) | |
) | |
logger.debug("β Required skill constraint violation test passed") | |
def test_required_skill_constraint_satisfied(self): | |
"""Test that tasks assigned to employees with required skills are not penalized.""" | |
logger.debug("Testing required skill constraint satisfaction...") | |
task = create_task( | |
task_id="task1", | |
description="Python Development", | |
required_skill="Python", | |
employee=self.employee_alice, # Alice has Python skill | |
) | |
( | |
self.constraint_verifier.verify_that(required_skill) | |
.given(task, self.employee_alice, self.schedule_info) | |
.penalizes_by(0) | |
) | |
logger.debug("β Required skill constraint satisfaction test passed") | |
def test_required_skill_constraint_unassigned_task(self): | |
"""Test that unassigned tasks don't trigger required skill constraint.""" | |
logger.debug("Testing required skill constraint with unassigned task...") | |
task = create_task( | |
task_id="task1", | |
description="Python Development", | |
required_skill="Python", | |
employee=None, # Unassigned | |
) | |
( | |
self.constraint_verifier.verify_that(required_skill) | |
.given(task, self.schedule_info) | |
.penalizes_by(0) | |
) | |
logger.debug("β Required skill constraint unassigned task test passed") | |
def test_no_overlapping_tasks_constraint_violation(self): | |
"""Test that overlapping tasks for the same employee are penalized.""" | |
logger.debug("Testing no overlapping tasks constraint violation...") | |
task1 = create_task( | |
task_id="task1", | |
description="Task 1", | |
duration_slots=4, # slots 0-3 | |
start_slot=0, | |
required_skill="Python", | |
employee=self.employee_alice, | |
) | |
task2 = create_task( | |
task_id="task2", | |
description="Task 2", | |
duration_slots=3, # slots 2-4 (overlaps with task1 by 2 slots) | |
start_slot=2, | |
required_skill="Java", | |
employee=self.employee_alice, # Same employee | |
) | |
# Verify constraint violation (overlap of 2 slots: 2 and 3) | |
( | |
self.constraint_verifier.verify_that(no_overlapping_tasks) | |
.given(task1, task2, self.employee_alice, self.schedule_info) | |
.penalizes_by(2) | |
) | |
logger.debug("β No overlapping tasks constraint violation test passed") | |
def test_no_overlapping_tasks_constraint_different_employees(self): | |
"""Test that overlapping tasks for different employees are not penalized.""" | |
logger.debug("Testing no overlapping tasks with different employees...") | |
task1 = create_task( | |
task_id="task1", | |
description="Task 1", | |
duration_slots=4, | |
start_slot=0, | |
required_skill="Python", | |
employee=self.employee_alice, | |
) | |
task2 = create_task( | |
task_id="task2", | |
description="Task 2", | |
duration_slots=3, | |
start_slot=2, # Overlaps in time but different employee | |
required_skill="Java", | |
employee=self.employee_bob, # Different employee | |
) | |
( | |
self.constraint_verifier.verify_that(no_overlapping_tasks) | |
.given( | |
task1, task2, self.employee_alice, self.employee_bob, self.schedule_info | |
) | |
.penalizes_by(0) | |
) | |
logger.debug("β No overlapping tasks different employees test passed") | |
def test_no_overlapping_tasks_constraint_adjacent_tasks(self): | |
"""Test that adjacent (non-overlapping) tasks for the same employee are not penalized.""" | |
logger.debug("Testing no overlapping tasks with adjacent tasks...") | |
task1 = create_task( | |
task_id="task1", | |
description="Task 1", | |
duration_slots=4, # slots 0-3 | |
start_slot=0, | |
required_skill="Python", | |
employee=self.employee_alice, | |
) | |
task2 = create_task( | |
task_id="task2", | |
description="Task 2", | |
duration_slots=3, # slots 4-6 (no overlap) | |
start_slot=4, | |
required_skill="Java", | |
employee=self.employee_alice, # Same employee | |
) | |
( | |
self.constraint_verifier.verify_that(no_overlapping_tasks) | |
.given(task1, task2, self.employee_alice, self.schedule_info) | |
.penalizes_by(0) | |
) | |
logger.debug("β No overlapping tasks adjacent tasks test passed") | |
def test_task_within_schedule_constraint_violation(self): | |
"""Test that tasks starting before slot 0 are penalized.""" | |
logger.debug("Testing task within schedule constraint violation...") | |
task = create_task( | |
task_id="task1", | |
description="Invalid Task", | |
start_slot=-1, # Invalid start slot | |
required_skill="Python", | |
employee=self.employee_alice, | |
) | |
( | |
self.constraint_verifier.verify_that(task_within_schedule) | |
.given(task, self.employee_alice, self.schedule_info) | |
.penalizes_by(1) | |
) | |
logger.debug("β Task within schedule constraint violation test passed") | |
def test_task_within_schedule_constraint_satisfied(self): | |
"""Test that tasks starting at valid slots are not penalized.""" | |
logger.debug("Testing task within schedule constraint satisfaction...") | |
task = create_task( | |
task_id="task1", | |
description="Valid Task", | |
start_slot=0, # Valid start slot | |
required_skill="Python", | |
employee=self.employee_alice, | |
) | |
( | |
self.constraint_verifier.verify_that(task_within_schedule) | |
.given(task, self.employee_alice, self.schedule_info) | |
.penalizes_by(0) | |
) | |
logger.debug("β Task within schedule constraint satisfaction test passed") | |
def test_task_fits_in_schedule_constraint_violation(self): | |
"""Test that tasks extending beyond schedule end are penalized.""" | |
task = create_task( | |
task_id="task1", | |
description="Overlong Task", | |
duration_slots=10, # Task extends to slot 65 (beyond 59) | |
start_slot=56, # Start at slot 56, end at slot 65 (beyond schedule) | |
required_skill="Python", | |
employee=self.employee_alice, | |
) | |
( | |
self.constraint_verifier.verify_that(task_fits_in_schedule) | |
.given(task, self.employee_alice, self.schedule_info) | |
.penalizes_by(1) | |
) | |
def test_task_fits_in_schedule_constraint_satisfied(self): | |
"""Test that tasks fitting within schedule are not penalized.""" | |
task = create_task( | |
task_id="task1", | |
description="Valid Task", | |
start_slot=0, | |
required_skill="Python", | |
employee=self.employee_alice, | |
) | |
( | |
self.constraint_verifier.verify_that(task_fits_in_schedule) | |
.given(task, self.employee_alice, self.schedule_info) | |
.penalizes_by(0) | |
) | |
def test_unavailable_employee_constraint_violation(self): | |
"""Test that tasks assigned to unavailable employees are penalized.""" | |
# With 20 slots per working day, tomorrow starts at slot 20 | |
task = create_task( | |
task_id="task1", | |
description="Task on unavailable day", | |
start_slot=20, # Tomorrow (when Alice is unavailable) | |
required_skill="Python", | |
employee=self.employee_alice, | |
) | |
( | |
self.constraint_verifier.verify_that(unavailable_employee) | |
.given(task, self.employee_alice, self.schedule_info) | |
.penalizes_by(1) | |
) | |
def test_unavailable_employee_constraint_satisfied(self): | |
"""Test that tasks not on unavailable days are not penalized.""" | |
task = create_task( | |
task_id="task1", | |
description="Task on available day", | |
start_slot=0, # Today (Alice is available) | |
required_skill="Python", | |
employee=self.employee_alice, | |
) | |
( | |
self.constraint_verifier.verify_that(unavailable_employee) | |
.given(task, self.employee_alice, self.schedule_info) | |
.penalizes_by(0) | |
) | |
def test_maintain_project_task_order_constraint_violation(self): | |
"""Test that tasks violating project sequence order are penalized.""" | |
task1 = create_task( | |
task_id="task1", | |
description="Second Task", | |
start_slot=0, # Starts first but should come second | |
required_skill="Python", | |
project_id="project1", | |
sequence_number=2, | |
employee=self.employee_alice, | |
) | |
task2 = create_task( | |
task_id="task2", | |
description="First Task", | |
start_slot=2, # Starts during task1 but should come first | |
required_skill="Java", | |
project_id="project1", | |
sequence_number=1, | |
employee=self.employee_bob, | |
) | |
( | |
self.constraint_verifier.verify_that(maintain_project_task_order) | |
.given( | |
task1, task2, self.employee_alice, self.employee_bob, self.schedule_info | |
) | |
.penalizes_by(6) | |
) | |
def test_maintain_project_task_order_constraint_satisfied(self): | |
"""Test that tasks maintaining correct project sequence are not penalized.""" | |
task1 = create_task( | |
task_id="task1", | |
description="First Task", | |
start_slot=0, # Comes first and finishes before task2 | |
required_skill="Python", | |
project_id="project1", | |
sequence_number=1, | |
employee=self.employee_alice, | |
) | |
task2 = create_task( | |
task_id="task2", | |
description="Second Task", | |
start_slot=5, # Starts after task1 finishes | |
required_skill="Java", | |
project_id="project1", | |
sequence_number=2, | |
employee=self.employee_bob, | |
) | |
( | |
self.constraint_verifier.verify_that(maintain_project_task_order) | |
.given( | |
task1, task2, self.employee_alice, self.employee_bob, self.schedule_info | |
) | |
.penalizes_by(0) | |
) | |
def test_maintain_project_task_order_different_projects(self): | |
"""Test that tasks in different projects don't affect each other's sequence.""" | |
task1 = create_task( | |
task_id="task1", | |
description="Task in Project A", | |
start_slot=0, | |
required_skill="Python", | |
project_id="projectA", | |
sequence_number=2, | |
employee=self.employee_alice, | |
) | |
task2 = create_task( | |
task_id="task2", | |
description="Task in Project B", | |
start_slot=2, # Overlaps but different project | |
required_skill="Java", | |
project_id="projectB", | |
sequence_number=1, | |
employee=self.employee_bob, | |
) | |
( | |
self.constraint_verifier.verify_that(maintain_project_task_order) | |
.given( | |
task1, task2, self.employee_alice, self.employee_bob, self.schedule_info | |
) | |
.penalizes_by(0) | |
) | |
def test_no_lunch_break_spanning_constraint_violation(self): | |
"""Test that tasks spanning lunch break are penalized.""" | |
task = create_task( | |
task_id="task1", | |
description="Task spanning lunch", | |
start_slot=7, # Starts at 12:30 (slot 7), spans lunch break | |
duration_slots=4, # 2 hours, ends at 14:30 (slot 11) | |
required_skill="Python", | |
employee=self.employee_alice, | |
) | |
( | |
self.constraint_verifier.verify_that(no_lunch_break_spanning) | |
.given(task, self.employee_alice, self.schedule_info) | |
.penalizes_by(1) | |
) | |
def test_no_lunch_break_spanning_constraint_satisfied(self): | |
"""Test that tasks not spanning lunch break are not penalized.""" | |
task = create_task( | |
task_id="task1", | |
description="Task before lunch", | |
start_slot=0, # Starts at 9:00 | |
duration_slots=4, # 2 hours, ends at 11:00 | |
required_skill="Python", | |
employee=self.employee_alice, | |
) | |
( | |
self.constraint_verifier.verify_that(no_lunch_break_spanning) | |
.given(task, self.employee_alice, self.schedule_info) | |
.penalizes_by(0) | |
) | |
def test_no_weekend_scheduling_constraint_satisfied(self): | |
"""Test that weekday tasks are not penalized. | |
Note: Since our slot system only includes working days, | |
is_weekend_slot should always return False for valid slots. | |
""" | |
task = create_task( | |
task_id="task1", | |
description="Weekday task", | |
start_slot=0, # First slot of first working day | |
required_skill="Python", | |
employee=self.employee_alice, | |
) | |
( | |
self.constraint_verifier.verify_that(no_weekend_scheduling) | |
.given(task, self.employee_alice, self.schedule_info) | |
.penalizes_by(0) | |
) | |
# ==================== SOFT CONSTRAINT TESTS ==================== | |
def test_undesired_day_for_employee_constraint_violation(self): | |
"""Test that tasks on undesired days incur soft penalty.""" | |
# With 20 slots per working day, day after tomorrow starts at slot 40 | |
task = create_task( | |
task_id="task1", | |
description="Task on undesired day", | |
start_slot=40, # Day after tomorrow (Alice's undesired date) | |
required_skill="Python", | |
employee=self.employee_alice, | |
) | |
( | |
self.constraint_verifier.verify_that(undesired_day_for_employee) | |
.given(task, self.employee_alice, self.schedule_info) | |
.penalizes_by(1) | |
) | |
def test_undesired_day_for_employee_constraint_satisfied(self): | |
"""Test that tasks not on undesired days are not penalized.""" | |
task = create_task( | |
task_id="task1", | |
description="Task on neutral day", | |
start_slot=0, # Today (neutral for Alice) | |
required_skill="Python", | |
employee=self.employee_alice, | |
) | |
( | |
self.constraint_verifier.verify_that(undesired_day_for_employee) | |
.given(task, self.employee_alice, self.schedule_info) | |
.penalizes_by(0) | |
) | |
def test_desired_day_for_employee_constraint_reward(self): | |
"""Test that tasks on desired days provide reward.""" | |
# Alice's desired day is today (slot 0-19) | |
task = create_task( | |
task_id="task1", | |
description="Task on desired day", | |
start_slot=0, # Today (Alice's desired date) | |
required_skill="Python", | |
employee=self.employee_alice, | |
) | |
( | |
self.constraint_verifier.verify_that(desired_day_for_employee) | |
.given(task, self.employee_alice, self.schedule_info) | |
.rewards() | |
) | |
def test_desired_day_for_employee_constraint_neutral(self): | |
"""Test that tasks on neutral days don't provide desired day reward.""" | |
task = create_task( | |
task_id="task1", | |
description="Task on neutral day", | |
start_slot=20, # Tomorrow (neutral for Alice) | |
required_skill="Python", | |
employee=self.employee_alice, | |
) | |
( | |
self.constraint_verifier.verify_that(desired_day_for_employee) | |
.given(task, self.employee_alice, self.schedule_info) | |
.justifies_with() | |
) | |
def test_balance_employee_task_assignments_constraint_balanced(self): | |
"""Test that balanced task assignments don't incur penalty.""" | |
# Create balanced task assignments (2 tasks each) | |
tasks = [ | |
create_task( | |
"task1", | |
"Alice Task 1", | |
start_slot=0, | |
required_skill="Python", | |
employee=self.employee_alice, | |
), | |
create_task( | |
"task2", | |
"Alice Task 2", | |
start_slot=5, | |
required_skill="Testing", | |
employee=self.employee_alice, | |
), | |
create_task( | |
"task3", | |
"Bob Task 1", | |
start_slot=10, | |
required_skill="Java", | |
employee=self.employee_bob, | |
), | |
create_task( | |
"task4", | |
"Bob Task 2", | |
start_slot=15, | |
required_skill="Documentation", | |
employee=self.employee_bob, | |
), | |
] | |
# Verify balanced assignment (both employees have 2 tasks) | |
( | |
self.constraint_verifier.verify_that(balance_employee_task_assignments) | |
.given( | |
*tasks, | |
self.employee_alice, | |
self.employee_bob, | |
self.schedule_info, | |
) | |
.penalizes_by(0) | |
) | |
def test_balance_employee_task_assignments_constraint_imbalanced(self): | |
"""Test that imbalanced task assignments incur penalty.""" | |
# Create imbalanced task assignments (Alice: 3 tasks, Bob: 0 tasks) | |
tasks = [ | |
create_task( | |
"task1", | |
"Alice Task 1", | |
start_slot=0, | |
required_skill="Python", | |
employee=self.employee_alice, | |
), | |
create_task( | |
"task2", | |
"Alice Task 2", | |
start_slot=5, | |
required_skill="Testing", | |
employee=self.employee_alice, | |
), | |
create_task( | |
"task3", | |
"Alice Task 3", | |
start_slot=10, | |
required_skill="Java", | |
employee=self.employee_alice, | |
), | |
] | |
( | |
self.constraint_verifier.verify_that(balance_employee_task_assignments) | |
.given( | |
*tasks, | |
self.employee_alice, | |
self.employee_bob, | |
self.schedule_info, | |
) | |
.penalizes() | |
) | |
# ==================== INTEGRATION TESTS ==================== | |
def test_all_constraints_together_feasible_solution(self): | |
"""Test all constraints working together on a feasible solution.""" | |
# Create a feasible mini schedule | |
tasks = [ | |
create_task( | |
"task1", | |
"Valid Python Task", | |
start_slot=0, # Today (Alice's desired day) | |
required_skill="Python", | |
project_id="project1", | |
sequence_number=1, | |
employee=self.employee_alice, | |
), | |
create_task( | |
"task2", | |
"Valid Java Task", | |
start_slot=10, # After lunch break (14:00), non-overlapping | |
required_skill="Java", | |
project_id="project1", | |
sequence_number=2, | |
employee=self.employee_alice, | |
), | |
create_task( | |
"task3", | |
"Bob's Valid Task", | |
start_slot=14, # After lunch break (14:00) | |
required_skill="Java", | |
project_id="project2", | |
sequence_number=1, | |
employee=self.employee_bob, | |
), | |
] | |
( | |
self.constraint_verifier.verify_that() | |
.given( | |
*tasks, | |
self.employee_alice, | |
self.employee_bob, | |
self.schedule_info, | |
) | |
.scores(HardSoftDecimalScore.of(Decimal("0"), Decimal("1.292893"))) | |
) | |
def test_all_constraints_together_infeasible_solution(self): | |
"""Test all constraints working together on an infeasible solution.""" | |
# Create a mini schedule with multiple constraint violations | |
tasks = [ | |
create_task( | |
"task1", | |
"Valid Python Task", | |
start_slot=0, # Today (Alice's desired day) | |
required_skill="Python", | |
project_id="project1", | |
sequence_number=1, | |
employee=self.employee_alice, | |
), | |
create_task( | |
"task2", | |
"Invalid Skill Task", | |
start_slot=20, # Tomorrow (Alice unavailable) | |
required_skill="NonExistentSkill", | |
project_id="project1", | |
sequence_number=2, | |
employee=self.employee_alice, | |
), | |
create_task( | |
"task3", | |
"Overlapping Task", | |
start_slot=2, # Overlaps with task1 | |
required_skill="Testing", | |
project_id="project2", | |
sequence_number=1, | |
employee=self.employee_alice, | |
), | |
] | |
( | |
self.constraint_verifier.verify_that() | |
.given( | |
*tasks, | |
self.employee_alice, | |
self.employee_bob, | |
self.schedule_info, | |
) | |
.scores(HardSoftDecimalScore.of(Decimal("-4"), Decimal("-0.12132"))) | |
) | |
# ==================== DATA GENERATOR FUNCTIONS ==================== | |
def create_test_dates(): | |
"""Generate common test dates for consistent usage across tests.""" | |
today = date.today() | |
return { | |
"today": today, | |
"tomorrow": today + timedelta(days=1), | |
"day_after": today + timedelta(days=2), | |
} | |
def create_employee( | |
name, skills=None, unavailable_dates=None, undesired_dates=None, desired_dates=None | |
): | |
"""Create an employee with specified attributes.""" | |
return Employee( | |
name=name, | |
skills=skills or set(), | |
unavailable_dates=unavailable_dates or set(), | |
undesired_dates=undesired_dates or set(), | |
desired_dates=desired_dates or set(), | |
) | |
def create_task( | |
task_id, | |
description="Test Task", | |
duration_slots=4, | |
start_slot=0, | |
required_skill="Python", | |
project_id=None, | |
sequence_number=None, | |
employee=None, | |
): | |
"""Create a task with specified attributes.""" | |
return Task( | |
id=task_id, | |
description=description, | |
duration_slots=duration_slots, | |
start_slot=start_slot, | |
required_skill=required_skill, | |
project_id=project_id, | |
sequence_number=sequence_number, | |
employee=employee, | |
) | |
def create_schedule_info(total_slots=60): | |
"""Create a schedule info object with specified total slots. | |
Default is 60 slots = 3 working days * 20 slots per working day. | |
""" | |
return ScheduleInfo(total_slots=total_slots, base_date=date.today()) | |
def create_standard_employees(dates): | |
"""Create the standard set of test employees used across multiple tests.""" | |
return { | |
"alice": create_employee( | |
name="Alice", | |
skills={"Python", "Java", "Testing"}, | |
unavailable_dates={dates["tomorrow"]}, | |
undesired_dates={dates["day_after"]}, | |
desired_dates={dates["today"]}, | |
), | |
"bob": create_employee( | |
name="Bob", | |
skills={"Java", "Documentation", "Management"}, | |
), | |
"charlie": create_employee( | |
name="Charlie", | |
skills={"Python", "Testing", "DevOps"}, | |
), | |
} | |
if __name__ == "__main__": | |
"""Direct execution for non-pytest testing""" | |
logger.section("Constraint Solver Tests") | |
logger.info( | |
"Note: This test suite is designed for pytest. For best results, run with:" | |
) | |
logger.info(" pytest tests/test_constraints.py -v") | |
logger.info(" YUGA_DEBUG=true pytest tests/test_constraints.py -v -s") | |
# Create test results tracker | |
results = create_test_results(logger) | |
try: | |
# Create test instance | |
test_instance = TestConstraints() | |
test_instance.setup_method() | |
# Run a few sample tests | |
logger.info("Running sample constraint tests...") | |
sample_tests = [ | |
( | |
"required_skill_violation", | |
test_instance.test_required_skill_constraint_violation, | |
), | |
( | |
"required_skill_satisfied", | |
test_instance.test_required_skill_constraint_satisfied, | |
), | |
( | |
"no_overlapping_violation", | |
test_instance.test_no_overlapping_tasks_constraint_violation, | |
), | |
( | |
"task_within_schedule", | |
test_instance.test_task_within_schedule_constraint_satisfied, | |
), | |
] | |
for test_name, test_func in sample_tests: | |
results.run_test(test_name, test_func) | |
logger.info(f"β Completed {len(sample_tests)} sample constraint tests") | |
except Exception as e: | |
logger.error(f"Failed to run constraint tests: {e}") | |
results.add_result("constraint_tests_setup", False, str(e)) | |
# Generate summary and exit with appropriate code | |
all_passed = results.summary() | |
if not all_passed: | |
logger.info( | |
"π‘ Hint: Use 'pytest tests/test_constraints.py' for full test coverage" | |
) | |
sys.exit(0 if all_passed else 1) | |