yuga-planner / tests /test_constraints.py
blackopsrepl's picture
feat: refactor tests and logging
f3473c1
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)