blackopsrepl commited on
Commit
e7cf451
·
1 Parent(s): e510416
README.MD ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Employee Scheduling (Python)
2
+
3
+ Schedule shifts to employees, accounting for employee availability and shift skill requirements.
4
+
5
+ ![Employee Scheduling Screenshot](./employee-scheduling-screenshot.png)
6
+
7
+ - [Prerequisites](#prerequisites)
8
+ - [Run the application](#run-the-application)
9
+ - [Test the application](#test-the-application)
10
+
11
+ > [!TIP]
12
+ > <img src="https://docs.timefold.ai/_/img/models/employee-shift-scheduling.svg" align="right" width="50px" /> [Check out our off-the-shelf model for Employee Shift Scheduling](https://app.timefold.ai/models/employee-scheduling/v1). This model supports many additional constraints such as skills, pairing employees, fairness and more.
13
+
14
+ ## Prerequisites
15
+
16
+ 1. Install [Python 3.11 or 3.12](https://www.python.org/downloads/).
17
+
18
+ 2. Install JDK 17+, for example with [Sdkman](https://sdkman.io):
19
+
20
+ ```sh
21
+ $ sdk install java
22
+ ```
23
+
24
+ ## Run the application
25
+
26
+ 1. Git clone the timefold-solver-python repo and navigate to this directory:
27
+
28
+ ```sh
29
+ $ git clone https://github.com/TimefoldAI/timefold-solver-python.git
30
+ ...
31
+ $ cd timefold-solver-python/quickstarts/employee-scheduling
32
+ ```
33
+
34
+ 2. Create a virtual environment:
35
+
36
+ ```sh
37
+ $ python -m venv .venv
38
+ ```
39
+
40
+ 3. Activate the virtual environment:
41
+
42
+ ```sh
43
+ $ . .venv/bin/activate
44
+ ```
45
+
46
+ 4. Install the application:
47
+
48
+ ```sh
49
+ $ pip install -e .
50
+ ```
51
+
52
+ 5. Run the application:
53
+
54
+ ```sh
55
+ $ run-app
56
+ ```
57
+
58
+ 6. Visit [http://localhost:8080](http://localhost:8080) in your browser.
59
+
60
+ 7. Click on the **Solve** button.
61
+
62
+ ## Test the application
63
+
64
+ 1. Run tests:
65
+
66
+ ```sh
67
+ $ pytest
68
+ ```
69
+
70
+ ## More information
71
+
72
+ Visit [timefold.ai](https://timefold.ai).
src/employee_scheduling/.project ADDED
@@ -0,0 +1 @@
 
 
1
+ /srv/lab/dev/solverforge/solverforge-quickstarts/fast/employee-scheduling-fast/src/employee_scheduling
src/employee_scheduling/__init__.py CHANGED
@@ -1,6 +1,6 @@
1
  import uvicorn
2
 
3
- from .rest_api import app
4
 
5
 
6
  def main():
 
1
  import uvicorn
2
 
3
+ from .rest_api import app as app
4
 
5
 
6
  def main():
src/employee_scheduling/__pycache__/__init__.cpython-312.pyc CHANGED
Binary files a/src/employee_scheduling/__pycache__/__init__.cpython-312.pyc and b/src/employee_scheduling/__pycache__/__init__.cpython-312.pyc differ
 
src/employee_scheduling/__pycache__/constraints.cpython-312.pyc CHANGED
Binary files a/src/employee_scheduling/__pycache__/constraints.cpython-312.pyc and b/src/employee_scheduling/__pycache__/constraints.cpython-312.pyc differ
 
src/employee_scheduling/__pycache__/converters.cpython-312.pyc CHANGED
Binary files a/src/employee_scheduling/__pycache__/converters.cpython-312.pyc and b/src/employee_scheduling/__pycache__/converters.cpython-312.pyc differ
 
src/employee_scheduling/__pycache__/demo_data.cpython-312.pyc CHANGED
Binary files a/src/employee_scheduling/__pycache__/demo_data.cpython-312.pyc and b/src/employee_scheduling/__pycache__/demo_data.cpython-312.pyc differ
 
src/employee_scheduling/__pycache__/rest_api.cpython-312.pyc CHANGED
Binary files a/src/employee_scheduling/__pycache__/rest_api.cpython-312.pyc and b/src/employee_scheduling/__pycache__/rest_api.cpython-312.pyc differ
 
src/employee_scheduling/constraints.py CHANGED
@@ -5,7 +5,7 @@ from solverforge_legacy.solver.score import (
5
  HardSoftDecimalScore,
6
  ConstraintCollectors,
7
  )
8
- from datetime import datetime, date
9
 
10
  from .domain import Employee, Shift
11
 
@@ -33,13 +33,12 @@ def overlapping_in_minutes(
33
 
34
 
35
  def get_shift_overlapping_duration_in_minutes(shift: Shift, dt: date) -> int:
36
- overlap = 0
37
  start_date_time = datetime.combine(dt, datetime.min.time())
38
  end_date_time = datetime.combine(dt, datetime.max.time())
39
- overlap += overlapping_in_minutes(
40
  start_date_time, end_date_time, shift.start, shift.end
41
  )
42
- return overlap
43
 
44
 
45
  @constraint_provider
@@ -51,7 +50,7 @@ def define_constraints(constraint_factory: ConstraintFactory):
51
  at_least_10_hours_between_two_shifts(constraint_factory),
52
  one_shift_per_day(constraint_factory),
53
  unavailable_employee(constraint_factory),
54
- max_shifts_per_employee(constraint_factory),
55
  # Soft constraints
56
  undesired_day_for_employee(constraint_factory),
57
  desired_day_for_employee(constraint_factory),
@@ -126,10 +125,10 @@ def unavailable_employee(constraint_factory: ConstraintFactory):
126
  Joiners.equal(lambda shift: shift.employee, lambda employee: employee),
127
  )
128
  .flatten_last(lambda employee: employee.unavailable_dates)
129
- .filter(lambda shift, unavailable_date: shift.is_overlapping_with_date(unavailable_date))
130
  .penalize(
131
  HardSoftDecimalScore.ONE_HARD,
132
- lambda shift, unavailable_date: shift.get_overlapping_duration_in_minutes(unavailable_date),
133
  )
134
  .as_constraint("Unavailable employee")
135
  )
@@ -169,7 +168,7 @@ def undesired_day_for_employee(constraint_factory: ConstraintFactory):
169
  .filter(lambda shift, undesired_date: shift.is_overlapping_with_date(undesired_date))
170
  .penalize(
171
  HardSoftDecimalScore.ONE_SOFT,
172
- lambda shift, undesired_date: shift.get_overlapping_duration_in_minutes(undesired_date),
173
  )
174
  .as_constraint("Undesired day for employee")
175
  )
@@ -186,7 +185,7 @@ def desired_day_for_employee(constraint_factory: ConstraintFactory):
186
  .filter(lambda shift, desired_date: shift.is_overlapping_with_date(desired_date))
187
  .reward(
188
  HardSoftDecimalScore.ONE_SOFT,
189
- lambda shift, desired_date: shift.get_overlapping_duration_in_minutes(desired_date),
190
  )
191
  .as_constraint("Desired day for employee")
192
  )
 
5
  HardSoftDecimalScore,
6
  ConstraintCollectors,
7
  )
8
+ from datetime import datetime, date, time
9
 
10
  from .domain import Employee, Shift
11
 
 
33
 
34
 
35
  def get_shift_overlapping_duration_in_minutes(shift: Shift, dt: date) -> int:
 
36
  start_date_time = datetime.combine(dt, datetime.min.time())
37
  end_date_time = datetime.combine(dt, datetime.max.time())
38
+ overlap = overlapping_in_minutes(
39
  start_date_time, end_date_time, shift.start, shift.end
40
  )
41
+ return int(overlap)
42
 
43
 
44
  @constraint_provider
 
50
  at_least_10_hours_between_two_shifts(constraint_factory),
51
  one_shift_per_day(constraint_factory),
52
  unavailable_employee(constraint_factory),
53
+ # max_shifts_per_employee(constraint_factory), # Optional extension - disabled by default
54
  # Soft constraints
55
  undesired_day_for_employee(constraint_factory),
56
  desired_day_for_employee(constraint_factory),
 
125
  Joiners.equal(lambda shift: shift.employee, lambda employee: employee),
126
  )
127
  .flatten_last(lambda employee: employee.unavailable_dates)
128
+ .filter(lambda shift, unavailable_date: is_overlapping_with_date(shift, unavailable_date))
129
  .penalize(
130
  HardSoftDecimalScore.ONE_HARD,
131
+ lambda shift, unavailable_date: int((min(shift.end, datetime.combine(unavailable_date, time(23, 59, 59))) - max(shift.start, datetime.combine(unavailable_date, time(0, 0, 0)))).total_seconds() / 60),
132
  )
133
  .as_constraint("Unavailable employee")
134
  )
 
168
  .filter(lambda shift, undesired_date: shift.is_overlapping_with_date(undesired_date))
169
  .penalize(
170
  HardSoftDecimalScore.ONE_SOFT,
171
+ lambda shift, undesired_date: int((min(shift.end, datetime.combine(undesired_date, time(23, 59, 59))) - max(shift.start, datetime.combine(undesired_date, time(0, 0, 0)))).total_seconds() / 60),
172
  )
173
  .as_constraint("Undesired day for employee")
174
  )
 
185
  .filter(lambda shift, desired_date: shift.is_overlapping_with_date(desired_date))
186
  .reward(
187
  HardSoftDecimalScore.ONE_SOFT,
188
+ lambda shift, desired_date: int((min(shift.end, datetime.combine(desired_date, time(23, 59, 59))) - max(shift.start, datetime.combine(desired_date, time(0, 0, 0)))).total_seconds() / 60),
189
  )
190
  .as_constraint("Desired day for employee")
191
  )
src/employee_scheduling/converters.py CHANGED
@@ -1,8 +1,5 @@
1
- from typing import List, Optional, Union
2
  from datetime import datetime, date
3
  from . import domain
4
- from .json_serialization import JsonDomainBase
5
- from pydantic import Field
6
 
7
 
8
  # Conversion functions from domain to API models
 
 
1
  from datetime import datetime, date
2
  from . import domain
 
 
3
 
4
 
5
  # Conversion functions from domain to API models
src/employee_scheduling/demo_data.py CHANGED
@@ -5,7 +5,7 @@ from random import Random
5
  from typing import Generator
6
  from dataclasses import dataclass, field
7
 
8
- from .domain import *
9
 
10
 
11
  class DemoData(Enum):
 
5
  from typing import Generator
6
  from dataclasses import dataclass, field
