diff --git a/python/python-core/src/main/python/domain/__init__.py b/python/python-core/src/main/python/domain/__init__.py
index b4c99b72a9..7b5678842a 100644
--- a/python/python-core/src/main/python/domain/__init__.py
+++ b/python/python-core/src/main/python/domain/__init__.py
@@ -30,6 +30,7 @@
from ._value_range import *
from ._variable_listener import *
from typing import TYPE_CHECKING as _TYPE_CHECKING
+from .shift_scheduling import Timeslot, Skill, Employee, Shift
if _TYPE_CHECKING:
class CountableValueRange:
@@ -43,5 +44,5 @@ def __getattr__(name: str):
if not _TYPE_CHECKING:
exported = [name for name in globals().keys() if not name.startswith('_')]
- exported += ['CountableValueRange']
+ exported += ['CountableValueRange', 'Timeslot', 'Skill', 'Employee', 'Shift']
__all__ = exported
diff --git a/python/python-core/src/main/python/domain/shift_scheduling.py b/python/python-core/src/main/python/domain/shift_scheduling.py
new file mode 100644
index 0000000000..50dcd5a3cc
--- /dev/null
+++ b/python/python-core/src/main/python/domain/shift_scheduling.py
@@ -0,0 +1,58 @@
+from __future__ import annotations
+
+import datetime
+from dataclasses import dataclass, field
+from typing import List
+
+from timefold.solver.domain import problem_fact, planning_entity, planning_variable
+
+
+@dataclass
+class Timeslot:
+ id: str
+ start_datetime: datetime.datetime
+ end_datetime: datetime.datetime
+
+ def overlaps(self, other_timeslot: Timeslot) -> bool:
+ return max(self.start_datetime, other_timeslot.start_datetime) < min(self.end_datetime, other_timeslot.end_datetime)
+
+
+@dataclass(frozen=True)
+class Skill:
+ name: str
+
+ def __hash__(self):
+ return hash(self.name)
+
+ def __eq__(self, other):
+ if not isinstance(other, Skill):
+ return False
+ return self.name == other.name
+
+
+@problem_fact
+@dataclass
+class Employee:
+ id: str
+ name: str
+ skills: List[Skill] = field(default_factory=list)
+ unavailable_timeslots: List[Timeslot] = field(default_factory=list)
+ cannot_work_with: List[str] = field(default_factory=list)
+ classification: str = "unknown"
+ is_senior: bool = False
+
+
+@planning_entity
+@dataclass
+class Shift:
+ id: str
+ timeslot: Timeslot
+ required_skill: Skill
+ employee: Employee = field(default=None)
+
+ @planning_variable(Employee)
+ def get_employee(self) -> Employee | None:
+ return self.employee
+
+ def set_employee(self, employee: Employee | None) -> None:
+ self.employee = employee
diff --git a/python/python-core/src/main/python/score/__init__.py b/python/python-core/src/main/python/score/__init__.py
index 2e6f48e156..70f5a39709 100644
--- a/python/python-core/src/main/python/score/__init__.py
+++ b/python/python-core/src/main/python/score/__init__.py
@@ -27,3 +27,24 @@
from ._score import *
from ._score_analysis import *
from ._score_director import *
+from .standard_shift_constraints import define_standard_shift_constraints
+
+__all__ = [
+ # from ._annotations
+ 'constraint_provider',
+ # from ._constraint_factory
+ 'ConstraintFactory',
+ # from ._constraint_builder
+ 'ConstraintBuilder', # Note: Or specific builders if preferred
+ # from ._constraint_stream
+ 'ConstraintStream', # Or specific stream types
+ # from ._joiners
+ 'Joiners',
+ # from ._score
+ 'HardSoftScore', 'SimpleScore', 'HardMediumSoftScore', # Add other score types as needed
+ # from ._incremental_score_calculator
+ 'IncrementalScoreCalculator',
+ # from current file
+ 'define_standard_shift_constraints'
+ # Potentially others from _score_director, _score_analysis depending on typical public API
+]
diff --git a/python/python-core/src/main/python/score/standard_shift_constraints.py b/python/python-core/src/main/python/score/standard_shift_constraints.py
new file mode 100644
index 0000000000..a6944a7022
--- /dev/null
+++ b/python/python-core/src/main/python/score/standard_shift_constraints.py
@@ -0,0 +1,83 @@
+from __future__ import annotations
+
+from timefold.solver.score import ConstraintFactory, HardSoftScore, Joiners
+
+from ..domain.shift_scheduling import Shift, Employee, Skill
+
+
+def no_overlapping_shifts(constraint_factory: ConstraintFactory):
+ return (constraint_factory.for_each_unique_pair(
+ Shift,
+ Joiners.equal(lambda shift: shift.employee),
+ Joiners.filtering(lambda shift1, shift2: shift1.timeslot.overlaps(shift2.timeslot))
+ )
+ .penalize(HardSoftScore.ONE_HARD)
+ .as_constraint("no_overlapping_shifts"))
+
+
+def employee_availability(constraint_factory: ConstraintFactory):
+ return (constraint_factory.for_each(Shift)
+ .filter(lambda shift: shift.employee is not None and
+ any(shift.timeslot.overlaps(unavailable_slot)
+ for unavailable_slot in shift.employee.unavailable_timeslots))
+ .penalize(HardSoftScore.ONE_HARD)
+ .as_constraint("employee_availability"))
+
+
+def skill_requirement(constraint_factory: ConstraintFactory):
+ return (constraint_factory.for_each(Shift)
+ .filter(lambda shift: (shift.employee is not None and
+ shift.required_skill is not None and
+ shift.required_skill not in shift.employee.skills))
+ .penalize(HardSoftScore.ONE_HARD)
+ .as_constraint("skill_requirement"))
+
+
+def filter_cannot_work_together(shift1: Shift, shift2: Shift) -> bool:
+ if shift1.employee is None or shift2.employee is None:
+ return False
+ # If employees are the same, this is not a "cannot work together" scenario,
+ # but potentially an "overlapping shift" for the same employee, handled by another constraint.
+ if shift1.employee.id == shift2.employee.id:
+ return False
+ if not shift1.timeslot.overlaps(shift2.timeslot):
+ return False
+ if shift1.employee.id in shift2.employee.cannot_work_with:
+ return True
+ if shift2.employee.id in shift1.employee.cannot_work_with:
+ return True
+ return False
+
+
+def cannot_work_together(constraint_factory: ConstraintFactory):
+ return (constraint_factory.for_each_unique_pair(
+ Shift,
+ Joiners.filtering(filter_cannot_work_together)
+ )
+ .penalize(HardSoftScore.ONE_HARD)
+ .as_constraint("cannot_work_together"))
+
+
+def define_standard_shift_constraints(constraint_factory: ConstraintFactory):
+ return [
+ no_overlapping_shifts(constraint_factory),
+ employee_availability(constraint_factory),
+ skill_requirement(constraint_factory),
+ cannot_work_together(constraint_factory),
+ seniority_requirement(constraint_factory)
+ ]
+
+
+def seniority_requirement(constraint_factory: ConstraintFactory):
+ return (constraint_factory.for_each(Shift)
+ .filter(lambda s_junior: (s_junior.employee is not None and
+ s_junior.employee.classification in ["nurse", "paramedic"] and
+ not s_junior.employee.is_senior))
+ .if_not_exists(Shift,
+ Joiners.filtering(lambda s_junior, s_other: s_junior.id != s_other.id),
+ Joiners.filtering(lambda s_junior, s_other: (s_other.employee is not None and
+ s_other.employee.classification in ["nurse", "paramedic"])),
+ Joiners.filtering(lambda s_junior, s_other: s_junior.timeslot.overlaps(s_other.timeslot))
+ )
+ .penalize(HardSoftScore.ONE_HARD)
+ .as_constraint("seniority_requirement"))
diff --git a/python/python-core/src/test/python/score/test_standard_shift_constraints.py b/python/python-core/src/test/python/score/test_standard_shift_constraints.py
new file mode 100644
index 0000000000..f0f7665611
--- /dev/null
+++ b/python/python-core/src/test/python/score/test_standard_shift_constraints.py
@@ -0,0 +1,400 @@
+import datetime
+
+from timefold.solver.test import ConstraintVerifier
+from timefold.solver.score import HardSoftScore
+
+from .....main.python.domain.shift_scheduling import Timeslot, Skill, Employee, Shift
+from .....main.python.score.standard_shift_constraints import define_standard_shift_constraints, \
+ no_overlapping_shifts, employee_availability, skill_requirement, cannot_work_together, seniority_requirement
+
+# Setup
+constraint_verifier = ConstraintVerifier.create(define_standard_shift_constraints,
+ [Shift, Employee], Shift)
+
+# Test Data
+employee1 = Employee(id="e1", name="Employee 1", skills=[Skill(name="Skill1")])
+employee2 = Employee(id="e2", name="Employee 2", skills=[Skill(name="Skill2")])
+
+skill1 = Skill(name="AnySkill")
+
+# Test Case 1: Overlapping Shifts for the Same Employee (Penalty)
+def test_overlapping_shifts_same_employee():
+ timeslot1 = Timeslot(id="ts1", start_datetime=datetime.datetime(2023, 1, 1, 9, 0),
+ end_datetime=datetime.datetime(2023, 1, 1, 11, 0))
+ timeslot2 = Timeslot(id="ts2", start_datetime=datetime.datetime(2023, 1, 1, 10, 0),
+ end_datetime=datetime.datetime(2023, 1, 1, 12, 0))
+
+ shift1 = Shift(id="s1", timeslot=timeslot1, required_skill=skill1, employee=employee1)
+ shift2 = Shift(id="s2", timeslot=timeslot2, required_skill=skill1, employee=employee1)
+
+ constraint_verifier.verify_that(no_overlapping_shifts) \
+ .given(employee1, shift1, shift2) \
+ .penalizes_by(1)
+
+# Test Case 2: No Overlapping Shifts (Same Employee, Different Times) (No Penalty)
+def test_non_overlapping_shifts_same_employee():
+ # Non-overlapping: S1: 9-10, S2: 10-11 (boundary, not overlapping)
+ timeslot1 = Timeslot(id="ts3", start_datetime=datetime.datetime(2023, 1, 1, 9, 0),
+ end_datetime=datetime.datetime(2023, 1, 1, 10, 0))
+ timeslot2 = Timeslot(id="ts4", start_datetime=datetime.datetime(2023, 1, 1, 10, 0),
+ end_datetime=datetime.datetime(2023, 1, 1, 11, 0))
+ # Non-overlapping: S1: 9-10, S3: 11-12
+ timeslot3 = Timeslot(id="ts5", start_datetime=datetime.datetime(2023, 1, 1, 11, 0),
+ end_datetime=datetime.datetime(2023, 1, 1, 12, 0))
+
+
+ shift1 = Shift(id="s3", timeslot=timeslot1, required_skill=skill1, employee=employee1)
+ shift2 = Shift(id="s4", timeslot=timeslot2, required_skill=skill1, employee=employee1)
+ shift3 = Shift(id="s5", timeslot=timeslot3, required_skill=skill1, employee=employee1)
+
+
+ constraint_verifier.verify_that(no_overlapping_shifts) \
+ .given(employee1, shift1, shift2) \
+ .has_no_violations()
+
+ constraint_verifier.verify_that(no_overlapping_shifts) \
+ .given(employee1, shift1, shift3) \
+ .has_no_violations()
+
+# Test Case 3: Overlapping Shifts for Different Employees (No Penalty)
+def test_overlapping_shifts_different_employees():
+ timeslot1 = Timeslot(id="ts6", start_datetime=datetime.datetime(2023, 1, 1, 9, 0),
+ end_datetime=datetime.datetime(2023, 1, 1, 11, 0))
+ timeslot2 = Timeslot(id="ts7", start_datetime=datetime.datetime(2023, 1, 1, 10, 0),
+ end_datetime=datetime.datetime(2023, 1, 1, 12, 0))
+
+ shift1 = Shift(id="s6", timeslot=timeslot1, required_skill=skill1, employee=employee1)
+ shift2 = Shift(id="s7", timeslot=timeslot2, required_skill=skill1, employee=employee2)
+
+ constraint_verifier.verify_that(no_overlapping_shifts) \
+ .given(employee1, employee2, shift1, shift2) \
+ .has_no_violations()
+
+
+# --- Tests for employee_availability ---
+
+# Test Case 1: Employee Unavailable (Penalty)
+def test_employee_unavailable():
+ unavailable_timeslot = Timeslot(id="uts1", start_datetime=datetime.datetime(2023, 1, 2, 10, 0),
+ end_datetime=datetime.datetime(2023, 1, 2, 12, 0))
+ employee_with_unavailability = Employee(id="e3", name="Unavailable Employee",
+ skills=[Skill(name="AnySkill")],
+ unavailable_timeslots=[unavailable_timeslot])
+
+ shift_during_unavailability = Shift(id="s8",
+ timeslot=Timeslot(id="ts8",
+ start_datetime=datetime.datetime(2023, 1, 2, 10, 0),
+ end_datetime=datetime.datetime(2023, 1, 2, 11, 0)),
+ required_skill=skill1,
+ employee=employee_with_unavailability)
+
+ overlapping_shift = Shift(id="s11",
+ timeslot=Timeslot(id="ts11",
+ start_datetime=datetime.datetime(2023, 1, 2, 9, 0), # Shift starts before
+ end_datetime=datetime.datetime(2023, 1, 2, 11, 0)), # Shift ends during
+ required_skill=skill1,
+ employee=employee_with_unavailability)
+
+ constraint_verifier.verify_that(employee_availability) \
+ .given(employee_with_unavailability, shift_during_unavailability) \
+ .penalizes_by(1)
+
+ constraint_verifier.verify_that(employee_availability) \
+ .given(employee_with_unavailability, overlapping_shift) \
+ .penalizes_by(1)
+
+
+# Test Case 2: Employee Available (No Penalty)
+def test_employee_available():
+ unavailable_timeslot = Timeslot(id="uts2", start_datetime=datetime.datetime(2023, 1, 3, 10, 0),
+ end_datetime=datetime.datetime(2023, 1, 3, 12, 0))
+ employee_with_unavailability = Employee(id="e4", name="Available Employee",
+ skills=[Skill(name="AnySkill")],
+ unavailable_timeslots=[unavailable_timeslot])
+
+ shift_outside_unavailability = Shift(id="s9",
+ timeslot=Timeslot(id="ts9",
+ start_datetime=datetime.datetime(2023, 1, 3, 13, 0),
+ end_datetime=datetime.datetime(2023, 1, 3, 14, 0)),
+ required_skill=skill1,
+ employee=employee_with_unavailability)
+
+ constraint_verifier.verify_that(employee_availability) \
+ .given(employee_with_unavailability, shift_outside_unavailability) \
+ .has_no_violations()
+
+
+# Test Case 3: Employee Unavailable but Shift Assigned to Different Employee (No Penalty)
+def test_employee_unavailable_different_employee_assigned():
+ unavailable_timeslot_e5 = Timeslot(id="uts3", start_datetime=datetime.datetime(2023, 1, 4, 10, 0),
+ end_datetime=datetime.datetime(2023, 1, 4, 12, 0))
+ employee_unavailable = Employee(id="e5", name="Busy Employee",
+ skills=[Skill(name="AnySkill")],
+ unavailable_timeslots=[unavailable_timeslot_e5])
+ available_employee = Employee(id="e6", name="Free Employee", skills=[Skill(name="AnySkill")])
+
+ shift_during_e5_unavailability = Shift(id="s10",
+ timeslot=Timeslot(id="ts10",
+ start_datetime=datetime.datetime(2023, 1, 4, 10, 0),
+ end_datetime=datetime.datetime(2023, 1, 4, 11, 0)),
+ required_skill=skill1,
+ employee=available_employee) # Assigned to e6 (available_employee)
+
+ constraint_verifier.verify_that(employee_availability) \
+ .given(employee_unavailable, available_employee, shift_during_e5_unavailability) \
+ .has_no_violations()
+
+
+# Test Case 4: Shift With No Employee Assigned (No Penalty)
+def test_shift_with_no_employee_assigned_availability():
+ unavailable_timeslot_e7 = Timeslot(id="uts4", start_datetime=datetime.datetime(2023, 1, 5, 10, 0),
+ end_datetime=datetime.datetime(2023, 1, 5, 12, 0))
+ employee_with_unavailability = Employee(id="e7", name="Another Busy Employee",
+ skills=[Skill(name="AnySkill")],
+ unavailable_timeslots=[unavailable_timeslot_e7])
+
+ shift_with_no_employee = Shift(id="s12",
+ timeslot=Timeslot(id="ts12",
+ start_datetime=datetime.datetime(2023, 1, 5, 10, 0),
+ end_datetime=datetime.datetime(2023, 1, 5, 11, 0)),
+ required_skill=skill1,
+ employee=None) # No employee assigned
+
+ constraint_verifier.verify_that(employee_availability) \
+ .given(employee_with_unavailability, shift_with_no_employee) \
+ .has_no_violations()
+
+
+# --- Tests for skill_requirement ---
+
+# Test Data for Skill Tests
+skill_java = Skill(name="Java")
+skill_python = Skill(name="Python")
+skill_any = Skill(name="AnySkill") # Re-using skill1 for clarity in these tests
+
+default_timeslot = Timeslot(id="ts_skill_default", start_datetime=datetime.datetime(2023, 1, 6, 9, 0),
+ end_datetime=datetime.datetime(2023, 1, 6, 17, 0))
+
+# Test Case 1: Employee Missing Required Skill (Penalty)
+def test_employee_missing_required_skill():
+ employee_python_only = Employee(id="e8", name="Python Developer", skills=[skill_python])
+ shift_requires_java = Shift(id="s13", timeslot=default_timeslot,
+ required_skill=skill_java, employee=employee_python_only)
+
+ constraint_verifier.verify_that(skill_requirement) \
+ .given(employee_python_only, shift_requires_java, skill_java, skill_python) \
+ .penalizes_by(1)
+
+# Test Case 2: Employee Has Required Skill (No Penalty)
+def test_employee_has_required_skill():
+ employee_java_dev = Employee(id="e9", name="Java Developer", skills=[skill_java, skill_python])
+ shift_requires_java = Shift(id="s14", timeslot=default_timeslot,
+ required_skill=skill_java, employee=employee_java_dev)
+
+ constraint_verifier.verify_that(skill_requirement) \
+ .given(employee_java_dev, shift_requires_java, skill_java, skill_python) \
+ .has_no_violations()
+
+# Test Case 3: Shift Has No Required Skill (No Penalty)
+def test_shift_has_no_required_skill():
+ employee_no_java = Employee(id="e10", name="No Java Skill Employee", skills=[skill_python])
+ shift_no_skill_needed = Shift(id="s15", timeslot=default_timeslot,
+ required_skill=None, employee=employee_no_java)
+
+ constraint_verifier.verify_that(skill_requirement) \
+ .given(employee_no_java, shift_no_skill_needed, skill_python) \
+ .has_no_violations()
+
+# Test Case 4: Shift Has No Employee Assigned (No Penalty)
+def test_shift_has_no_employee_assigned_skill():
+ employee_irrelevant = Employee(id="e11", name="Irrelevant Employee", skills=[skill_java]) # Skills don't matter here
+ shift_requires_java_unassigned = Shift(id="s16", timeslot=default_timeslot,
+ required_skill=skill_java, employee=None)
+
+ constraint_verifier.verify_that(skill_requirement) \
+ .given(employee_irrelevant, shift_requires_java_unassigned, skill_java) \
+ .has_no_violations()
+
+
+# --- Tests for cannot_work_together ---
+
+# Common Timeslots for CWT tests
+cwt_ts_overlap1 = Timeslot(id="cwt_ts1", start_datetime=datetime.datetime(2023, 2, 1, 9, 0),
+ end_datetime=datetime.datetime(2023, 2, 1, 11, 0))
+cwt_ts_overlap2 = Timeslot(id="cwt_ts2", start_datetime=datetime.datetime(2023, 2, 1, 10, 0),
+ end_datetime=datetime.datetime(2023, 2, 1, 12, 0))
+cwt_ts_non_overlap1 = Timeslot(id="cwt_ts3", start_datetime=datetime.datetime(2023, 2, 1, 13, 0),
+ end_datetime=datetime.datetime(2023, 2, 1, 14, 0))
+cwt_ts_non_overlap2 = Timeslot(id="cwt_ts4", start_datetime=datetime.datetime(2023, 2, 1, 15, 0),
+ end_datetime=datetime.datetime(2023, 2, 1, 16, 0))
+cwt_skill = Skill(name="CWT_Skill")
+
+
+# Test Case 1: Cannot Work Together, Overlapping (Penalty)
+def test_cannot_work_together_overlapping_penalty():
+ emp_cwt1 = Employee(id="emp_cwt1", name="CWT Emp1", cannot_work_with=["emp_cwt2"])
+ emp_cwt2 = Employee(id="emp_cwt2", name="CWT Emp2")
+
+ shift_cwt1 = Shift(id="shift_cwt1", timeslot=cwt_ts_overlap1, required_skill=cwt_skill, employee=emp_cwt1)
+ shift_cwt2 = Shift(id="shift_cwt2", timeslot=cwt_ts_overlap2, required_skill=cwt_skill, employee=emp_cwt2)
+
+ constraint_verifier.verify_that(cannot_work_together) \
+ .given(emp_cwt1, emp_cwt2, shift_cwt1, shift_cwt2) \
+ .penalizes_by(1)
+
+ # Test the other direction too
+ emp_cwt3 = Employee(id="emp_cwt3", name="CWT Emp3")
+ emp_cwt4 = Employee(id="emp_cwt4", name="CWT Emp4", cannot_work_with=["emp_cwt3"])
+
+ shift_cwt3 = Shift(id="shift_cwt3", timeslot=cwt_ts_overlap1, required_skill=cwt_skill, employee=emp_cwt3)
+ shift_cwt4 = Shift(id="shift_cwt4", timeslot=cwt_ts_overlap2, required_skill=cwt_skill, employee=emp_cwt4)
+ constraint_verifier.verify_that(cannot_work_together) \
+ .given(emp_cwt3, emp_cwt4, shift_cwt3, shift_cwt4) \
+ .penalizes_by(1)
+
+
+# Test Case 2: Cannot Work Together, Non-Overlapping (No Penalty)
+def test_cannot_work_together_non_overlapping_no_penalty():
+ emp_cwt_non1 = Employee(id="emp_cwt_non1", name="CWT NonOverlap1", cannot_work_with=["emp_cwt_non2"])
+ emp_cwt_non2 = Employee(id="emp_cwt_non2", name="CWT NonOverlap2")
+
+ shift_cwt_non1 = Shift(id="shift_cwt_non1", timeslot=cwt_ts_non_overlap1, required_skill=cwt_skill, employee=emp_cwt_non1)
+ shift_cwt_non2 = Shift(id="shift_cwt_non2", timeslot=cwt_ts_non_overlap2, required_skill=cwt_skill, employee=emp_cwt_non2)
+
+ constraint_verifier.verify_that(cannot_work_together) \
+ .given(emp_cwt_non1, emp_cwt_non2, shift_cwt_non1, shift_cwt_non2) \
+ .has_no_violations()
+
+# Test Case 3: Can Work Together, Overlapping (No Penalty)
+def test_can_work_together_overlapping_no_penalty():
+ emp_cwt_can1 = Employee(id="emp_cwt_can1", name="CWT Can1") # cannot_work_with is empty
+ emp_cwt_can2 = Employee(id="emp_cwt_can2", name="CWT Can2")
+
+ shift_cwt_can1 = Shift(id="shift_cwt_can1", timeslot=cwt_ts_overlap1, required_skill=cwt_skill, employee=emp_cwt_can1)
+ shift_cwt_can2 = Shift(id="shift_cwt_can2", timeslot=cwt_ts_overlap2, required_skill=cwt_skill, employee=emp_cwt_can2)
+
+ constraint_verifier.verify_that(cannot_work_together) \
+ .given(emp_cwt_can1, emp_cwt_can2, shift_cwt_can1, shift_cwt_can2) \
+ .has_no_violations()
+
+# Test Case 4: One Employee Not Assigned (No Penalty)
+def test_cannot_work_together_one_employee_not_assigned():
+ emp_cwt_assigned = Employee(id="emp_cwt_assigned", name="CWT Assigned", cannot_work_with=["emp_cwt_unassigned_ghost"])
+ emp_cwt_unassigned = Employee(id="emp_cwt_unassigned", name="CWT Unassigned") # This employee exists but is not assigned to shift_cwt_unassigned
+
+ shift_cwt_assigned = Shift(id="shift_cwt_assigned", timeslot=cwt_ts_overlap1, required_skill=cwt_skill, employee=emp_cwt_assigned)
+ shift_cwt_unassigned = Shift(id="shift_cwt_unassigned", timeslot=cwt_ts_overlap2, required_skill=cwt_skill, employee=None) # No employee
+
+ constraint_verifier.verify_that(cannot_work_together) \
+ .given(emp_cwt_assigned, emp_cwt_unassigned, shift_cwt_assigned, shift_cwt_unassigned) \
+ .has_no_violations()
+
+ # Also test if the first shift has no employee
+ emp_cwt_assigned2 = Employee(id="emp_cwt_assigned2", name="CWT Assigned2")
+ emp_cwt_unassigned_ghost2 = Employee(id="emp_cwt_unassigned_ghost2", name="CWT Unassigned Ghost2", cannot_work_with=["emp_cwt_assigned2"])
+
+
+ shift_cwt_assigned_other = Shift(id="shift_cwt_assigned_other", timeslot=cwt_ts_overlap2, required_skill=cwt_skill, employee=emp_cwt_assigned2)
+ shift_cwt_unassigned_first = Shift(id="shift_cwt_unassigned_first", timeslot=cwt_ts_overlap1, required_skill=cwt_skill, employee=None)
+
+ constraint_verifier.verify_that(cannot_work_together) \
+ .given(emp_cwt_assigned2, emp_cwt_unassigned_ghost2, shift_cwt_unassigned_first, shift_cwt_assigned_other) \
+ .has_no_violations()
+
+
+# --- Tests for seniority_requirement ---
+
+# Helper for creating employees for seniority tests
+def _create_test_employee(id: str, classification: str, is_senior: bool) -> Employee:
+ return Employee(id=id, name=f"Test Employee {id}", classification=classification, is_senior=is_senior,
+ skills=[Skill(name="BasicCare")]) # Add a default skill
+
+# Common Timeslots & Skill for Seniority tests
+sr_ts1 = Timeslot(id="sr_ts1", start_datetime=datetime.datetime(2023, 3, 1, 9, 0),
+ end_datetime=datetime.datetime(2023, 3, 1, 10, 0))
+sr_ts_overlap = Timeslot(id="sr_ts_overlap", start_datetime=datetime.datetime(2023, 3, 1, 9, 30), # Overlaps with sr_ts1
+ end_datetime=datetime.datetime(2023, 3, 1, 10, 30))
+sr_skill = Skill(name="SR_Skill")
+
+
+# Test Case 1: Junior Nurse, Truly Alone (Penalty)
+def test_junior_nurse_truly_alone_penalty():
+ emp1 = _create_test_employee(id="E1_sr1", classification="nurse", is_senior=False)
+ shift1 = Shift(id="shift_sr1", timeslot=sr_ts1, required_skill=sr_skill, employee=emp1)
+
+ constraint_verifier.verify_that(seniority_requirement) \
+ .given(emp1, shift1) \
+ .penalizes_by(1)
+
+# Test Case 2: Senior Nurse, Truly Alone (No Penalty)
+def test_senior_nurse_truly_alone_no_penalty():
+ emp1 = _create_test_employee(id="E1_sr2", classification="nurse", is_senior=True)
+ shift1 = Shift(id="shift_sr2", timeslot=sr_ts1, required_skill=sr_skill, employee=emp1)
+
+ constraint_verifier.verify_that(seniority_requirement) \
+ .given(emp1, shift1) \
+ .has_no_violations()
+
+# Test Case 3: Junior Nurse, Concurrent Senior Nurse (No Penalty for Junior)
+def test_junior_nurse_with_concurrent_senior_nurse_no_penalty():
+ emp_junior = _create_test_employee(id="E1_sr3", classification="nurse", is_senior=False)
+ emp_senior = _create_test_employee(id="E2_sr3", classification="nurse", is_senior=True)
+ shift_junior = Shift(id="shift_sr3_junior", timeslot=sr_ts1, required_skill=sr_skill, employee=emp_junior)
+ shift_senior = Shift(id="shift_sr3_senior", timeslot=sr_ts_overlap, required_skill=sr_skill, employee=emp_senior)
+
+ constraint_verifier.verify_that(seniority_requirement) \
+ .given(emp_junior, emp_senior, shift_junior, shift_senior) \
+ .has_no_violations()
+
+# Test Case 4: Junior Nurse, Concurrent Junior Nurse (No Penalty for s_junior)
+def test_junior_nurse_with_concurrent_junior_nurse_no_penalty():
+ emp_junior1 = _create_test_employee(id="E1_sr4", classification="nurse", is_senior=False)
+ emp_junior2 = _create_test_employee(id="E2_sr4", classification="nurse", is_senior=False)
+ shift_junior1 = Shift(id="shift_sr4_junior1", timeslot=sr_ts1, required_skill=sr_skill, employee=emp_junior1)
+ shift_junior2 = Shift(id="shift_sr4_junior2", timeslot=sr_ts_overlap, required_skill=sr_skill, employee=emp_junior2)
+
+ constraint_verifier.verify_that(seniority_requirement) \
+ .given(emp_junior1, emp_junior2, shift_junior1, shift_junior2) \
+ .has_no_violations()
+
+
+# Test Case 5: Junior Paramedic, Truly Alone (Penalty)
+def test_junior_paramedic_truly_alone_penalty():
+ emp1 = _create_test_employee(id="E1_sr5", classification="paramedic", is_senior=False)
+ shift1 = Shift(id="shift_sr5", timeslot=sr_ts1, required_skill=sr_skill, employee=emp1)
+
+ constraint_verifier.verify_that(seniority_requirement) \
+ .given(emp1, shift1) \
+ .penalizes_by(1)
+
+
+# Test Case 6: Junior Nurse, Concurrent Non-Nurse/Paramedic (Penalty for Junior)
+def test_junior_nurse_with_concurrent_admin_penalty():
+ emp_junior_nurse = _create_test_employee(id="E1_sr6", classification="nurse", is_senior=False)
+ emp_admin = _create_test_employee(id="E2_sr6", classification="admin", is_senior=False) # is_senior for admin doesn't matter
+ shift_junior_nurse = Shift(id="shift_sr6_nurse", timeslot=sr_ts1, required_skill=sr_skill, employee=emp_junior_nurse)
+ shift_admin = Shift(id="shift_sr6_admin", timeslot=sr_ts_overlap, required_skill=sr_skill, employee=emp_admin)
+
+ constraint_verifier.verify_that(seniority_requirement) \
+ .given(emp_junior_nurse, emp_admin, shift_junior_nurse, shift_admin) \
+ .penalizes_by(1)
+
+
+# Test Case 7: Non-Nurse/Paramedic Employee Alone (No Penalty)
+def test_admin_alone_no_penalty():
+ emp_admin = _create_test_employee(id="E1_sr7", classification="admin", is_senior=False)
+ shift_admin = Shift(id="shift_sr7", timeslot=sr_ts1, required_skill=sr_skill, employee=emp_admin)
+
+ constraint_verifier.verify_that(seniority_requirement) \
+ .given(emp_admin, shift_admin) \
+ .has_no_violations()
+
+# Test Case 8: Junior Nurse, Shift Unassigned (No Penalty)
+def test_junior_nurse_shift_unassigned_no_penalty():
+ # This employee exists but is not assigned to the shift
+ emp_junior_nurse_exists = _create_test_employee(id="E1_sr8", classification="nurse", is_senior=False)
+ shift_unassigned = Shift(id="shift_sr8_unassigned", timeslot=sr_ts1, required_skill=sr_skill, employee=None)
+
+ constraint_verifier.verify_that(seniority_requirement) \
+ .given(emp_junior_nurse_exists, shift_unassigned) \
+ .has_no_violations()