7
 
8
+ from .domain import Employee, EmployeeSchedule, Shift
9
 
10
 
11
  class DemoData(Enum):
src/employee_scheduling/rest_api.py CHANGED
@@ -1,7 +1,8 @@
1
- from fastapi import FastAPI
2
  from fastapi.staticfiles import StaticFiles
3
  from uuid import uuid4
4
  from dataclasses import replace
 
5
 
6
  from .domain import EmployeeSchedule, EmployeeScheduleModel
7
  from .converters import (
@@ -9,6 +10,7 @@ from .converters import (
9
  )
10
  from .demo_data import DemoData, generate_demo_data
11
  from .solver import solver_manager, solution_manager
 
12
 
13
  app = FastAPI(docs_url='/q/swagger-ui')
14
  data_sets: dict[str, EmployeeSchedule] = {}
@@ -48,9 +50,79 @@ async def solve_timetable(schedule_model: EmployeeScheduleModel) -> str:
48
  return job_id
49
 
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  @app.delete("/schedules/{problem_id}")
52
- async def stop_solving(problem_id: str) -> None:
53
- solver_manager.terminate_early(problem_id)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
 
55
 
56
  app.mount("/", StaticFiles(directory="static", html=True), name="static")
 
1
+ from fastapi import FastAPI, Request
2
  from fastapi.staticfiles import StaticFiles
3
  from uuid import uuid4
4
  from dataclasses import replace
5
+ from typing import Dict, List
6
 
7
  from .domain import EmployeeSchedule, EmployeeScheduleModel
8
  from .converters import (
 
10
  )
11
  from .demo_data import DemoData, generate_demo_data
12
  from .solver import solver_manager, solution_manager
13
+ from .score_analysis import ConstraintAnalysisDTO, MatchAnalysisDTO
14
 
15
  app = FastAPI(docs_url='/q/swagger-ui')
16
  data_sets: dict[str, EmployeeSchedule] = {}
 
50
  return job_id
51
 
52
 
53
+ @app.get("/schedules")
54
+ async def list_schedules() -> List[str]:
55
+ """List all job IDs of submitted schedules."""
56
+ return list(data_sets.keys())
57
+
58
+
59
+ @app.get("/schedules/{problem_id}/status")
60
+ async def get_status(problem_id: str) -> Dict:
61
+ """Get the schedule status and score for a given job ID."""
62
+ if problem_id not in data_sets:
63
+ raise ValueError(f"No schedule found with ID {problem_id}")
64
+
65
+ schedule = data_sets[problem_id]
66
+ solver_status = solver_manager.get_solver_status(problem_id)
67
+
68
+ return {
69
+ "score": {
70
+ "hardScore": schedule.score.hard_score if schedule.score else 0,
71
+ "softScore": schedule.score.soft_score if schedule.score else 0,
72
+ },
73
+ "solverStatus": solver_status.name,
74
+ }
75
+
76
+
77
  @app.delete("/schedules/{problem_id}")
78
+ async def stop_solving(problem_id: str) -> EmployeeScheduleModel:
79
+ """Terminate solving for a given job ID."""
80
+ if problem_id not in data_sets:
81
+ raise ValueError(f"No schedule found with ID {problem_id}")
82
+
83
+ try:
84
+ solver_manager.terminate_early(problem_id)
85
+ except Exception as e:
86
+ print(f"Warning: terminate_early failed for {problem_id}: {e}")
87
+
88
+ return await get_timetable(problem_id)
89
+
90
+
91
+ @app.put("/schedules/analyze")
92
+ async def analyze_schedule(request: Request) -> Dict:
93
+ """Submit a schedule to analyze its score."""
94
+ json_data = await request.json()
95
+
96
+ # Parse the incoming JSON using Pydantic models
97
+ schedule_model = EmployeeScheduleModel.model_validate(json_data)
98
+
99
+ # Convert to domain model for analysis
100
+ domain_schedule = model_to_schedule(schedule_model)
101
+
102
+ analysis = solution_manager.analyze(domain_schedule)
103
+
104
+ # Convert to proper DTOs for correct serialization
105
+ # Use str() for scores and justification to avoid Java object serialization issues
106
+ constraints = []
107
+ for constraint in getattr(analysis, 'constraint_analyses', []) or []:
108
+ matches = [
109
+ MatchAnalysisDTO(
110
+ name=str(getattr(getattr(match, 'constraint_ref', None), 'constraint_name', "")),
111
+ score=str(getattr(match, 'score', "0hard/0soft")),
112
+ justification=str(getattr(match, 'justification', "")),
113
+ )
114
+ for match in getattr(constraint, 'matches', []) or []
115
+ ]
116
+
117
+ constraint_dto = ConstraintAnalysisDTO(
118
+ name=str(getattr(constraint, 'constraint_name', "")),
119
+ weight=str(getattr(constraint, 'weight', "0hard/0soft")),
120
+ score=str(getattr(constraint, 'score', "0hard/0soft")),
121
+ matches=matches,
122
+ )
123
+ constraints.append(constraint_dto)
124
+
125
+ return {"constraints": [constraint.model_dump() for constraint in constraints]}
126
 
127
 
128
  app.mount("/", StaticFiles(directory="static", html=True), name="static")
src/employee_scheduling/score_analysis.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+ from typing import List
3
+
4
+
5
+ class MatchAnalysisDTO(BaseModel):
6
+ name: str
7
+ score: str
8
+ justification: str
9
+
10
+
11
+ class ConstraintAnalysisDTO(BaseModel):
12
+ name: str
13
+ weight: str
14
+ score: str
15
+ matches: List[MatchAnalysisDTO]
static/app.js CHANGED
@@ -440,12 +440,14 @@ function refreshSolvingButtons(solving) {
440
  if (solving) {
441
  $("#solveButton").hide();
442
  $("#stopSolvingButton").show();
 
443
  if (autoRefreshIntervalId == null) {
444
  autoRefreshIntervalId = setInterval(refreshSchedule, 2000);
445
  }
446
  } else {
447
  $("#solveButton").show();
448
  $("#stopSolvingButton").hide();
 
449
  if (autoRefreshIntervalId != null) {
450
  clearInterval(autoRefreshIntervalId);
451
  autoRefreshIntervalId = null;
 
440
  if (solving) {
441
  $("#solveButton").hide();
442
  $("#stopSolvingButton").show();
443
+ $("#solvingSpinner").addClass("active");
444
  if (autoRefreshIntervalId == null) {
445
  autoRefreshIntervalId = setInterval(refreshSchedule, 2000);
446
  }
447
  } else {
448
  $("#solveButton").show();
449
  $("#stopSolvingButton").hide();
450
+ $("#solvingSpinner").removeClass("active");
451
  if (autoRefreshIntervalId != null) {
452
  clearInterval(autoRefreshIntervalId);
453
  autoRefreshIntervalId = null;
static/index.html CHANGED
@@ -14,6 +14,24 @@
14
  .vis-time-axis .vis-grid.vis-sunday {
15
  background: #D3D7CFFF;
16
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  </style>
18
  <link rel="icon" href="/webjars/solverforge/img/solverforge-favicon.svg" type="image/svg+xml">
19
  </head>
@@ -38,8 +56,12 @@
38
  <button id="stopSolvingButton" type="button" class="btn btn-danger">
39
  <span class="fas fa-stop"></span> Stop solving
40
  </button>
 
41
  <span id="unassignedShifts" class="ms-2 align-middle fw-bold"></span>
42
  <span id="score" class="score ms-2 align-middle fw-bold">Score: ?</span>
 
 
 
43
 
44
  <div class="float-end">
45
  <ul class="nav nav-pills" role="tablist">
@@ -119,6 +141,24 @@
119
  </div>
120
  </div>
121
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  <footer id="solverforge-auto-footer"></footer>
123
 
124
  <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/2.11.8/umd/popper.min.js"></script>
 
14
  .vis-time-axis .vis-grid.vis-sunday {
15
  background: #D3D7CFFF;
16
  }
17
+
18
+ /* Solving spinner */
19
+ #solvingSpinner {
20
+ display: none;
21
+ width: 1.25rem;
22
+ height: 1.25rem;
23
+ border: 2px solid #10b981;
24
+ border-top-color: transparent;
25
+ border-radius: 50%;
26
+ animation: spin 0.75s linear infinite;
27
+ vertical-align: middle;
28
+ }
29
+ #solvingSpinner.active {
30
+ display: inline-block;
31
+ }
32
+ @keyframes spin {
33
+ to { transform: rotate(360deg); }
34
+ }
35
  </style>
36
  <link rel="icon" href="/webjars/solverforge/img/solverforge-favicon.svg" type="image/svg+xml">
37
  </head>
 
56
  <button id="stopSolvingButton" type="button" class="btn btn-danger">
57
  <span class="fas fa-stop"></span> Stop solving
58
  </button>
59
+ <span id="solvingSpinner" class="ms-2"></span>
60
  <span id="unassignedShifts" class="ms-2 align-middle fw-bold"></span>
61
  <span id="score" class="score ms-2 align-middle fw-bold">Score: ?</span>
62
+ <button id="analyzeButton" type="button" class="ms-2 btn btn-secondary">
63
+ <span class="fas fa-question"></span>
64
+ </button>
65
 
66
  <div class="float-end">
67
  <ul class="nav nav-pills" role="tablist">
 
141
  </div>
142
  </div>
143
  </div>
144
+
145
+ <div class="modal fadebd-example-modal-lg" id="scoreAnalysisModal" tabindex="-1" aria-labelledby="scoreAnalysisModalLabel" aria-hidden="true">
146
+ <div class="modal-dialog modal-lg modal-dialog-scrollable">
147
+ <div class="modal-content">
148
+ <div class="modal-header">
149
+ <h1 class="modal-title fs-5" id="scoreAnalysisModalLabel">Score analysis <span id="scoreAnalysisScoreLabel"></span></h1>
150
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
151
+ </div>
152
+ <div class="modal-body" id="scoreAnalysisModalContent">
153
+ <!-- Filled in by app.js -->
154
+ </div>
155
+ <div class="modal-footer">
156
+ <button type="button" class="btn btn-primary" data-bs-dismiss="modal">Close</button>
157
+ </div>
158
+ </div>
159
+ </div>
160
+ </div>
161
+
162
  <footer id="solverforge-auto-footer"></footer>
163
 
164
  <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/2.11.8/umd/popper.min.js"></script>
static/webjars/timefold/css/timefold-webui.css ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ /* Keep in sync with .navbar height on a large screen. */
3
+ --ts-navbar-height: 109px;
4
+
5
+ --ts-violet-1-rgb: #3E00FF;
6
+ --ts-violet-2-rgb: #3423A6;
7
+ --ts-violet-3-rgb: #2E1760;
8
+ --ts-violet-4-rgb: #200F4F;
9
+ --ts-violet-5-rgb: #000000; /* TODO FIXME */
10
+ --ts-violet-dark-1-rgb: #b6adfd;
11
+ --ts-violet-dark-2-rgb: #c1bbfd;
12
+ --ts-gray-rgb: #666666;
13
+ --ts-white-rgb: #FFFFFF;
14
+ --ts-light-rgb: #F2F2F2;
15
+ --ts-gray-border: #c5c5c5;
16
+
17
+ --tf-light-rgb-transparent: rgb(242,242,242,0.5); /* #F2F2F2 = rgb(242,242,242) */
18
+ --bs-body-bg: var(--ts-light-rgb); /* link to html bg */
19
+ --bs-link-color: var(--ts-violet-1-rgb);
20
+ --bs-link-hover-color: var(--ts-violet-2-rgb);
21
+
22
+ --bs-navbar-color: var(--ts-white-rgb);
23
+ --bs-navbar-hover-color: var(--ts-white-rgb);
24
+ --bs-nav-link-font-size: 18px;
25
+ --bs-nav-link-font-weight: 400;
26
+ --bs-nav-link-color: var(--ts-white-rgb);
27
+ --ts-nav-link-hover-border-color: var(--ts-violet-1-rgb);
28
+ }
29
+ .btn {
30
+ --bs-btn-border-radius: 1.5rem;
31
+ }
32
+ .btn-primary {
33
+ --bs-btn-bg: var(--ts-violet-1-rgb);
34
+ --bs-btn-border-color: var(--ts-violet-1-rgb);
35
+ --bs-btn-hover-bg: var(--ts-violet-2-rgb);
36
+ --bs-btn-hover-border-color: var(--ts-violet-2-rgb);
37
+ --bs-btn-active-bg: var(--ts-violet-2-rgb);
38
+ --bs-btn-active-border-bg: var(--ts-violet-2-rgb);
39
+ --bs-btn-disabled-bg: var(--ts-violet-1-rgb);
40
+ --bs-btn-disabled-border-color: var(--ts-violet-1-rgb);
41
+ }
42
+ .btn-outline-primary {
43
+ --bs-btn-color: var(--ts-violet-1-rgb);
44
+ --bs-btn-border-color: var(--ts-violet-1-rgb);
45
+ --bs-btn-hover-bg: var(--ts-violet-1-rgb);
46
+ --bs-btn-hover-border-color: var(--ts-violet-1-rgb);
47
+ --bs-btn-active-bg: var(--ts-violet-1-rgb);
48
+ --bs-btn-active-border-color: var(--ts-violet-1-rgb);
49
+ --bs-btn-disabled-color: var(--ts-violet-1-rgb);
50
+ --bs-btn-disabled-border-color: var(--ts-violet-1-rgb);
51
+ }
52
+ .navbar-dark {
53
+ --bs-link-color: var(--ts-violet-dark-1-rgb);
54
+ --bs-link-hover-color: var(--ts-violet-dark-2-rgb);
55
+ --bs-navbar-color: var(--ts-white-rgb);
56
+ --bs-navbar-hover-color: var(--ts-white-rgb);
57
+ }
58
+ .nav-pills {
59
+ --bs-nav-pills-link-active-bg: var(--ts-violet-1-rgb);
60
+ }
static/webjars/timefold/img/timefold-favicon.svg ADDED
static/webjars/timefold/img/timefold-logo-horizontal-negative.svg ADDED
static/webjars/timefold/img/timefold-logo-horizontal-positive.svg ADDED
static/webjars/timefold/img/timefold-logo-stacked-positive.svg ADDED
static/webjars/timefold/js/timefold-webui.js ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ function replaceTimefoldAutoHeaderFooter() {
2
+ const timefoldHeader = $("header#timefold-auto-header");
3
+ if (timefoldHeader != null) {
4
+ timefoldHeader.addClass("bg-black")
5
+ timefoldHeader.append(
6
+ $(`<div class="container-fluid">
7
+ <nav class="navbar sticky-top navbar-expand-lg navbar-dark shadow mb-3">
8
+ <a class="navbar-brand" href="https://timefold.ai">
9
+ <img src="/timefold/img/timefold-logo-horizontal-negative.svg" alt="Timefold logo" width="200">
10
+ </a>
11
+ </nav>
12
+ </div>`));
13
+ }
14
+ const timefoldFooter = $("footer#timefold-auto-footer");
15
+ if (timefoldFooter != null) {
16
+ timefoldFooter.append(
17
+ $(`<footer class="bg-black text-white-50">
18
+ <div class="container">
19
+ <div class="hstack gap-3 p-4">
20
+ <div class="ms-auto"><a class="text-white" href="https://timefold.ai">Timefold</a></div>
21
+ <div class="vr"></div>
22
+ <div><a class="text-white" href="https://timefold.ai/docs">Documentation</a></div>
23
+ <div class="vr"></div>
24
+ <div><a class="text-white" href="https://github.com/TimefoldAI/timefold-solver-python">Code</a></div>
25
+ <div class="vr"></div>
26
+ <div class="me-auto"><a class="text-white" href="https://timefold.ai/product/support/">Support</a></div>
27
+ </div>
28
+ </div>
29
+ <div id="applicationInfo" class="container text-center"></div>
30
+ </footer>`));
31
+
32
+ applicationInfo();
33
+ }
34
+
35
+ }
36
+
37
+ function showSimpleError(title) {
38
+ const notification = $(`<div class="toast" role="alert" aria-live="assertive" aria-atomic="true" style="min-width: 50rem"/>`)
39
+ .append($(`<div class="toast-header bg-danger">
40
+ <strong class="me-auto text-dark">Error</strong>
41
+ <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
42
+ </div>`))
43
+ .append($(`<div class="toast-body"/>`)
44
+ .append($(`<p/>`).text(title))
45
+ );
46
+ $("#notificationPanel").append(notification);
47
+ notification.toast({delay: 30000});
48
+ notification.toast('show');
49
+ }
50
+
51
+ function showError(title, xhr) {
52
+ var serverErrorMessage = !xhr.responseJSON ? `${xhr.status}: ${xhr.statusText}` : xhr.responseJSON.message;
53
+ var serverErrorCode = !xhr.responseJSON ? `unknown` : xhr.responseJSON.code;
54
+ var serverErrorId = !xhr.responseJSON ? `----` : xhr.responseJSON.id;
55
+ var serverErrorDetails = !xhr.responseJSON ? `no details provided` : xhr.responseJSON.details;
56
+
57
+ if (xhr.responseJSON && !serverErrorMessage) {
58
+ serverErrorMessage = JSON.stringify(xhr.responseJSON);
59
+ serverErrorCode = xhr.statusText + '(' + xhr.status + ')';
60
+ serverErrorId = `----`;
61
+ }
62
+
63
+ console.error(title + "\n" + serverErrorMessage + " : " + serverErrorDetails);
64
+ const notification = $(`<div class="toast" role="alert" aria-live="assertive" aria-atomic="true" style="min-width: 50rem"/>`)
65
+ .append($(`<div class="toast-header bg-danger">
66
+ <strong class="me-auto text-dark">Error</strong>
67
+ <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
68
+ </div>`))
69
+ .append($(`<div class="toast-body"/>`)
70
+ .append($(`<p/>`).text(title))
71
+ .append($(`<pre/>`)
72
+ .append($(`<code/>`).text(serverErrorMessage + "\n\nCode: " + serverErrorCode + "\nError id: " + serverErrorId))
73
+ )
74
+ );
75
+ $("#notificationPanel").append(notification);
76
+ notification.toast({delay: 30000});
77
+ notification.toast('show');
78
+ }
79
+
80
+ // ****************************************************************************
81
+ // Application info
82
+ // ****************************************************************************
83
+
84
+ function applicationInfo() {
85
+ $.getJSON("info", function (info) {
86
+ $("#applicationInfo").append("<small>" + info.application + " (version: " + info.version + ", built at: " + info.built + ")</small>");
87
+ }).fail(function (xhr, ajaxOptions, thrownError) {
88
+ console.warn("Unable to collect application information");
89
+ });
90
+ }
91
+
92
+ // ****************************************************************************
93
+ // TangoColorFactory
94
+ // ****************************************************************************
95
+
96
+ const SEQUENCE_1 = [0x8AE234, 0xFCE94F, 0x729FCF, 0xE9B96E, 0xAD7FA8];
97
+ const SEQUENCE_2 = [0x73D216, 0xEDD400, 0x3465A4, 0xC17D11, 0x75507B];
98
+
99
+ var colorMap = new Map;
100
+ var nextColorCount = 0;
101
+
102
+ function pickColor(object) {
103
+ let color = colorMap[object];
104
+ if (color !== undefined) {
105
+ return color;
106
+ }
107
+ color = nextColor();
108
+ colorMap[object] = color;
109
+ return color;
110
+ }
111
+
112
+ function nextColor() {
113
+ let color;
114
+ let colorIndex = nextColorCount % SEQUENCE_1.length;
115
+ let shadeIndex = Math.floor(nextColorCount / SEQUENCE_1.length);
116
+ if (shadeIndex === 0) {
117
+ color = SEQUENCE_1[colorIndex];
118
+ } else if (shadeIndex === 1) {
119
+ color = SEQUENCE_2[colorIndex];
120
+ } else {
121
+ shadeIndex -= 3;
122
+ let floorColor = SEQUENCE_2[colorIndex];
123
+ let ceilColor = SEQUENCE_1[colorIndex];
124
+ let base = Math.floor((shadeIndex / 2) + 1);
125
+ let divisor = 2;
126
+ while (base >= divisor) {
127
+ divisor *= 2;
128
+ }
129
+ base = (base * 2) - divisor + 1;
130
+ let shadePercentage = base / divisor;
131
+ color = buildPercentageColor(floorColor, ceilColor, shadePercentage);
132
+ }
133
+ nextColorCount++;
134
+ return "#" + color.toString(16);
135
+ }
136
+
137
+ function buildPercentageColor(floorColor, ceilColor, shadePercentage) {
138
+ let red = (floorColor & 0xFF0000) + Math.floor(shadePercentage * ((ceilColor & 0xFF0000) - (floorColor & 0xFF0000))) & 0xFF0000;
139
+ let green = (floorColor & 0x00FF00) + Math.floor(shadePercentage * ((ceilColor & 0x00FF00) - (floorColor & 0x00FF00))) & 0x00FF00;
140
+ let blue = (floorColor & 0x0000FF) + Math.floor(shadePercentage * ((ceilColor & 0x0000FF) - (floorColor & 0x0000FF))) & 0x0000FF;
141
+ return red | green | blue;
142
+ }
tests/test_constraints.py CHANGED
@@ -1,11 +1,29 @@
 
 
 
 
 
1
  from solverforge_legacy.solver.test import ConstraintVerifier
2
 
3
- from employee_scheduling.domain import *
4
- from employee_scheduling.constraints import *
 
 
 
 
 
 
 
 
 
 
5
 
6
  from datetime import date, datetime, time, timedelta
 
7
 
 
8
  DAY_1 = date(2021, 2, 1)
 
9
  DAY_3 = date(2021, 2, 3)
10
  DAY_START_TIME = datetime.combine(DAY_1, time(9, 0))
11
  DAY_END_TIME = datetime.combine(DAY_1, time(17, 0))
@@ -17,708 +35,603 @@ constraint_verifier = ConstraintVerifier.build(
17
  )
18
 
19
 
20
- def test_required_skill():
21
- employee = Employee(name="Amy")
22
- (
23
- constraint_verifier.verify_that(required_skill)
24
- .given(
25
- employee,
26
- Shift(
27
- id="1",
28
- start=DAY_START_TIME,
29
- end=DAY_END_TIME,
30
- location="Location",
31
- required_skill="Skill",
32
- employee=employee,
33
- ),
34
- )
35
- .penalizes(1)
36
- )
37
-
38
- employee = Employee(name="Beth", skills={"Skill"})
39
- (
40
- constraint_verifier.verify_that(required_skill)
41
- .given(
42
- employee,
43
- Shift(
44
- id="2",
45
- start=DAY_START_TIME,
46
- end=DAY_END_TIME,
47
- location="Location",
48
- required_skill="Skill",
49
- employee=employee,
50
- ),
51
- )
52
- .penalizes(0)
53
- )
54
-
55
-
56
- def test_overlapping_shifts():
57
- employee1 = Employee(name="Amy")
58
- employee2 = Employee(name="Beth")
59
- (
60
- constraint_verifier.verify_that(no_overlapping_shifts)
61
- .given(
62
- employee1,
63
- employee2,
64
- Shift(
65
- id="1",
66
- start=DAY_START_TIME,
67
- end=DAY_END_TIME,
68
- location="Location",
69
- required_skill="Skill",
70
- employee=employee1,
71
- ),
72
- Shift(
73
- id="2",
74
- start=DAY_START_TIME,
75
- end=DAY_END_TIME,
76
- location="Location 2",
77
- required_skill="Skill",
78
- employee=employee1,
79
- ),
80
- )
81
- .penalizes_by(timedelta(hours=8) // timedelta(minutes=1))
82
- )
83
-
84
- (
85
- constraint_verifier.verify_that(no_overlapping_shifts)
86
- .given(
87
- employee1,
88
- employee2,
89
- Shift(
90
- id="1",
91
- start=DAY_START_TIME,
92
- end=DAY_END_TIME,
93
- location="Location",
94
- required_skill="Skill",
95
- employee=employee1,
96
- ),
97
- Shift(
98
- id="2",
99
- start=DAY_START_TIME,
100
- end=DAY_END_TIME,
101
- location="Location 2",
102
- required_skill="Skill",
103
- employee=employee2,
104
- ),
105
- )
106
- .penalizes(0)
107
- )
108
-
109
- (
110
- constraint_verifier.verify_that(no_overlapping_shifts)
111
- .given(
112
- employee1,
113
- employee2,
114
- Shift(
115
- id="1",
116
- start=DAY_START_TIME,
117
- end=DAY_END_TIME,
118
- location="Location",
119
- required_skill="Skill",
120
- employee=employee1,
121
- ),
122
- Shift(
123
- id="2",
124
- start=AFTERNOON_START_TIME,
125
- end=AFTERNOON_END_TIME,
126
- location="Location 2",
127
- required_skill="Skill",
128
- employee=employee1,
129
- ),
130
- )
131
- .penalizes_by(timedelta(hours=4) // timedelta(minutes=1))
132
- )
133
-
134
-
135
- def test_one_shift_per_day():
136
- employee1 = Employee(name="Amy")
137
- employee2 = Employee(name="Beth")
138
- (
139
- constraint_verifier.verify_that(no_overlapping_shifts)
140
- .given(
141
- employee1,
142
- employee2,
143
- Shift(
144
- id="1",
145
- start=DAY_START_TIME,
146
- end=DAY_END_TIME,
147
- location="Location",
148
- required_skill="Skill",
149
- employee=employee1,
150
- ),
151
- Shift(
152
- id="2",
153
- start=DAY_START_TIME,
154
- end=DAY_END_TIME,
155
- location="Location 2",
156
- required_skill="Skill",
157
- employee=employee1,
158
- ),
159
- )
160
- .penalizes(1)
161
- )
162
-
163
- (
164
- constraint_verifier.verify_that(no_overlapping_shifts)
165
- .given(
166
- employee1,
167
- employee2,
168
- Shift(
169
- id="1",
170
- start=DAY_START_TIME,
171
- end=DAY_END_TIME,
172
- location="Location",
173
- required_skill="Skill",
174
- employee=employee1,
175
- ),
176
- Shift(
177
- id="2",
178
- start=DAY_START_TIME,
179
- end=DAY_END_TIME,
180
- location="Location 2",
181
- required_skill="Skill",
182
- employee=employee2,
183
- ),
184
- )
185
- .penalizes(0)
186
- )
187
-
188
- (
189
- constraint_verifier.verify_that(no_overlapping_shifts)
190
- .given(
191
- employee1,
192
- employee2,
193
- Shift(
194
- id="1",
195
- start=DAY_START_TIME,
196
- end=DAY_END_TIME,
197
- location="Location",
198
- required_skill="Skill",
199
- employee=employee1,
200
- ),
201
- Shift(
202
- id="2",
203
- start=AFTERNOON_START_TIME,
204
- end=AFTERNOON_END_TIME,
205
- location="Location 2",
206
- required_skill="Skill",
207
- employee=employee1,
208
- ),
209
- )
210
- .penalizes(1)
211
- )
212
-
213
- (
214
- constraint_verifier.verify_that(no_overlapping_shifts)
215
- .given(
216
- employee1,
217
- employee2,
218
- Shift(
219
- id="1",
220
- start=DAY_START_TIME,
221
- end=DAY_END_TIME,
222
- location="Location",
223
- required_skill="Skill",
224
- employee=employee1,
225
- ),
226
- Shift(
227
- id="2",
228
- start=DAY_START_TIME + timedelta(days=1),
229
- end=DAY_END_TIME + timedelta(days=1),
230
- location="Location 2",
231
- required_skill="Skill",
232
- employee=employee1,
233
- ),
234
- )
235
- .penalizes(0)
236
- )
237
-
238
-
239
- def test_at_least_10_hours_between_shifts():
240
- employee1 = Employee(name="Amy")
241
- employee2 = Employee(name="Beth")
242
-
243
- (
244
- constraint_verifier.verify_that(at_least_10_hours_between_two_shifts)
245
- .given(
246
- employee1,
247
- employee2,
248
- Shift(
249
- id="1",
250
- start=DAY_START_TIME,
251
- end=DAY_END_TIME,
252
- location="Location",
253
- required_skill="Skill",
254
- employee=employee1,
255
- ),
256
- Shift(
257
- id="2",
258
- start=AFTERNOON_END_TIME,
259
- end=DAY_START_TIME + timedelta(days=1),
260
- location="Location 2",
261
- required_skill="Skill",
262
- employee=employee1,
263
- ),
264
- )
265
- .penalizes_by(360)
266
- )
267
-
268
- (
269
- constraint_verifier.verify_that(at_least_10_hours_between_two_shifts)
270
- .given(
271
- employee1,
272
- employee2,
273
- Shift(
274
- id="1",
275
- start=DAY_START_TIME,
276
- end=DAY_END_TIME,
277
- location="Location",
278
- required_skill="Skill",
279
- employee=employee1,
280
- ),
281
- Shift(
282
- id="2",
283
- start=DAY_END_TIME,
284
- end=DAY_START_TIME + timedelta(days=1),
285
- location="Location 2",
286
- required_skill="Skill",
287
- employee=employee1,
288
- ),
289
- )
290
- .penalizes_by(600)
291
- )
292
-
293
- (
294
- constraint_verifier.verify_that(at_least_10_hours_between_two_shifts)
295
- .given(
296
- employee1,
297
- employee2,
298
- Shift(
299
- id="1",
300
- start=DAY_END_TIME,
301
- end=DAY_START_TIME + timedelta(days=1),
302
- location="Location",
303
- required_skill="Skill",
304
- employee=employee1,
305
- ),
306
- Shift(
307
- id="2",
308
- start=DAY_START_TIME,
309
- end=DAY_END_TIME,
310
- location="Location 2",
311
- required_skill="Skill",
312
- employee=employee1,
313
- ),
314
- )
315
- .penalizes_by(600)
316
- )
317
-
318
- (
319
- constraint_verifier.verify_that(at_least_10_hours_between_two_shifts)
320
- .given(
321
- employee1,
322
- employee2,
323
- Shift(
324
- id="1",
325
- start=DAY_START_TIME,
326
- end=DAY_END_TIME,
327
- location="Location",
328
- required_skill="Skill",
329
- employee=employee1,
330
- ),
331
- Shift(
332
- id="2",
333
- start=DAY_END_TIME + timedelta(hours=10),
334
- end=DAY_START_TIME + timedelta(days=1),
335
- location="Location 2",
336
- required_skill="Skill",
337
- employee=employee1,
338
- ),
339
- )
340
- .penalizes(0)
341
- )
342
-
343
- (
344
- constraint_verifier.verify_that(at_least_10_hours_between_two_shifts)
345
- .given(
346
- employee1,
347
- employee2,
348
- Shift(
349
- id="1",
350
- start=DAY_START_TIME,
351
- end=DAY_END_TIME,
352
- location="Location",
353
- required_skill="Skill",
354
- employee=employee1,
355
- ),
356
- Shift(
357
- id="2",
358
- start=AFTERNOON_END_TIME,
359
- end=DAY_START_TIME + timedelta(days=1),
360
- location="Location 2",
361
- required_skill="Skill",
362
- employee=employee2,
363
- ),
364
- )
365
- .penalizes(0)
366
- )
367
-
368
- (
369
- constraint_verifier.verify_that(no_overlapping_shifts)
370
- .given(
371
- employee1,
372
- employee2,
373
- Shift(
374
- id="1",
375
- start=DAY_START_TIME,
376
- end=DAY_END_TIME,
377
- location="Location",
378
- required_skill="Skill",
379
- employee=employee1,
380
- ),
381
- Shift(
382
- id="2",
383
- start=DAY_START_TIME + timedelta(days=1),
384
- end=DAY_END_TIME + timedelta(days=1),
385
- location="Location 2",
386
- required_skill="Skill",
387
- employee=employee1,
388
- ),
389
- )
390
- .penalizes(0)
391
- )
392
-
393
-
394
- def test_unavailable_employee():
395
- employee1 = Employee(name="Amy", unavailable_dates={DAY_1, DAY_3})
396
- employee2 = Employee(name="Beth")
397
-
398
- (
399
- constraint_verifier.verify_that(unavailable_employee)
400
- .given(
401
- employee1,
402
- employee2,
403
- Shift(
404
- id="1",
405
- start=DAY_START_TIME,
406
- end=DAY_END_TIME,
407
- location="Location",
408
- required_skill="Skill",
409
- employee=employee1,
410
- ),
411
- )
412
- .penalizes_by(timedelta(hours=8) // timedelta(minutes=1))
413
- )
414
-
415
- (
416
- constraint_verifier.verify_that(unavailable_employee)
417
- .given(
418
- employee1,
419
- employee2,
420
- Shift(
421
- id="1",
422
- start=DAY_START_TIME - timedelta(days=1),
423
- end=DAY_END_TIME,
424
- location="Location",
425
- required_skill="Skill",
426
- employee=employee1,
427
- ),
428
- )
429
- .penalizes_by(timedelta(hours=17) // timedelta(minutes=1))
430
- )
431
-
432
- (
433
- constraint_verifier.verify_that(unavailable_employee)
434
- .given(
435
- employee1,
436
- employee2,
437
- Shift(
438
- id="1",
439
- start=DAY_START_TIME + timedelta(days=1),
440
- end=DAY_END_TIME + timedelta(days=1),
441
- location="Location",
442
- required_skill="Skill",
443
- employee=employee1,
444
- ),
445
- )
446
- .penalizes(0)
447
- )
448
-
449
- (
450
- constraint_verifier.verify_that(unavailable_employee)
451
- .given(
452
- employee1,
453
- employee2,
454
- Shift(
455
- id="1",
456
- start=DAY_START_TIME,
457
- end=DAY_END_TIME,
458
- location="Location",
459
- required_skill="Skill",
460
- employee=employee2,
461
- ),
462
- )
463
- .penalizes(0)
464
- )
465
-
466
-
467
- def test_undesired_day_for_employee():
468
- employee1 = Employee(name="Amy", undesired_dates={DAY_1, DAY_3})
469
- employee2 = Employee(name="Beth")
470
-
471
- (
472
- constraint_verifier.verify_that(undesired_day_for_employee)
473
- .given(
474
- employee1,
475
- employee2,
476
- Shift(
477
- id="1",
478
- start=DAY_START_TIME,
479
- end=DAY_END_TIME,
480
- location="Location",
481
- required_skill="Skill",
482
- employee=employee1,
483
- ),
484
- )
485
- .penalizes_by(timedelta(hours=8) // timedelta(minutes=1))
486
- )
487
-
488
- (
489
- constraint_verifier.verify_that(undesired_day_for_employee)
490
- .given(
491
- employee1,
492
- employee2,
493
- Shift(
494
- id="1",
495
- start=DAY_START_TIME - timedelta(days=1),
496
- end=DAY_END_TIME,
497
- location="Location",
498
- required_skill="Skill",
499
- employee=employee1,
500
- ),
501
- )
502
- .penalizes_by(timedelta(hours=17) // timedelta(minutes=1))
503
- )
504
-
505
- (
506
- constraint_verifier.verify_that(undesired_day_for_employee)
507
- .given(
508
- employee1,
509
- employee2,
510
- Shift(
511
- id="1",
512
- start=DAY_START_TIME + timedelta(days=1),
513
- end=DAY_END_TIME + timedelta(days=1),
514
- location="Location",
515
- required_skill="Skill",
516
- employee=employee1,
517
- ),
518
- )
519
- .penalizes(0)
520
- )
521
-
522
- (
523
- constraint_verifier.verify_that(undesired_day_for_employee)
524
- .given(
525
- employee1,
526
- employee2,
527
- Shift(
528
- id="1",
529
- start=DAY_START_TIME,
530
- end=DAY_END_TIME,
531
- location="Location",
532
- required_skill="Skill",
533
- employee=employee2,
534
- ),
535
- )
536
- .penalizes(0)
537
- )
538
-
539
-
540
- def test_desired_day_for_employee():
541
- employee1 = Employee(name="Amy", desired_dates={DAY_1, DAY_3})
542
- employee2 = Employee(name="Beth")
543
-
544
- (
545
- constraint_verifier.verify_that(desired_day_for_employee)
546
- .given(
547
- employee1,
548
- employee2,
549
- Shift(
550
- id="1",
551
- start=DAY_START_TIME,
552
- end=DAY_END_TIME,
553
- location="Location",
554
- required_skill="Skill",
555
- employee=employee1,
556
- ),
557
- )
558
- .rewards_with(timedelta(hours=8) // timedelta(minutes=1))
559
- )
560
-
561
- (
562
- constraint_verifier.verify_that(desired_day_for_employee)
563
- .given(
564
- employee1,
565
- employee2,
566
- Shift(
567
- id="1",
568
- start=DAY_START_TIME - timedelta(days=1),
569
- end=DAY_END_TIME,
570
- location="Location",
571
- required_skill="Skill",
572
- employee=employee1,
573
- ),
574
- )
575
- .rewards_with(timedelta(hours=17) // timedelta(minutes=1))
576
- )
577
-
578
- (
579
- constraint_verifier.verify_that(desired_day_for_employee)
580
- .given(
581
- employee1,
582
- employee2,
583
- Shift(
584
- id="1",
585
- start=DAY_START_TIME + timedelta(days=1),
586
- end=DAY_END_TIME + timedelta(days=1),
587
- location="Location",
588
- required_skill="Skill",
589
- employee=employee1,
590
- ),
591
- )
592
- .rewards(0)
593
- )
594
-
595
- (
596
- constraint_verifier.verify_that(desired_day_for_employee)
597
- .given(
598
- employee1,
599
- employee2,
600
- Shift(
601
- id="1",
602
- start=DAY_START_TIME,
603
- end=DAY_END_TIME,
604
- location="Location",
605
- required_skill="Skill",
606
- employee=employee2,
607
- ),
608
- )
609
- .rewards(0)
610
- )
611
-
612
-
613
- def test_max_shifts_per_employee():
614
- employee = Employee(name="Amy")
615
-
616
- # 12 shifts: no violation (at the limit)
617
- shifts_12 = [
618
- Shift(
619
- id=str(i),
620
- start=DAY_START_TIME + timedelta(days=i),
621
- end=DAY_END_TIME + timedelta(days=i),
622
  location="Location",
623
  required_skill="Skill",
624
  employee=employee,
625
  )
626
- for i in range(12)
627
- ]
628
- (
629
- constraint_verifier.verify_that(max_shifts_per_employee)
630
- .given(employee, *shifts_12)
631
- .penalizes(0)
632
- )
633
-
634
- # 13 shifts: penalty of 1 (13 - 12 = 1)
635
- shifts_13 = [
636
- Shift(
637
- id=str(i),
638
- start=DAY_START_TIME + timedelta(days=i),
639
- end=DAY_END_TIME + timedelta(days=i),
640
  location="Location",
641
  required_skill="Skill",
642
  employee=employee,
643
  )
644
- for i in range(13)
645
- ]
646
- (
647
- constraint_verifier.verify_that(max_shifts_per_employee)
648
- .given(employee, *shifts_13)
649
- .penalizes_by(1)
650
- )
651
-
652
- # 15 shifts: penalty of 3 (15 - 12 = 3)
653
- shifts_15 = [
654
- Shift(
655
- id=str(i),
656
- start=DAY_START_TIME + timedelta(days=i),
657
- end=DAY_END_TIME + timedelta(days=i),
658
  location="Location",
659
  required_skill="Skill",
660
  employee=employee,
661
  )
662
- for i in range(15)
663
- ]
664
- (
665
- constraint_verifier.verify_that(max_shifts_per_employee)
666
- .given(employee, *shifts_15)
667
- .penalizes_by(3)
668
- )
669
-
670
-
671
- def test_balance_employee_shift_assignments():
672
- employee1 = Employee(name="Amy", desired_dates={DAY_1, DAY_3})
673
- employee2 = Employee(name="Beth")
674
-
675
- # No employees have shifts assigned; the schedule is perfectly balanced.
676
- (
677
- constraint_verifier.verify_that(balance_employee_shift_assignments)
678
- .given(employee1, employee2)
679
- .penalizes_by(0)
680
- )
681
-
682
- # Only one employee has shifts assigned; the schedule is less balanced.
683
- (
684
- constraint_verifier.verify_that(balance_employee_shift_assignments)
685
- .given(
686
- employee1,
687
- employee2,
688
- Shift(
689
- id="1",
690
- start=DAY_START_TIME,
691
- end=DAY_END_TIME,
692
- location="Location",
693
- required_skill="Skill",
694
- employee=employee1,
695
- ),
696
- )
697
- .penalizes_by_more_than(0)
698
- )
699
-
700
- # Every employee has a shift assigned; the schedule is once again perfectly balanced.
701
- (
702
- constraint_verifier.verify_that(balance_employee_shift_assignments)
703
- .given(
704
- employee1,
705
- employee2,
706
- Shift(
707
- id="1",
708
- start=DAY_START_TIME,
709
- end=DAY_END_TIME,
710
- location="Location",
711
- required_skill="Skill",
712
- employee=employee1,
713
- ),
714
- Shift(
715
- id="2",
716
- start=DAY_START_TIME,
717
- end=DAY_END_TIME,
718
- location="Location",
719
- required_skill="Skill",
720
- employee=employee2,
721
- ),
722
- )
723
- .penalizes_by(0)
724
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Constraint tests for the employee scheduling quickstart.
3
+
4
+ Each constraint is tested with both penalizing and non-penalizing scenarios.
5
+ """
6
  from solverforge_legacy.solver.test import ConstraintVerifier
7
 
8
+ from employee_scheduling.domain import Employee, Shift, EmployeeSchedule
9
+ from employee_scheduling.constraints import (
10
+ define_constraints,
11
+ required_skill,
12
+ no_overlapping_shifts,
13
+ at_least_10_hours_between_two_shifts,
14
+ one_shift_per_day,
15
+ unavailable_employee,
16
+ undesired_day_for_employee,
17
+ desired_day_for_employee,
18
+ balance_employee_shift_assignments,
19
+ )
20
 
21
  from datetime import date, datetime, time, timedelta
22
+ import pytest
23
 
24
+ # Test constants
25
  DAY_1 = date(2021, 2, 1)
26
+ DAY_2 = date(2021, 2, 2)
27
  DAY_3 = date(2021, 2, 3)
28
  DAY_START_TIME = datetime.combine(DAY_1, time(9, 0))
29
  DAY_END_TIME = datetime.combine(DAY_1, time(17, 0))
 
35
  )
36
 
37
 
38
+ # ========================================
39
+ # Required Skill Tests
40
+ # ========================================
41
+
42
+ class TestRequiredSkill:
43
+ """Tests for the required_skill constraint."""
44
+
45
+ def test_penalized_when_employee_lacks_skill(self):
46
+ """Employee without required skill should be penalized."""
47
+ employee = Employee(name="Amy") # No skills
48
+ shift = Shift(
49
+ id="1",
50
+ start=DAY_START_TIME,
51
+ end=DAY_END_TIME,
52
+ location="Location",
53
+ required_skill="Driving",
54
+ employee=employee,
55
+ )
56
+ constraint_verifier.verify_that(required_skill).given(
57
+ employee, shift
58
+ ).penalizes(1)
59
+
60
+ def test_not_penalized_when_employee_has_skill(self):
61
+ """Employee with required skill should not be penalized."""
62
+ employee = Employee(name="Amy", skills={"Driving"})
63
+ shift = Shift(
64
+ id="1",
65
+ start=DAY_START_TIME,
66
+ end=DAY_END_TIME,
67
+ location="Location",
68
+ required_skill="Driving",
69
+ employee=employee,
70
+ )
71
+ constraint_verifier.verify_that(required_skill).given(
72
+ employee, shift
73
+ ).penalizes(0)
74
+
75
+ def test_not_penalized_when_employee_has_multiple_skills(self):
76
+ """Employee with multiple skills including required should not be penalized."""
77
+ employee = Employee(name="Amy", skills={"Driving", "First Aid", "Cooking"})
78
+ shift = Shift(
79
+ id="1",
80
+ start=DAY_START_TIME,
81
+ end=DAY_END_TIME,
82
+ location="Location",
83
+ required_skill="First Aid",
84
+ employee=employee,
85
+ )
86
+ constraint_verifier.verify_that(required_skill).given(
87
+ employee, shift
88
+ ).penalizes(0)
89
+
90
+ def test_penalized_when_employee_has_different_skills(self):
91
+ """Employee with skills but not the required one should be penalized."""
92
+ employee = Employee(name="Amy", skills={"Cooking", "Cleaning"})
93
+ shift = Shift(
94
+ id="1",
95
+ start=DAY_START_TIME,
96
+ end=DAY_END_TIME,
97
+ location="Location",
98
+ required_skill="Driving",
99
+ employee=employee,
100
+ )
101
+ constraint_verifier.verify_that(required_skill).given(
102
+ employee, shift
103
+ ).penalizes(1)
104
+
105
+
106
+ # ========================================
107
+ # Overlapping Shifts Tests
108
+ # ========================================
109
+
110
+ class TestNoOverlappingShifts:
111
+ """Tests for the no_overlapping_shifts constraint."""
112
+
113
+ def test_penalized_when_shifts_fully_overlap(self):
114
+ """Same employee with fully overlapping shifts should be penalized."""
115
+ employee = Employee(name="Amy")
116
+ shift1 = Shift(
117
+ id="1",
118
+ start=DAY_START_TIME,
119
+ end=DAY_END_TIME,
120
+ location="Location A",
121
+ required_skill="Skill",
122
+ employee=employee,
123
+ )
124
+ shift2 = Shift(
125
+ id="2",
126
+ start=DAY_START_TIME,
127
+ end=DAY_END_TIME,
128
+ location="Location B",
129
+ required_skill="Skill",
130
+ employee=employee,
131
+ )
132
+ # 8 hours overlap = 480 minutes
133
+ constraint_verifier.verify_that(no_overlapping_shifts).given(
134
+ employee, shift1, shift2
135
+ ).penalizes_by(480)
136
+
137
+ def test_penalized_when_shifts_partially_overlap(self):
138
+ """Same employee with partially overlapping shifts should be penalized by overlap duration."""
139
+ employee = Employee(name="Amy")
140
+ shift1 = Shift(
141
+ id="1",
142
+ start=DAY_START_TIME, # 9:00
143
+ end=DAY_END_TIME, # 17:00
144
+ location="Location A",
145
+ required_skill="Skill",
146
+ employee=employee,
147
+ )
148
+ shift2 = Shift(
149
+ id="2",
150
+ start=AFTERNOON_START_TIME, # 13:00
151
+ end=AFTERNOON_END_TIME, # 21:00
152
+ location="Location B",
153
+ required_skill="Skill",
154
+ employee=employee,
155
+ )
156
+ # Overlap from 13:00 to 17:00 = 4 hours = 240 minutes
157
+ constraint_verifier.verify_that(no_overlapping_shifts).given(
158
+ employee, shift1, shift2
159
+ ).penalizes_by(240)
160
+
161
+ def test_not_penalized_when_different_employees(self):
162
+ """Different employees with overlapping shifts should not be penalized."""
163
+ employee1 = Employee(name="Amy")
164
+ employee2 = Employee(name="Beth")
165
+ shift1 = Shift(
166
+ id="1",
167
+ start=DAY_START_TIME,
168
+ end=DAY_END_TIME,
169
+ location="Location A",
170
+ required_skill="Skill",
171
+ employee=employee1,
172
+ )
173
+ shift2 = Shift(
174
+ id="2",
175
+ start=DAY_START_TIME,
176
+ end=DAY_END_TIME,
177
+ location="Location B",
178
+ required_skill="Skill",
179
+ employee=employee2,
180
+ )
181
+ constraint_verifier.verify_that(no_overlapping_shifts).given(
182
+ employee1, employee2, shift1, shift2
183
+ ).penalizes(0)
184
+
185
+ def test_not_penalized_when_shifts_dont_overlap(self):
186
+ """Same employee with non-overlapping shifts should not be penalized."""
187
+ employee = Employee(name="Amy")
188
+ shift1 = Shift(
189
+ id="1",
190
+ start=DAY_START_TIME,
191
+ end=DAY_END_TIME,
192
+ location="Location A",
193
+ required_skill="Skill",
194
+ employee=employee,
195
+ )
196
+ shift2 = Shift(
197
+ id="2",
198
+ start=DAY_START_TIME + timedelta(days=1),
199
+ end=DAY_END_TIME + timedelta(days=1),
200
+ location="Location B",
201
+ required_skill="Skill",
202
+ employee=employee,
203
+ )
204
+ constraint_verifier.verify_that(no_overlapping_shifts).given(
205
+ employee, shift1, shift2
206
+ ).penalizes(0)
207
+
208
+
209
+ # ========================================
210
+ # One Shift Per Day Tests
211
+ # ========================================
212
+
213
+ class TestOneShiftPerDay:
214
+ """Tests for the one_shift_per_day constraint."""
215
+
216
+ def test_penalized_when_two_shifts_same_day(self):
217
+ """Employee with two shifts on same day should be penalized."""
218
+ employee = Employee(name="Amy")
219
+ shift1 = Shift(
220
+ id="1",
221
+ start=datetime.combine(DAY_1, time(6, 0)),
222
+ end=datetime.combine(DAY_1, time(10, 0)),
223
+ location="Location A",
224
+ required_skill="Skill",
225
+ employee=employee,
226
+ )
227
+ shift2 = Shift(
228
+ id="2",
229
+ start=datetime.combine(DAY_1, time(18, 0)),
230
+ end=datetime.combine(DAY_1, time(22, 0)),
231
+ location="Location B",
232
+ required_skill="Skill",
233
+ employee=employee,
234
+ )
235
+ constraint_verifier.verify_that(one_shift_per_day).given(
236
+ employee, shift1, shift2
237
+ ).penalizes(1)
238
+
239
+ def test_not_penalized_when_shifts_different_days(self):
240
+ """Employee with shifts on different days should not be penalized."""
241
+ employee = Employee(name="Amy")
242
+ shift1 = Shift(
243
+ id="1",
244
+ start=DAY_START_TIME,
245
+ end=DAY_END_TIME,
246
+ location="Location A",
247
+ required_skill="Skill",
248
+ employee=employee,
249
+ )
250
+ shift2 = Shift(
251
+ id="2",
252
+ start=DAY_START_TIME + timedelta(days=1),
253
+ end=DAY_END_TIME + timedelta(days=1),
254
+ location="Location B",
255
+ required_skill="Skill",
256
+ employee=employee,
257
+ )
258
+ constraint_verifier.verify_that(one_shift_per_day).given(
259
+ employee, shift1, shift2
260
+ ).penalizes(0)
261
+
262
+ def test_not_penalized_when_different_employees_same_day(self):
263
+ """Different employees with shifts on same day should not be penalized."""
264
+ employee1 = Employee(name="Amy")
265
+ employee2 = Employee(name="Beth")
266
+ shift1 = Shift(
267
+ id="1",
268
+ start=DAY_START_TIME,
269
+ end=DAY_END_TIME,
270
+ location="Location A",
271
+ required_skill="Skill",
272
+ employee=employee1,
273
+ )
274
+ shift2 = Shift(
275
+ id="2",
276
+ start=DAY_START_TIME,
277
+ end=DAY_END_TIME,
278
+ location="Location B",
279
+ required_skill="Skill",
280
+ employee=employee2,
281
+ )
282
+ constraint_verifier.verify_that(one_shift_per_day).given(
283
+ employee1, employee2, shift1, shift2
284
+ ).penalizes(0)
285
+
286
+
287
+ # ========================================
288
+ # 10 Hours Between Shifts Tests
289
+ # ========================================
290
+
291
+ class TestAtLeast10HoursBetweenShifts:
292
+ """Tests for the at_least_10_hours_between_two_shifts constraint."""
293
+
294
+ def test_penalized_when_less_than_10_hours_gap(self):
295
+ """Employee with less than 10 hours between shifts should be penalized."""
296
+ employee = Employee(name="Amy")
297
+ shift1 = Shift(
298
+ id="1",
299
+ start=DAY_START_TIME, # 9:00
300
+ end=DAY_END_TIME, # 17:00
301
+ location="Location A",
302
+ required_skill="Skill",
303
+ employee=employee,
304
+ )
305
+ shift2 = Shift(
306
+ id="2",
307
+ start=AFTERNOON_END_TIME, # 21:00 (4 hours after shift1 ends)
308
+ end=DAY_START_TIME + timedelta(days=1),
309
+ location="Location B",
310
+ required_skill="Skill",
311
+ employee=employee,
312
+ )
313
+ # Gap is 4 hours, need 10 hours, so 6 hours short = 360 minutes penalty
314
+ constraint_verifier.verify_that(at_least_10_hours_between_two_shifts).given(
315
+ employee, shift1, shift2
316
+ ).penalizes_by(360)
317
+
318
+ def test_penalized_when_no_gap(self):
319
+ """Back-to-back shifts should be penalized by full 600 minutes."""
320
+ employee = Employee(name="Amy")
321
+ shift1 = Shift(
322
+ id="1",
323
+ start=DAY_START_TIME,
324
+ end=DAY_END_TIME,
325
+ location="Location A",
326
+ required_skill="Skill",
327
+ employee=employee,
328
+ )
329
+ shift2 = Shift(
330
+ id="2",
331
+ start=DAY_END_TIME, # Starts exactly when shift1 ends
332
+ end=DAY_START_TIME + timedelta(days=1),
333
+ location="Location B",
334
+ required_skill="Skill",
335
+ employee=employee,
336
+ )
337
+ constraint_verifier.verify_that(at_least_10_hours_between_two_shifts).given(
338
+ employee, shift1, shift2
339
+ ).penalizes_by(600)
340
+
341
+ def test_not_penalized_when_exactly_10_hours_gap(self):
342
+ """Employee with exactly 10 hours between shifts should not be penalized."""
343
+ employee = Employee(name="Amy")
344
+ shift1 = Shift(
345
+ id="1",
346
+ start=DAY_START_TIME,
347
+ end=DAY_END_TIME, # 17:00
348
+ location="Location A",
349
+ required_skill="Skill",
350
+ employee=employee,
351
+ )
352
+ shift2 = Shift(
353
+ id="2",
354
+ start=DAY_END_TIME + timedelta(hours=10), # 03:00 next day
355
+ end=DAY_START_TIME + timedelta(days=1),
356
+ location="Location B",
357
+ required_skill="Skill",
358
+ employee=employee,
359
+ )
360
+ constraint_verifier.verify_that(at_least_10_hours_between_two_shifts).given(
361
+ employee, shift1, shift2
362
+ ).penalizes(0)
363
+
364
+ def test_not_penalized_when_different_employees(self):
365
+ """Different employees with back-to-back shifts should not be penalized."""
366
+ employee1 = Employee(name="Amy")
367
+ employee2 = Employee(name="Beth")
368
+ shift1 = Shift(
369
+ id="1",
370
+ start=DAY_START_TIME,
371
+ end=DAY_END_TIME,
372
+ location="Location A",
373
+ required_skill="Skill",
374
+ employee=employee1,
375
+ )
376
+ shift2 = Shift(
377
+ id="2",
378
+ start=AFTERNOON_END_TIME,
379
+ end=DAY_START_TIME + timedelta(days=1),
380
+ location="Location B",
381
+ required_skill="Skill",
382
+ employee=employee2,
383
+ )
384
+ constraint_verifier.verify_that(at_least_10_hours_between_two_shifts).given(
385
+ employee1, employee2, shift1, shift2
386
+ ).penalizes(0)
387
+
388
+
389
+ # ========================================
390
+ # Unavailable Employee Tests
391
+ # ========================================
392
+
393
+ class TestUnavailableEmployee:
394
+ """Tests for the unavailable_employee constraint."""
395
+
396
+ def test_penalized_when_shift_on_unavailable_day(self):
397
+ """Employee scheduled on unavailable day should be penalized by shift duration."""
398
+ employee = Employee(name="Amy", unavailable_dates={DAY_1})
399
+ shift = Shift(
400
+ id="1",
401
+ start=DAY_START_TIME, # DAY_1 at 9:00
402
+ end=DAY_END_TIME, # DAY_1 at 17:00
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
403
  location="Location",
404
  required_skill="Skill",
405
  employee=employee,
406
  )
407
+ # 8 hours = 480 minutes
408
+ constraint_verifier.verify_that(unavailable_employee).given(
409
+ employee, shift
410
+ ).penalizes_by(480)
411
+
412
+ def test_penalized_proportionally_for_multi_day_shift(self):
413
+ """Multi-day shift crossing unavailable day should be penalized proportionally."""
414
+ employee = Employee(name="Amy", unavailable_dates={DAY_1})
415
+ shift = Shift(
416
+ id="1",
417
+ start=DAY_START_TIME - timedelta(days=1), # Starts day before
418
+ end=DAY_END_TIME, # Ends on DAY_1
 
 
419
  location="Location",
420
  required_skill="Skill",
421
  employee=employee,
422
  )
423
+ # Overlap with DAY_1 is from midnight to 17:00 = 17 hours = 1020 minutes
424
+ constraint_verifier.verify_that(unavailable_employee).given(
425
+ employee, shift
426
+ ).penalizes_by(1020)
427
+
428
+ def test_not_penalized_when_shift_on_different_day(self):
429
+ """Employee scheduled on available day should not be penalized."""
430
+ employee = Employee(name="Amy", unavailable_dates={DAY_1})
431
+ shift = Shift(
432
+ id="1",
433
+ start=DAY_START_TIME + timedelta(days=1), # DAY_2
434
+ end=DAY_END_TIME + timedelta(days=1),
 
 
435
  location="Location",
436
  required_skill="Skill",
437
  employee=employee,
438
  )
439
+ constraint_verifier.verify_that(unavailable_employee).given(
440
+ employee, shift
441
+ ).penalizes(0)
442
+
443
+ def test_not_penalized_when_different_employee(self):
444
+ """Different employee (without unavailable dates) should not be penalized."""
445
+ employee1 = Employee(name="Amy", unavailable_dates={DAY_1})
446
+ employee2 = Employee(name="Beth") # No unavailable dates
447
+ shift = Shift(
448
+ id="1",
449
+ start=DAY_START_TIME,
450
+ end=DAY_END_TIME,
451
+ location="Location",
452
+ required_skill="Skill",
453
+ employee=employee2,
454
+ )
455
+ constraint_verifier.verify_that(unavailable_employee).given(
456
+ employee1, employee2, shift
457
+ ).penalizes(0)
458
+
459
+ def test_penalized_for_multiple_unavailable_days(self):
460
+ """Shift crossing multiple unavailable days should be penalized for both."""
461
+ employee = Employee(name="Amy", unavailable_dates={DAY_1, DAY_3})
462
+ shift = Shift(
463
+ id="1",
464
+ start=DAY_START_TIME,
465
+ end=DAY_END_TIME,
466
+ location="Location",
467
+ required_skill="Skill",
468
+ employee=employee,
469
+ )
470
+ # Only DAY_1 overlaps (DAY_3 is 2 days later)
471
+ constraint_verifier.verify_that(unavailable_employee).given(
472
+ employee, shift
473
+ ).penalizes_by(480)
474
+
475
+
476
+ # ========================================
477
+ # Undesired Day Tests
478
+ # ========================================
479
+
480
+ class TestUndesiredDayForEmployee:
481
+ """Tests for the undesired_day_for_employee constraint (soft)."""
482
+
483
+ def test_penalized_when_shift_on_undesired_day(self):
484
+ """Employee scheduled on undesired day should be penalized."""
485
+ employee = Employee(name="Amy", undesired_dates={DAY_1})
486
+ shift = Shift(
487
+ id="1",
488
+ start=DAY_START_TIME,
489
+ end=DAY_END_TIME,
490
+ location="Location",
491
+ required_skill="Skill",
492
+ employee=employee,
493
+ )
494
+ constraint_verifier.verify_that(undesired_day_for_employee).given(
495
+ employee, shift
496
+ ).penalizes_by(480)
497
+
498
+ def test_not_penalized_when_shift_on_different_day(self):
499
+ """Employee scheduled on non-undesired day should not be penalized."""
500
+ employee = Employee(name="Amy", undesired_dates={DAY_1})
501
+ shift = Shift(
502
+ id="1",
503
+ start=DAY_START_TIME + timedelta(days=1),
504
+ end=DAY_END_TIME + timedelta(days=1),
505
+ location="Location",
506
+ required_skill="Skill",
507
+ employee=employee,
508
+ )
509
+ constraint_verifier.verify_that(undesired_day_for_employee).given(
510
+ employee, shift
511
+ ).penalizes(0)
512
+
513
+ def test_not_penalized_when_different_employee(self):
514
+ """Different employee without undesired dates should not be penalized."""
515
+ employee1 = Employee(name="Amy", undesired_dates={DAY_1})
516
+ employee2 = Employee(name="Beth")
517
+ shift = Shift(
518
+ id="1",
519
+ start=DAY_START_TIME,
520
+ end=DAY_END_TIME,
521
+ location="Location",
522
+ required_skill="Skill",
523
+ employee=employee2,
524
+ )
525
+ constraint_verifier.verify_that(undesired_day_for_employee).given(
526
+ employee1, employee2, shift
527
+ ).penalizes(0)
528
+
529
+
530
+ # ========================================
531
+ # Desired Day Tests
532
+ # ========================================
533
+
534
+ class TestDesiredDayForEmployee:
535
+ """Tests for the desired_day_for_employee constraint (soft reward)."""
536
+
537
+ def test_rewarded_when_shift_on_desired_day(self):
538
+ """Employee scheduled on desired day should be rewarded."""
539
+ employee = Employee(name="Amy", desired_dates={DAY_1})
540
+ shift = Shift(
541
+ id="1",
542
+ start=DAY_START_TIME,
543
+ end=DAY_END_TIME,
544
+ location="Location",
545
+ required_skill="Skill",
546
+ employee=employee,
547
+ )
548
+ constraint_verifier.verify_that(desired_day_for_employee).given(
549
+ employee, shift
550
+ ).rewards_with(480)
551
+
552
+ def test_not_rewarded_when_shift_on_different_day(self):
553
+ """Employee scheduled on non-desired day should not be rewarded."""
554
+ employee = Employee(name="Amy", desired_dates={DAY_1})
555
+ shift = Shift(
556
+ id="1",
557
+ start=DAY_START_TIME + timedelta(days=1),
558
+ end=DAY_END_TIME + timedelta(days=1),
559
+ location="Location",
560
+ required_skill="Skill",
561
+ employee=employee,
562
+ )
563
+ constraint_verifier.verify_that(desired_day_for_employee).given(
564
+ employee, shift
565
+ ).rewards(0)
566
+
567
+ def test_not_rewarded_when_different_employee(self):
568
+ """Different employee without desired dates should not be rewarded."""
569
+ employee1 = Employee(name="Amy", desired_dates={DAY_1})
570
+ employee2 = Employee(name="Beth")
571
+ shift = Shift(
572
+ id="1",
573
+ start=DAY_START_TIME,
574
+ end=DAY_END_TIME,
575
+ location="Location",
576
+ required_skill="Skill",
577
+ employee=employee2,
578
+ )
579
+ constraint_verifier.verify_that(desired_day_for_employee).given(
580
+ employee1, employee2, shift
581
+ ).rewards(0)
582
+
583
+
584
+ # ========================================
585
+ # Balance Employee Shift Assignments Tests
586
+ # ========================================
587
+
588
+ class TestBalanceEmployeeShiftAssignments:
589
+ """Tests for the balance_employee_shift_assignments constraint."""
590
+
591
+ def test_no_penalty_when_no_shifts(self):
592
+ """No shifts assigned should have zero imbalance."""
593
+ employee1 = Employee(name="Amy")
594
+ employee2 = Employee(name="Beth")
595
+ constraint_verifier.verify_that(balance_employee_shift_assignments).given(
596
+ employee1, employee2
597
+ ).penalizes_by(0)
598
+
599
+ def test_penalized_when_unbalanced(self):
600
+ """Only one employee with shifts should be penalized (imbalanced)."""
601
+ employee1 = Employee(name="Amy")
602
+ employee2 = Employee(name="Beth")
603
+ shift = Shift(
604
+ id="1",
605
+ start=DAY_START_TIME,
606
+ end=DAY_END_TIME,
607
+ location="Location",
608
+ required_skill="Skill",
609
+ employee=employee1,
610
+ )
611
+ constraint_verifier.verify_that(balance_employee_shift_assignments).given(
612
+ employee1, employee2, shift
613
+ ).penalizes_by_more_than(0)
614
+
615
+ def test_no_penalty_when_balanced(self):
616
+ """Equal shifts per employee should have zero imbalance."""
617
+ employee1 = Employee(name="Amy")
618
+ employee2 = Employee(name="Beth")
619
+ shift1 = Shift(
620
+ id="1",
621
+ start=DAY_START_TIME,
622
+ end=DAY_END_TIME,
623
+ location="Location",
624
+ required_skill="Skill",
625
+ employee=employee1,
626
+ )
627
+ shift2 = Shift(
628
+ id="2",
629
+ start=DAY_START_TIME,
630
+ end=DAY_END_TIME,
631
+ location="Location",
632
+ required_skill="Skill",
633
+ employee=employee2,
634
+ )
635
+ constraint_verifier.verify_that(balance_employee_shift_assignments).given(
636
+ employee1, employee2, shift1, shift2
637
+ ).penalizes_by(0)
tests/test_feasible.py CHANGED
@@ -1,12 +1,9 @@
1
- from solverforge_legacy.solver import SolverFactory
2
- from solverforge_legacy.solver.config import (
3
- SolverConfig,
4
- ScoreDirectorFactoryConfig,
5
- TerminationConfig,
6
- Duration,
7
- TerminationCompositionStyle,
8
- )
9
 
 
 
 
10
  from employee_scheduling.rest_api import app
11
  from employee_scheduling.domain import EmployeeScheduleModel
12
  from employee_scheduling.converters import model_to_schedule
@@ -14,11 +11,14 @@ from employee_scheduling.converters import model_to_schedule
14
  from fastapi.testclient import TestClient
15
  from time import sleep
16
  from pytest import fail
 
17
 
18
  client = TestClient(app)
19
 
20
 
 
21
  def test_feasible():
 
22
  demo_data_response = client.get("/demo-data/SMALL")
23
  assert demo_data_response.status_code == 200
24
 
@@ -27,16 +27,138 @@ def test_feasible():
27
  job_id = job_id_response.text[1:-1]
28
 
29
  ATTEMPTS = 1_000
 
30
  for _ in range(ATTEMPTS):
31
  sleep(0.1)
32
  schedule_response = client.get(f"/schedules/{job_id}")
33
  schedule_json = schedule_response.json()
34
  schedule_model = EmployeeScheduleModel.model_validate(schedule_json)
35
  schedule = model_to_schedule(schedule_model)
36
- if schedule.score is not None and schedule.score.is_feasible:
37
- stop_solving_response = client.delete(f"/schedules/{job_id}")
38
- assert stop_solving_response.status_code == 200
39
- return
 
 
40
 
41
  client.delete(f"/schedules/{job_id}")
42
- fail("solution is not feasible")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Integration tests for the employee scheduling solver.
 
 
 
 
 
 
3
 
4
+ Tests that the solver can find feasible solutions for demo data
5
+ and that the REST API works correctly.
6
+ """
7
  from employee_scheduling.rest_api import app
8
  from employee_scheduling.domain import EmployeeScheduleModel
9
  from employee_scheduling.converters import model_to_schedule
 
11
  from fastapi.testclient import TestClient
12
  from time import sleep
13
  from pytest import fail
14
+ import pytest
15
 
16
  client = TestClient(app)
17
 
18
 
19
+ @pytest.mark.timeout(120)
20
  def test_feasible():
21
+ """Test that the solver can find a feasible solution for SMALL demo data."""
22
  demo_data_response = client.get("/demo-data/SMALL")
23
  assert demo_data_response.status_code == 200
24
 
 
27
  job_id = job_id_response.text[1:-1]
28
 
29
  ATTEMPTS = 1_000
30
+ best_score = None
31
  for _ in range(ATTEMPTS):
32
  sleep(0.1)
33
  schedule_response = client.get(f"/schedules/{job_id}")
34
  schedule_json = schedule_response.json()
35
  schedule_model = EmployeeScheduleModel.model_validate(schedule_json)
36
  schedule = model_to_schedule(schedule_model)
37
+ if schedule.score is not None:
38
+ best_score = schedule.score
39
+ if schedule.score.is_feasible:
40
+ stop_solving_response = client.delete(f"/schedules/{job_id}")
41
+ assert stop_solving_response.status_code == 200
42
+ return
43
 
44
  client.delete(f"/schedules/{job_id}")
45
+ fail(f"Solution is not feasible after 100 seconds. Best score: {best_score}")
46
+
47
+
48
+ def test_demo_data_list():
49
+ """Test that demo data list endpoint returns available datasets."""
50
+ response = client.get("/demo-data")
51
+ assert response.status_code == 200
52
+ data = response.json()
53
+ assert isinstance(data, list)
54
+ assert len(data) > 0
55
+ assert "SMALL" in data
56
+
57
+
58
+ def test_demo_data_small_structure():
59
+ """Test that SMALL demo data has expected structure."""
60
+ response = client.get("/demo-data/SMALL")
61
+ assert response.status_code == 200
62
+ data = response.json()
63
+
64
+ # Check required fields
65
+ assert "employees" in data
66
+ assert "shifts" in data
67
+
68
+ # Validate employees
69
+ assert len(data["employees"]) > 0
70
+ for employee in data["employees"]:
71
+ assert "name" in employee
72
+
73
+ # Validate shifts
74
+ assert len(data["shifts"]) > 0
75
+ for shift in data["shifts"]:
76
+ assert "id" in shift
77
+ assert "start" in shift
78
+ assert "end" in shift
79
+ assert "location" in shift
80
+ assert "requiredSkill" in shift
81
+
82
+
83
+ def test_solver_start_and_stop():
84
+ """Test that solver can be started and stopped."""
85
+ demo_data_response = client.get("/demo-data/SMALL")
86
+ assert demo_data_response.status_code == 200
87
+
88
+ # Start solving
89
+ start_response = client.post("/schedules", json=demo_data_response.json())
90
+ assert start_response.status_code == 200
91
+ job_id = start_response.text[1:-1]
92
+
93
+ # Wait a bit
94
+ sleep(0.5)
95
+
96
+ # Check status
97
+ status_response = client.get(f"/schedules/{job_id}")
98
+ assert status_response.status_code == 200
99
+ schedule = status_response.json()
100
+ assert "solverStatus" in schedule
101
+
102
+ # Stop solving
103
+ stop_response = client.delete(f"/schedules/{job_id}")
104
+ assert stop_response.status_code == 200
105
+
106
+
107
+ def test_solver_assigns_employees():
108
+ """Test that solver actually assigns employees to shifts."""
109
+ demo_data_response = client.get("/demo-data/SMALL")
110
+ assert demo_data_response.status_code == 200
111
+
112
+ job_id_response = client.post("/schedules", json=demo_data_response.json())
113
+ assert job_id_response.status_code == 200
114
+ job_id = job_id_response.text[1:-1]
115
+
116
+ # Wait for some solving
117
+ sleep(2)
118
+
119
+ schedule_response = client.get(f"/schedules/{job_id}")
120
+ schedule_json = schedule_response.json()
121
+
122
+ # Check that some shifts have employees assigned
123
+ assigned_shifts = [s for s in schedule_json["shifts"] if s.get("employee") is not None]
124
+ assert len(assigned_shifts) > 0, "Solver should assign some employees to shifts"
125
+
126
+ client.delete(f"/schedules/{job_id}")
127
+
128
+
129
+ def test_score_analysis():
130
+ """Test that score analysis endpoint returns constraint analysis."""
131
+ demo_data_response = client.get("/demo-data/SMALL")
132
+ assert demo_data_response.status_code == 200
133
+
134
+ # Start solving and get a scored solution
135
+ job_id_response = client.post("/schedules", json=demo_data_response.json())
136
+ assert job_id_response.status_code == 200
137
+ job_id = job_id_response.text[1:-1]
138
+
139
+ # Wait for solver to produce a score
140
+ sleep(2)
141
+
142
+ schedule_response = client.get(f"/schedules/{job_id}")
143
+ schedule_json = schedule_response.json()
144
+
145
+ # Stop solving
146
+ client.delete(f"/schedules/{job_id}")
147
+
148
+ # Call analyze endpoint
149
+ analyze_response = client.put("/schedules/analyze", json=schedule_json)
150
+ assert analyze_response.status_code == 200
151
+ analysis = analyze_response.json()
152
+
153
+ # Verify structure
154
+ assert "constraints" in analysis
155
+ assert isinstance(analysis["constraints"], list)
156
+ assert len(analysis["constraints"]) > 0
157
+
158
+ # Check constraint structure
159
+ for constraint in analysis["constraints"]:
160
+ assert "name" in constraint
161
+ assert "weight" in constraint
162
+ assert "score" in constraint
163
+ assert "matches" in constraint
164
+ assert isinstance(constraint["matches"], list)