Compare commits
3 Commits
master
...
refactor_2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18813a3ccb | ||
|
|
5d2b665fa6 | ||
|
|
a8f47d29f6 |
5
.continue/rules/python.md
Normal file
5
.continue/rules/python.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
description: A description of your rule
|
||||||
|
---
|
||||||
|
Ты senior python-разработчик
|
||||||
|
У тебя 5-лет опыта работы с библиотекой ortools
|
||||||
136
PLAN.md
Normal file
136
PLAN.md
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
# 📋 ПЛАН РАБОТ: Модульность конфигурации
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
Перенести все константы (настройки) в отдельный файл `config.py` для удобства редактирования человеком, не знакомым с Python.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Этап 1: Создание файла конфигурации
|
||||||
|
|
||||||
|
**Файл:** `config.py`
|
||||||
|
|
||||||
|
**Что перенести:**
|
||||||
|
- **Настройки дня**
|
||||||
|
- Год (`year = 2026`)
|
||||||
|
- Месяц (`month = 4`)
|
||||||
|
|
||||||
|
- **Смены (`SHIFTS`)**
|
||||||
|
- С 06:00 до 14:30 (смена 0)
|
||||||
|
- С 08:00 до 16:30 (смена 1)
|
||||||
|
- С 11:30 до 20:00 (смена 2)
|
||||||
|
- С 12:30 до 21:00 (смена 3)
|
||||||
|
|
||||||
|
- **Операторы (`OPERATORS`)**
|
||||||
|
- Иванов
|
||||||
|
- Алексеев
|
||||||
|
- Сидоров
|
||||||
|
- Козобородов
|
||||||
|
- Трудяшкина
|
||||||
|
- Петрова
|
||||||
|
|
||||||
|
- **Предпочтения операторов (`OPERATOR_PREFERENCES`)**
|
||||||
|
- Иванов: 10, 13 апреля
|
||||||
|
- Алексеев: понедельники
|
||||||
|
- Сидоров: 25, 26 апреля
|
||||||
|
- Козобородов: смена 2 (11:30–20:00)
|
||||||
|
- Трудяшкина: 4 апреля
|
||||||
|
- Петрова: 8, 29 апреля
|
||||||
|
|
||||||
|
**Как редактировать:**
|
||||||
|
1. Откройте `config.py` в текстовом редакторе
|
||||||
|
2. Изменяйте значения в квадратных скобках или кавычках
|
||||||
|
3. Для добавления/удаления дней используйте списки `[25, 26]`
|
||||||
|
4. Сохраните файл
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Этап 2: Обновление `models.py`
|
||||||
|
|
||||||
|
**Что делать:**
|
||||||
|
- Удалить все константы из `models.py`
|
||||||
|
- Добавить импорт: `from config import *`
|
||||||
|
- Удалить определения `SHIFTS`, `OPERATORS`, `OPERATOR_PREFERENCES`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Этап 3: Обновление `main.py`
|
||||||
|
|
||||||
|
**Что делать:**
|
||||||
|
- Удалить встроенную генерацию дат
|
||||||
|
- Использовать импорт из `config.py`
|
||||||
|
- Добавить вывод в CSV
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Этап 4: Создание класса предпочтений операторов
|
||||||
|
|
||||||
|
**Файл:** `models.py` (или отдельный файл `preferences.py`)
|
||||||
|
|
||||||
|
**Что сделать:**
|
||||||
|
- Создать класс `OperatorPreferences` для конструирования и управления предпочтениями операторов.
|
||||||
|
- Реализовать цепочку методов (builder pattern) for удобного создания предпочтений.
|
||||||
|
- Поддержать следующие типы предпочтений:
|
||||||
|
- Желаемый рабочий день (конкретные даты месяца)
|
||||||
|
- Желаемая смена (индекс смены 0-3)
|
||||||
|
- Желаемый день недели для работы (понедельник, вторник, ...)
|
||||||
|
- Желаемый выходной (конкретные даты месяца)
|
||||||
|
- Реализовать метод `__str__` для отладки.
|
||||||
|
- Обеспечить экспорт предпочтений в формат, пригодный для `config.py`.
|
||||||
|
|
||||||
|
**Требования к реализации:**
|
||||||
|
- Класс должен позволять конструировать предпочтения через цепочку методов.
|
||||||
|
- Поддерживать три типа предпочтений (как указано выше).
|
||||||
|
- Экспортировать предпочтения в формат, пригодный для `config.py`.
|
||||||
|
- Поддерживать метод `__str__` для отладки.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Этап 5: Создание документации
|
||||||
|
|
||||||
|
**Файл:** `USER_GUIDE.md`
|
||||||
|
|
||||||
|
**Что включить:**
|
||||||
|
- Как изменить даты
|
||||||
|
- Как добавить/удалить операторов
|
||||||
|
- Как изменить смены
|
||||||
|
- Как добавить новые предпочтения
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Этап 6: Тестирование
|
||||||
|
|
||||||
|
**Проверить:**
|
||||||
|
1. Запуск `python main.py`
|
||||||
|
2. Корректное считывание дат из `config.py`
|
||||||
|
3. Генерация расписания
|
||||||
|
4. Вывод в CSV
|
||||||
|
5. Корректная работа класса `OperatorPreferences`
|
||||||
|
6. Экспорт предпочтений в `config.py`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Этап 7: Написание тестов
|
||||||
|
|
||||||
|
**Файл:** `tests/test_all.py`
|
||||||
|
|
||||||
|
**Что сделать:**
|
||||||
|
- Создать тесты для всех модулей проекта
|
||||||
|
- Использовать pytest для запуска тестов
|
||||||
|
- Проверить корректность работы всех компонентов
|
||||||
|
- Добавить тесты для edge-кейсов
|
||||||
|
|
||||||
|
**Структура тестов:**
|
||||||
|
- `test_config.py` - тесты для конфигурации
|
||||||
|
- `test_models.py` - тесты для моделей и предпочтений
|
||||||
|
- `test_main.py` - тесты для основного скрипта
|
||||||
|
- `test_integration.py` - интеграционные тесты
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Результат
|
||||||
|
- Конфигурация в одном файле `config.py`
|
||||||
|
- Простое редактирование без знания Python
|
||||||
|
- Четкая документация в `USER_GUIDE.md`
|
||||||
|
- Гибкая система предпочтений через класс `OperatorPreferences`
|
||||||
|
- Полноценный набор тестов для всех компонентов
|
||||||
|
|
||||||
60
config.py
Normal file
60
config.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""
|
||||||
|
config.py - Настройки графика смен
|
||||||
|
Редактируйте этот файл БЕЗ знания Python!
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 📅 НАСТРОЙКИ ДАТ
|
||||||
|
YEAR = 2026 # Год
|
||||||
|
MONTH = 4 # Месяц (апрель)
|
||||||
|
|
||||||
|
# 🕒 СМЕНЫ (время начала, время окончания)
|
||||||
|
# format: (начало_часа, окончание_часа)
|
||||||
|
SHIFTS = [
|
||||||
|
(6, 14.5), # Смена 0: 06:00 - 14:30
|
||||||
|
(8, 16.5), # Смена 1: 08:00 - 16:30
|
||||||
|
(11.5, 20), # Смена 2: 11:30 - 20:00
|
||||||
|
(12.5, 21), # Смена 3: 12:30 - 21:00
|
||||||
|
]
|
||||||
|
|
||||||
|
# 👷 ИМЕНА ОПЕРАТОРОВ
|
||||||
|
# Меняйте здесь имена операторов!
|
||||||
|
OPERATORS = [
|
||||||
|
"Иванов",
|
||||||
|
"Алексеев",
|
||||||
|
"Сидоров",
|
||||||
|
"Козобородов",
|
||||||
|
"Трудяшкина",
|
||||||
|
"Петрова",
|
||||||
|
]
|
||||||
|
|
||||||
|
# 💖 ПРЕДПОЧТЕНИЯ ОПЕРАТОРОВ
|
||||||
|
# Формат: {имя_оператора: объект_предпочтений}
|
||||||
|
# Для создания предпочтений используйте класс OperatorPreferences из models.py
|
||||||
|
# Пример:
|
||||||
|
# from models import OperatorPreferences, DayOfWeek
|
||||||
|
# prefs = OperatorPreferences.create("Иванов") \
|
||||||
|
# .work_on_date(10) \
|
||||||
|
# .work_on_date(13) \
|
||||||
|
# .build()
|
||||||
|
# OPERATOR_PREFERENCES = {"Иванов": prefs}
|
||||||
|
|
||||||
|
# Для удобства, здесь приведены примеры создания предпочтений через словарь,
|
||||||
|
# который будет автоматически преобразован в объект OperatorPreferences в models.py.
|
||||||
|
# Это позволяет редактировать файл без знания Python, используя простые структуры данных.
|
||||||
|
|
||||||
|
OPERATOR_PREFERENCES = {
|
||||||
|
"Иванов": {"dates": [10, 13]}, # 10 и 13 апреля
|
||||||
|
"Алексеев": {"day_of_week": "понедельник"}, # только понедельники
|
||||||
|
"Сидоров": {"dates": [25, 26]}, # 25 и 26 апреля
|
||||||
|
"Козобородов": {"shift": 2}, # только смена 2 (11:30–20:00)
|
||||||
|
"Трудяшкина": {"dates": [4]}, # 4 апреля
|
||||||
|
"Петрова": {"dates": [8, 29]}, # 8 и 29 апреля
|
||||||
|
}
|
||||||
|
|
||||||
|
# 📝 ПОЯСНЕНИЯ ДЛЯ РЕДАКТИРОВАНИЯ:
|
||||||
|
# - Дни месяца указываются в списке: {"dates": [10, 13]} = 10 и 13 апреля
|
||||||
|
# - "понедельник" (или "monday") означает работать только в понедельник
|
||||||
|
# - Число в "shift": 0-3 указывает на номер смены (0 = первая смена)
|
||||||
|
# - Для добавления/удаления дней меняйте списки в квадратных скобках
|
||||||
|
# - Для изменения имени оператора меняйте строку в OPERATORS
|
||||||
|
# - Для установки дня, когда оператор НЕ работает, используйте: {"off_dates": [5]}
|
||||||
154
main.py
154
main.py
@@ -1,101 +1,81 @@
|
|||||||
from ortools.sat.python import cp_model
|
"""
|
||||||
|
main.py - Основной скрипт для создания графика смен
|
||||||
|
Использует модуль models.py для создания и решения модели
|
||||||
|
"""
|
||||||
|
from models import create_model, solve_model, get_solution
|
||||||
|
from config import YEAR, MONTH, SHIFTS, OPERATORS
|
||||||
import csv
|
import csv
|
||||||
|
|
||||||
# Определяем даты апреля 2026 года
|
def generate_csv_report(schedule: dict, filename: str = 'shift_schedule.csv'):
|
||||||
days = [
|
"""
|
||||||
"2026-04-{:02d}".format(i) for i in range(1, 31)
|
Генерирует CSV-отчет на основе расписания.
|
||||||
]
|
|
||||||
|
|
||||||
# Смены
|
Аргументы:
|
||||||
shifts = [
|
schedule: словарь расписания из get_solution()
|
||||||
(6, 14.5), # с 06:00 до 14:30
|
filename: имя файла для вывода CSV
|
||||||
(8, 16.5), # с 08:00 до 16:30
|
|
||||||
(11.5, 20), # с 11:30 до 20:00
|
|
||||||
(12.5, 21) # с 12:30 до 21:00
|
|
||||||
]
|
|
||||||
|
|
||||||
# Операторы и их пожелания
|
Возвращает:
|
||||||
operators = [
|
True если файл создан успешно
|
||||||
"Иванов",
|
"""
|
||||||
"Алексеев",
|
with open(filename, 'w', newline='', encoding='utf-8') as file:
|
||||||
"Сидоров",
|
|
||||||
"Козобородов",
|
|
||||||
"Трудяшкина",
|
|
||||||
"Петрова"
|
|
||||||
]
|
|
||||||
|
|
||||||
operator_preferences = {
|
|
||||||
"Иванов": ["2026-04-10", "2026-04-13"],
|
|
||||||
"Алексеев": [(i, 0) for i in days if (int(i[-2:]) - 1) % 7 == 0], # Понедельники
|
|
||||||
"Сидоров": ["2026-04-25", "2026-04-26"],
|
|
||||||
"Козобородов": [(i, 2) for i in days], # Предпочитает смену 11:30–20:00
|
|
||||||
"Трудяшкина": ["2026-04-04"],
|
|
||||||
"Петрова": ["2026-04-08", "2026-04-29"]
|
|
||||||
}
|
|
||||||
|
|
||||||
# Создаем модель
|
|
||||||
model = cp_model.CpModel()
|
|
||||||
|
|
||||||
# Переменные: определяем, работает ли оператор на смене в день
|
|
||||||
works = {}
|
|
||||||
for day in days:
|
|
||||||
for shift_idx in range(len(shifts)):
|
|
||||||
for op in operators:
|
|
||||||
works[(day, shift_idx, op)] = model.NewBoolVar(f"{op}_{day}_shift_{shift_idx}")
|
|
||||||
|
|
||||||
# # Пожелания операторов
|
|
||||||
for op, prefs in operator_preferences.items():
|
|
||||||
if isinstance(prefs, list):
|
|
||||||
for pref in prefs:
|
|
||||||
if isinstance(pref, tuple):
|
|
||||||
day, shift = pref
|
|
||||||
model.AddHint(works[day, shift, op], 1)
|
|
||||||
else:
|
|
||||||
day = pref
|
|
||||||
model.Add(sum(works[day, shift_idx, op] for shift_idx in range(len(shifts))) == 0)
|
|
||||||
|
|
||||||
# Условие 1: На каждую смену должен быть назначен оператор
|
|
||||||
for day in days:
|
|
||||||
for shift_idx in range(len(shifts)):
|
|
||||||
model.Add(sum(works[day, shift_idx, op] for op in operators) == 1)
|
|
||||||
|
|
||||||
# Условие 2: У каждого оператора должно быть 8 выходных
|
|
||||||
for op in operators:
|
|
||||||
model.Add(sum(works[day, shift_idx, op] for day in days for shift_idx in range(len(shifts))) == 20 )
|
|
||||||
|
|
||||||
# Условие 3: Оператор не должен работать больше 5 дней подряд
|
|
||||||
for op in operators:
|
|
||||||
for day_idx in range(len(days) - 4):
|
|
||||||
model.Add(sum(works[days[day_idx + i], shift_idx, op] for i in range(5) for shift_idx in range(len(shifts))) <= 4)
|
|
||||||
|
|
||||||
# Условие 4: В день оператор может работать только одну смену
|
|
||||||
for day in days:
|
|
||||||
for op in operators:
|
|
||||||
model.Add(sum(works[day, shift_idx, op] for shift_idx in range(len(shifts))) <= 1)
|
|
||||||
|
|
||||||
# Решаем модель
|
|
||||||
solver = cp_model.CpSolver()
|
|
||||||
status = solver.Solve(model)
|
|
||||||
|
|
||||||
if status != cp_model.OPTIMAL and status != cp_model.FEASIBLE:
|
|
||||||
print("Невозможно составить график")
|
|
||||||
else:
|
|
||||||
# Создаем CSV файл с результатами
|
|
||||||
with open('shift_schedule.csv', 'w', newline='', encoding='utf-8') as file:
|
|
||||||
writer = csv.writer(file)
|
writer = csv.writer(file)
|
||||||
writer.writerow(['Day'] + [f'{i+1} shift' for i in range(len(shifts))])
|
# Заголовок
|
||||||
|
writer.writerow(['День'] + [f'Смена {i+1}' for i in range(len(SHIFTS))])
|
||||||
|
|
||||||
for day in days:
|
for day, day_shifts in schedule.items():
|
||||||
row = [day]
|
row = [day]
|
||||||
for shift_idx in range(len(shifts)):
|
for shift_idx in range(len(SHIFTS)):
|
||||||
op = None
|
op = None
|
||||||
for op_name in operators:
|
for op_name in OPERATORS:
|
||||||
if solver.Value(works[day, shift_idx, op_name]) == 1:
|
if schedule[day][shift_idx].get(op_name, False):
|
||||||
op = op_name
|
op = op_name
|
||||||
break
|
break
|
||||||
row.append(op)
|
row.append(op)
|
||||||
writer.writerow(row)
|
writer.writerow(row)
|
||||||
|
|
||||||
print("График успешно составлен и сохранен в 'shift_schedule.csv'")
|
print(f"График успешно составлен и сохранен в '{filename}'")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""
|
||||||
|
Главная функция: создает и решает модель, затем генерирует CSV-отчет.
|
||||||
|
"""
|
||||||
|
print("=" * 60)
|
||||||
|
print("Создание графика смен")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Шаг 1: Создаем модель
|
||||||
|
print("\n[1] Создание модели. ..")
|
||||||
|
model, variables, operators_set = create_model()
|
||||||
|
print(f" Модель создана с {len(variables)} переменными")
|
||||||
|
print(f" Операторы: {', '.join(operators_set)}")
|
||||||
|
|
||||||
|
# Шаг 2: Решаем модель
|
||||||
|
print("\n[2] Решение модели. ..")
|
||||||
|
success = solve_model()
|
||||||
|
|
||||||
|
if success:
|
||||||
|
print(" ✅ Модель решена успешно!")
|
||||||
|
|
||||||
|
# Шаг 3: Получаем решение
|
||||||
|
print("\n[3] Получение решения. ..")
|
||||||
|
schedule = get_solution()
|
||||||
|
print(f" Решение получено для {len(schedule)} дней")
|
||||||
|
|
||||||
|
# Шаг 4: Генерируем CSV-отчет
|
||||||
|
print("\n[4] Генерация CSV-отчета. ..")
|
||||||
|
generate_csv_report(schedule, 'shift_schedule.csv')
|
||||||
|
|
||||||
|
else:
|
||||||
|
print(" ❌ Не удалось составить график!")
|
||||||
|
print(" Проверьте ограничения и предпочтения операторов.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("Готово!")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
293
models.py
Normal file
293
models.py
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from typing import List, Dict, Set, Union, Optional
|
||||||
|
from ortools.sat.python import cp_model
|
||||||
|
from config import YEAR, MONTH, SHIFTS, OPERATORS
|
||||||
|
|
||||||
|
class PreferenceType(Enum):
|
||||||
|
"""Типы предпочтений"""
|
||||||
|
WORK_ON_DATE = "work_on_date"
|
||||||
|
WORK_ON_DAY_OF_WEEK = "work_on_day_of_week"
|
||||||
|
WORK_ONLY_SHIFT = "work_only_shift"
|
||||||
|
WORK_ONLY_DATE = "work_only_date"
|
||||||
|
OFF_ON_DATE = "off_on_date"
|
||||||
|
|
||||||
|
class DayOfWeek(Enum):
|
||||||
|
"""Дни недели"""
|
||||||
|
MONDAY = 0
|
||||||
|
TUESDAY = 1
|
||||||
|
WEDNESDAY = 2
|
||||||
|
THURSDAY = 3
|
||||||
|
FRIDAY = 4
|
||||||
|
SATURDAY = 5
|
||||||
|
SUNDAY = 6
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OperatorPreferences:
|
||||||
|
"""
|
||||||
|
Класс для управления предпочтениями оператора.
|
||||||
|
Использует паттерн Builder для удобного создания.
|
||||||
|
"""
|
||||||
|
operator_name: str
|
||||||
|
work_on_dates: List[int] = field(default_factory=list) # Дни месяца (1-31)
|
||||||
|
work_on_day_of_week: List[DayOfWeek] = field(default_factory=list)
|
||||||
|
work_only_shift: Optional[int] = None # Индекс смены (0-3)
|
||||||
|
off_on_dates: List[int] = field(default_factory=list) # Дни, когда оператор НЕ работает
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if self.work_only_shift is not None and not (0 <= self.work_only_shift < len(SHIFTS)):
|
||||||
|
raise ValueError(f"Индекс смены {self.work_only_shift} вне диапазона 0-{len(SHIFTS)-1}")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(cls, operator_name: str) -> 'PreferencesBuilder':
|
||||||
|
"""Статический метод для начала построения предпочтений"""
|
||||||
|
return PreferencesBuilder(operator_name)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Union[int, str, List[int]]]:
|
||||||
|
"""
|
||||||
|
Конвертирует объект предпочтений в формат, пригодный для config.py.
|
||||||
|
Возвращает словарь с ключами, понятными для парсера в create_model.
|
||||||
|
"""
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
if self.work_only_shift is not None:
|
||||||
|
result['shift'] = self.work_only_shift
|
||||||
|
|
||||||
|
if self.work_on_day_of_week:
|
||||||
|
day_names = [d.name.lower() for d in self.work_on_day_of_week]
|
||||||
|
# Если только один день недели, можно хранить как строку, иначе как список
|
||||||
|
if len(day_names) == 1:
|
||||||
|
result['day_of_week'] = day_names[0]
|
||||||
|
else:
|
||||||
|
result['day_of_week'] = day_names
|
||||||
|
|
||||||
|
if self.work_on_dates:
|
||||||
|
result['dates'] = self.work_on_dates
|
||||||
|
|
||||||
|
if self.off_on_dates:
|
||||||
|
result['off_dates'] = self.off_on_dates
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
"""Метод для отладки"""
|
||||||
|
parts = []
|
||||||
|
if self.work_only_shift is not None:
|
||||||
|
parts.append(f"Shift {self.work_only_shift}")
|
||||||
|
if self.work_on_day_of_week:
|
||||||
|
parts.append(f"Days: {', '.join([d.name for d in self.work_on_day_of_week])}")
|
||||||
|
if self.work_on_dates:
|
||||||
|
parts.append(f"Dates: {self.work_on_dates}")
|
||||||
|
if self.off_on_dates:
|
||||||
|
parts.append(f"Off: {self.off_on_dates}")
|
||||||
|
|
||||||
|
return f"OperatorPreferences({self.operator_name}: {', '.join(parts)})"
|
||||||
|
|
||||||
|
class PreferencesBuilder:
|
||||||
|
"""Builder для создания предпочтений оператора"""
|
||||||
|
|
||||||
|
def __init__(self, operator_name: str):
|
||||||
|
self.operator_name = operator_name
|
||||||
|
self.prefs = OperatorPreferences(operator_name)
|
||||||
|
|
||||||
|
def work_on_date(self, day: int) -> 'PreferencesBuilder':
|
||||||
|
"""Добавить день месяца для работы"""
|
||||||
|
if not 1 <= day <= 31:
|
||||||
|
raise ValueError(f"День {day} вне диапазона 1-31")
|
||||||
|
self.prefs.work_on_dates.append(day)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def work_on_dates(self, days: List[int]) -> 'PreferencesBuilder':
|
||||||
|
"""Добавить список дней месяца для работы"""
|
||||||
|
for day in days:
|
||||||
|
self.work_on_date(day)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def work_on_day_of_week(self, day: DayOfWeek) -> 'PreferencesBuilder':
|
||||||
|
"""Добавить день недели для работы"""
|
||||||
|
if day not in self.prefs.work_on_day_of_week:
|
||||||
|
self.prefs.work_on_day_of_week.append(day)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def work_on_days_of_week(self, days: List[DayOfWeek]) -> 'PreferencesBuilder':
|
||||||
|
"""Добавить список дней недели для работы"""
|
||||||
|
for day in days:
|
||||||
|
self.work_on_day_of_week(day)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def work_only_shift(self, shift_idx: int) -> 'PreferencesBuilder':
|
||||||
|
"""Установить предпочтительную смену (работает только в этой смене)"""
|
||||||
|
self.prefs.work_only_shift = shift_idx
|
||||||
|
return self
|
||||||
|
|
||||||
|
def off_on_date(self, day: int) -> 'PreferencesBuilder':
|
||||||
|
"""Установить день, когда оператор НЕ работает"""
|
||||||
|
if not 1 <= day <= 31:
|
||||||
|
raise ValueError(f"День {day} вне диапазона 1-31")
|
||||||
|
self.prefs.off_on_dates.append(day)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def off_on_dates(self, days: List[int]) -> 'PreferencesBuilder':
|
||||||
|
"""Установить дни, когда оператор НЕ работает"""
|
||||||
|
for day in days:
|
||||||
|
self.off_on_date(day)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def build(self) -> OperatorPreferences:
|
||||||
|
"""Завершить построение и вернуть объект предпочтений"""
|
||||||
|
return self.prefs
|
||||||
|
|
||||||
|
def get_days() -> List[str]:
|
||||||
|
"""Генерирует список дат месяца"""
|
||||||
|
return [f"{YEAR}-{MONTH:02d}-{i:02d}" for i in range(1, 31)]
|
||||||
|
|
||||||
|
def create_model() -> Tuple[cp_model.CpModel, Dict, Set]:
|
||||||
|
"""
|
||||||
|
Создаёт и настраивает CP-модель.
|
||||||
|
|
||||||
|
Возвращает:
|
||||||
|
model: CP-модель
|
||||||
|
variables: словарь переменных работы
|
||||||
|
operators_set: множество операторов для удобства
|
||||||
|
"""
|
||||||
|
model = cp_model.CpModel()
|
||||||
|
variables = {}
|
||||||
|
days = get_days()
|
||||||
|
|
||||||
|
# Определяем переменные: works[(day, shift_idx, op)]
|
||||||
|
for day in days:
|
||||||
|
for shift_idx in range(len(SHIFTS)):
|
||||||
|
for op in OPERATORS:
|
||||||
|
var_name = f"{op}_{day}_shift_{shift_idx}"
|
||||||
|
variables[(day, shift_idx, op)] = model.NewBoolVar(var_name)
|
||||||
|
|
||||||
|
# Определяем множество операторов
|
||||||
|
operators_set = set(OPERATORS)
|
||||||
|
|
||||||
|
# 1. Учёт предпочтений операторов
|
||||||
|
# Ожидаем, что OPERATOR_PREFERENCES теперь содержит объекты OperatorPreferences или их словари
|
||||||
|
for op, prefs_data in OPERATOR_PREFERENCES.items():
|
||||||
|
# Если это словарь (из config.py), преобразуем в объект
|
||||||
|
if isinstance(prefs_data, dict):
|
||||||
|
prefs = OperatorPreferences(op)
|
||||||
|
if 'shift' in prefs_data:
|
||||||
|
prefs.work_only_shift = prefs_data['shift']
|
||||||
|
if 'day_of_week' in prefs_data:
|
||||||
|
dow = prefs_data['day_of_week']
|
||||||
|
if isinstance(dow, str):
|
||||||
|
try:
|
||||||
|
prefs.work_on_day_of_week.append(DayOfWeek[dow.upper()])
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
elif isinstance(dow, list):
|
||||||
|
for d in dow:
|
||||||
|
try:
|
||||||
|
prefs.work_on_day_of_week.append(DayOfWeek[d.upper()])
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
if 'dates' in prefs_data:
|
||||||
|
prefs.work_on_dates = prefs_data['dates']
|
||||||
|
if 'off_dates' in prefs_data:
|
||||||
|
prefs.off_on_dates = prefs_data['off_dates']
|
||||||
|
else:
|
||||||
|
prefs = prefs_data
|
||||||
|
|
||||||
|
# Применяем предпочтения
|
||||||
|
if prefs.work_only_shift is not None:
|
||||||
|
# Оператор предпочитает конкретную смену
|
||||||
|
shift_idx = prefs.work_only_shift
|
||||||
|
for day in days:
|
||||||
|
model.AddHint(variables[(day, shift_idx, op)], 1)
|
||||||
|
|
||||||
|
if prefs.work_on_day_of_week:
|
||||||
|
# Оператор работает только в определенные дни недели
|
||||||
|
for day in days:
|
||||||
|
date_obj = datetime(int(day.split('-')[0]), int(day.split('-')[1]), int(day.split('-')[2]))
|
||||||
|
if date_obj.weekday() in [d.value for d in prefs.work_on_day_of_week]:
|
||||||
|
# Если есть preference_shift, то только в эту смену
|
||||||
|
if prefs.work_only_shift is not None:
|
||||||
|
model.AddHint(variables[(day, prefs.work_only_shift, op)], 1)
|
||||||
|
else:
|
||||||
|
# Иначе в любую смену (но это уже обрабатывается другими ограничениями)
|
||||||
|
# Для hint можно добавить в первую смену как ориентир
|
||||||
|
model.AddHint(variables[(day, 0, op)], 1)
|
||||||
|
|
||||||
|
if prefs.work_on_dates:
|
||||||
|
# Оператор предпочитает конкретные дни
|
||||||
|
for pref_day_num in prefs.work_on_dates:
|
||||||
|
target_day = f"{YEAR}-{MONTH:02d}-{pref_day_num:02d}"
|
||||||
|
if target_day in days:
|
||||||
|
if prefs.work_only_shift is not None:
|
||||||
|
model.AddHint(variables[(target_day, prefs.work_only_shift, op)], 1)
|
||||||
|
else:
|
||||||
|
model.AddHint(variables[(target_day, 0, op)], 1)
|
||||||
|
|
||||||
|
if prefs.off_on_dates:
|
||||||
|
# Оператор НЕ работает в эти дни
|
||||||
|
for off_day_num in prefs.off_on_dates:
|
||||||
|
target_day = f"{YEAR}-{MONTH:02d}-{off_day_num:02d}"
|
||||||
|
if target_day in days:
|
||||||
|
# Запрещаем работу в этот день
|
||||||
|
for shift_idx in range(len(SHIFTS)):
|
||||||
|
model.Add(variables[(target_day, shift_idx, op)] == 0)
|
||||||
|
|
||||||
|
# Условие 1: На каждую смену должен быть назначен оператор
|
||||||
|
for day in days:
|
||||||
|
for shift_idx in range(len(SHIFTS)):
|
||||||
|
model.Add(sum(variables[(day, shift_idx, op)] for op in OPERATORS) == 1)
|
||||||
|
|
||||||
|
# Условие 2: У каждого оператора должно быть 8 выходных (20 рабочих дней)
|
||||||
|
for op in OPERATORS:
|
||||||
|
model.Add(sum(variables[(day, shift_idx, op)] for day in days for shift_idx in range(len(SHIFTS))) == 20)
|
||||||
|
|
||||||
|
# Условие 3: Оператор не должен работать больше 5 дней подряд
|
||||||
|
for op in OPERATORS:
|
||||||
|
for day_idx in range(len(days) - 4):
|
||||||
|
model.Add(sum(variables[(days[day_idx + i], shift_idx, op)]
|
||||||
|
for i in range(5)
|
||||||
|
for shift_idx in range(len(SHIFTS))) <= 4)
|
||||||
|
|
||||||
|
# Условие 4: В день оператор может работать только одну смену
|
||||||
|
for day in days:
|
||||||
|
for op in OPERATORS:
|
||||||
|
model.Add(sum(variables[(day, shift_idx, op)] for shift_idx in range(len(SHIFTS))) <= 1)
|
||||||
|
|
||||||
|
return model, variables, operators_set
|
||||||
|
|
||||||
|
def solve_model() -> bool:
|
||||||
|
"""
|
||||||
|
Решает модель.
|
||||||
|
|
||||||
|
Возвращает:
|
||||||
|
True - если решение найдено
|
||||||
|
False - если не удалось составить график
|
||||||
|
"""
|
||||||
|
model, variables, operators_set = create_model()
|
||||||
|
|
||||||
|
solver = cp_model.CpSolver()
|
||||||
|
status = solver.Solve(model)
|
||||||
|
|
||||||
|
return status in (cp_model.OPTIMAL, cp_model.FEASIBLE)
|
||||||
|
|
||||||
|
def get_solution() -> Dict:
|
||||||
|
"""
|
||||||
|
Получает решение модели.
|
||||||
|
|
||||||
|
Возвращает:
|
||||||
|
Словарь с расписанием: {день: {смена: {оператор: True/False}}}
|
||||||
|
"""
|
||||||
|
model, variables, operators_set = create_model()
|
||||||
|
solver = cp_model.CpSolver()
|
||||||
|
solver.Solve(model)
|
||||||
|
|
||||||
|
schedule = {}
|
||||||
|
for day in get_days():
|
||||||
|
schedule[day] = {
|
||||||
|
shift_idx: {op: solver.Value(variables[(day, shift_idx, op)])
|
||||||
|
for op in OPERATORS}
|
||||||
|
for shift_idx in range(len(SHIFTS))
|
||||||
|
}
|
||||||
|
|
||||||
|
return schedule
|
||||||
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Тесты для проекта модульности конфигурации
|
||||||
140
tests/test_config.py
Normal file
140
tests/test_config.py
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
"""
|
||||||
|
test_config.py - Тесты для конфигурации
|
||||||
|
Проверяют корректность чтения и структуры config.py
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from config import YEAR, MONTH, SHIFTS, OPERATORS, OPERATOR_PREFERENCES
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigConstants:
|
||||||
|
"""Тесты для констант конфигурации"""
|
||||||
|
|
||||||
|
def test_year_is_int(self):
|
||||||
|
"""Год должен быть целым числом"""
|
||||||
|
assert isinstance(YEAR, int)
|
||||||
|
assert YEAR == 2026
|
||||||
|
|
||||||
|
def test_month_is_int(self):
|
||||||
|
"""Месяц должен быть целым числом"""
|
||||||
|
assert isinstance(MONTH, int)
|
||||||
|
assert MONTH == 4
|
||||||
|
|
||||||
|
def test_month_in_range(self):
|
||||||
|
"""Месяц должен быть в диапазоне 1-12"""
|
||||||
|
assert 1 <= MONTH <= 12
|
||||||
|
|
||||||
|
def test_shifts_is_list(self):
|
||||||
|
"""Смены должны быть списком"""
|
||||||
|
assert isinstance(SHIFTS, list)
|
||||||
|
assert len(SHIFTS) == 4
|
||||||
|
|
||||||
|
def test_shifts_structure(self):
|
||||||
|
"""Каждая смена должна быть кортежем из двух чисел"""
|
||||||
|
for shift in SHIFTS:
|
||||||
|
assert isinstance(shift, tuple)
|
||||||
|
assert len(shift) == 2
|
||||||
|
start, end = shift
|
||||||
|
assert isinstance(start, (int, float))
|
||||||
|
assert isinstance(end, (int, float))
|
||||||
|
assert start < end
|
||||||
|
|
||||||
|
def test_operators_is_list(self):
|
||||||
|
"""Операторы должны быть списком"""
|
||||||
|
assert isinstance(OPERATORS, list)
|
||||||
|
assert len(OPERATORS) > 0
|
||||||
|
|
||||||
|
def test_operators_not_empty(self):
|
||||||
|
"""Список операторов не должен быть пустым"""
|
||||||
|
assert len(OPERATORS) >= 1
|
||||||
|
|
||||||
|
def test_operators_strings(self):
|
||||||
|
"""Все операторы должны быть строками"""
|
||||||
|
for op in OPERATORS:
|
||||||
|
assert isinstance(op, str)
|
||||||
|
assert len(op) > 0
|
||||||
|
|
||||||
|
def test_operator_preferences_structure(self):
|
||||||
|
"""Структура предпочтений должна быть корректной"""
|
||||||
|
assert isinstance(OPERATOR_PREFERENCES, dict)
|
||||||
|
assert len(OPERATOR_PREFERENCES) == len(OPERATORS)
|
||||||
|
|
||||||
|
def test_operator_preferences_values(self):
|
||||||
|
"""Значения предпочтений должны быть словарями"""
|
||||||
|
for op, prefs in OPERATOR_PREFERENCES.items():
|
||||||
|
assert isinstance(prefs, dict)
|
||||||
|
# Проверка, что ключи предпочтений корректны
|
||||||
|
valid_keys = {'dates', 'day_of_week', 'shift', 'off_dates'}
|
||||||
|
assert set(prefs.keys()).issubset(valid_keys)
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigDates:
|
||||||
|
"""Тесты для дат"""
|
||||||
|
|
||||||
|
def test_year_is_positive(self):
|
||||||
|
"""Год должен быть положительным"""
|
||||||
|
assert YEAR > 0
|
||||||
|
|
||||||
|
def test_month_is_valid(self):
|
||||||
|
"""Месяц должен быть валидным (1-12)"""
|
||||||
|
assert 1 <= MONTH <= 12
|
||||||
|
|
||||||
|
def test_shifts_time_valid(self):
|
||||||
|
"""Время смены должно быть валидным (0-24)"""
|
||||||
|
for start, end in SHIFTS:
|
||||||
|
assert 0 <= start <= 24
|
||||||
|
assert 0 <= end <= 24
|
||||||
|
assert start < end
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigOperators:
|
||||||
|
"""Тесты для операторов"""
|
||||||
|
|
||||||
|
def test_operators_unique(self):
|
||||||
|
"""Имена операторов должны быть уникальными"""
|
||||||
|
assert len(OPERATORS) == len(set(OPERATORS))
|
||||||
|
|
||||||
|
def test_operators_no_empty_strings(self):
|
||||||
|
"""Имена операторов не должны быть пустыми строками"""
|
||||||
|
for op in OPERATORS:
|
||||||
|
assert op.strip() != ""
|
||||||
|
|
||||||
|
def test_operators_no_special_chars(self):
|
||||||
|
"""Имена операторов не должны содержать специальные символы"""
|
||||||
|
for op in OPERATORS:
|
||||||
|
assert not any(c in op for c in ['<', '>', '/', '\\', '|', ':', '*', '?', '"'])
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigPreferences:
|
||||||
|
"""Тесты для предпочтений операторов"""
|
||||||
|
|
||||||
|
def test_all_operators_have_preferences(self):
|
||||||
|
"""Все операторы должны иметь предпочтения"""
|
||||||
|
assert len(OPERATOR_PREFERENCES) == len(OPERATORS)
|
||||||
|
|
||||||
|
def test_dates_in_range(self):
|
||||||
|
"""Дни в предпочтениях должны быть в диапазоне 1-31"""
|
||||||
|
for prefs in OPERATOR_PREFERENCES.values():
|
||||||
|
if 'dates' in prefs:
|
||||||
|
for day in prefs['dates']:
|
||||||
|
assert 1 <= day <= 31
|
||||||
|
|
||||||
|
def test_shift_in_range(self):
|
||||||
|
"""Смена в предпочтениях должна быть в диапазоне 0-3"""
|
||||||
|
for prefs in OPERATOR_PREFERENCES.values():
|
||||||
|
if 'shift' in prefs:
|
||||||
|
assert 0 <= prefs['shift'] <= 3
|
||||||
|
|
||||||
|
def test_day_of_week_valid(self):
|
||||||
|
"""День недели в предпочтениях должен быть валидным"""
|
||||||
|
for prefs in OPERATOR_PREFERENCES.values():
|
||||||
|
if 'day_of_week' in prefs:
|
||||||
|
day_str = prefs['day_of_week']
|
||||||
|
# Проверяем, что это строка с валидным значением
|
||||||
|
assert day_str.lower() in ['понедельник', 'вторник', 'среда', 'четверг', 'пятница', 'суббота', 'воскресенье']
|
||||||
|
|
||||||
|
def test_off_dates_in_range(self):
|
||||||
|
"""Дни отдыха должны быть в диапазоне 1-31"""
|
||||||
|
for prefs in OPERATOR_PREFERENCES.values():
|
||||||
|
if 'off_dates' in prefs:
|
||||||
|
for day in prefs['off_dates']:
|
||||||
|
assert 1 <= day <= 31
|
||||||
354
tests/test_models.py
Normal file
354
tests/test_models.py
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
"""
|
||||||
|
test_models.py - Тесты для моделей и предпочтений
|
||||||
|
Проверяют корректность работы класса OperatorPreferences и связанных функций
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime
|
||||||
|
from models import (
|
||||||
|
OperatorPreferences,
|
||||||
|
PreferencesBuilder,
|
||||||
|
DayOfWeek,
|
||||||
|
PreferenceType,
|
||||||
|
get_days,
|
||||||
|
create_model,
|
||||||
|
solve_model,
|
||||||
|
get_solution
|
||||||
|
)
|
||||||
|
from config import YEAR, MONTH, SHIFTS, OPERATORS
|
||||||
|
|
||||||
|
class TestDayOfWeek:
|
||||||
|
"""Тесты для enum DayOfWeek"""
|
||||||
|
|
||||||
|
def test_day_of_week_values(self):
|
||||||
|
"""Проверка значений enum"""
|
||||||
|
assert DayOfWeek.MONDAY.value == 0
|
||||||
|
assert DayOfWeek.TUESDAY.value == 1
|
||||||
|
assert DayOfWeek.WEDNESDAY.value == 2
|
||||||
|
assert DayOfWeek.THURSDAY.value == 3
|
||||||
|
assert DayOfWeek.FRIDAY.value == 4
|
||||||
|
assert DayOfWeek.SATURDAY.value == 5
|
||||||
|
assert DayOfWeek.SUNDAY.value == 6
|
||||||
|
|
||||||
|
def test_day_of_week_names(self):
|
||||||
|
"""Проверка названий enum"""
|
||||||
|
assert DayOfWeek.MONDAY.name == "MONDAY"
|
||||||
|
assert DayOfWeek.SUNDAY.name == "SUNDAY"
|
||||||
|
|
||||||
|
|
||||||
|
class TestOperatorPreferences:
|
||||||
|
"""Тесты для класса OperatorPreferences"""
|
||||||
|
|
||||||
|
def test_creation(self):
|
||||||
|
"""Тест создания объекта предпочтений"""
|
||||||
|
prefs = OperatorPreferences("Тестовый")
|
||||||
|
assert prefs.operator_name == "Тестовый"
|
||||||
|
assert prefs.work_on_dates == []
|
||||||
|
assert prefs.work_on_day_of_week == []
|
||||||
|
assert prefs.work_only_shift is None
|
||||||
|
assert prefs.off_on_dates == []
|
||||||
|
|
||||||
|
def test_to_dict_empty(self):
|
||||||
|
"""Тест конвертации в словарь для пустых предпочтений"""
|
||||||
|
prefs = OperatorPreferences("Тестовый")
|
||||||
|
result = prefs.to_dict()
|
||||||
|
assert result == {}
|
||||||
|
|
||||||
|
def test_to_dict_with_dates(self):
|
||||||
|
"""Тест конвертации с датами"""
|
||||||
|
prefs = OperatorPreferences("Тестовый")
|
||||||
|
prefs.work_on_dates = [10, 15, 20]
|
||||||
|
result = prefs.to_dict()
|
||||||
|
assert result['dates'] == [10, 15, 20]
|
||||||
|
|
||||||
|
def test_to_dict_with_shift(self):
|
||||||
|
"""Тест конвертации со сменой"""
|
||||||
|
prefs = OperatorPreferences("Тестовый")
|
||||||
|
prefs.work_only_shift = 2
|
||||||
|
result = prefs.to_dict()
|
||||||
|
assert result['shift'] == 2
|
||||||
|
|
||||||
|
def test_to_dict_with_day_of_week(self):
|
||||||
|
"""Тест конвертации с днем недели"""
|
||||||
|
prefs = OperatorPreferences("Тестовый")
|
||||||
|
prefs.work_on_day_of_week = [DayOfWeek.MONDAY, DayOfWeek.WEDNESDAY]
|
||||||
|
result = prefs.to_dict()
|
||||||
|
assert result['day_of_week'] == ['monday', 'wednesday']
|
||||||
|
|
||||||
|
def test_to_dict_with_off_dates(self):
|
||||||
|
"""Тест конвертации с днями отдыха"""
|
||||||
|
prefs = OperatorPreferences("Тестовый")
|
||||||
|
prefs.off_on_dates = [5, 12, 19]
|
||||||
|
result = prefs.to_dict()
|
||||||
|
assert result['off_dates'] == [5, 12, 19]
|
||||||
|
|
||||||
|
def test_to_dict_all(self):
|
||||||
|
"""Тест конвертации всех полей"""
|
||||||
|
prefs = OperatorPreferences("Тестовый")
|
||||||
|
prefs.work_on_dates = [10, 15]
|
||||||
|
prefs.work_on_day_of_week = [DayOfWeek.MONDAY]
|
||||||
|
prefs.work_only_shift = 1
|
||||||
|
prefs.off_on_dates = [5]
|
||||||
|
result = prefs.to_dict()
|
||||||
|
assert result['dates'] == [10, 15]
|
||||||
|
assert result['day_of_week'] == ['monday']
|
||||||
|
assert result['shift'] == 1
|
||||||
|
assert result['off_dates'] == [5]
|
||||||
|
|
||||||
|
def test_from_dict(self):
|
||||||
|
"""Тест создания из словаря"""
|
||||||
|
data = {
|
||||||
|
'dates': [10, 15],
|
||||||
|
'day_of_week': ['monday'],
|
||||||
|
'shift': 1,
|
||||||
|
'off_dates': [5]
|
||||||
|
}
|
||||||
|
prefs = OperatorPreferences.from_dict("Тестовый", data)
|
||||||
|
assert prefs.work_on_dates == [10, 15]
|
||||||
|
assert prefs.work_on_day_of_week == [DayOfWeek.MONDAY]
|
||||||
|
assert prefs.work_only_shift == 1
|
||||||
|
assert prefs.off_on_dates == [5]
|
||||||
|
|
||||||
|
def test_from_dict_partial(self):
|
||||||
|
"""Тест создания из словаря с неполными данными"""
|
||||||
|
data = {'dates': [10]}
|
||||||
|
prefs = OperatorPreferences.from_dict("Тестовый", data)
|
||||||
|
assert prefs.work_on_dates == [10]
|
||||||
|
assert prefs.work_on_day_of_week == []
|
||||||
|
assert prefs.work_only_shift is None
|
||||||
|
assert prefs.off_on_dates == []
|
||||||
|
|
||||||
|
def test_from_dict_invalid_day_of_week(self):
|
||||||
|
"""Тест обработки невалидного дня недели"""
|
||||||
|
data = {'day_of_week': ['invalid']}
|
||||||
|
prefs = OperatorPreferences.from_dict("Тестовый", data)
|
||||||
|
assert prefs.work_on_day_of_week == []
|
||||||
|
|
||||||
|
def test_from_dict_invalid_shift(self):
|
||||||
|
"""Тест обработки невалидной смены"""
|
||||||
|
data = {'shift': 5}
|
||||||
|
prefs = OperatorPreferences.from_dict("Тестовый", data)
|
||||||
|
assert prefs.work_only_shift is None
|
||||||
|
|
||||||
|
def test_from_dict_invalid_dates(self):
|
||||||
|
"""Тест обработки невалидных дат"""
|
||||||
|
data = {'dates': [32]}
|
||||||
|
prefs = OperatorPreferences.from_dict("Тестовый", data)
|
||||||
|
assert prefs.work_on_dates == []
|
||||||
|
|
||||||
|
def test_from_dict_invalid_off_dates(self):
|
||||||
|
"""Тест обработки невалидных дней отдыха"""
|
||||||
|
data = {'off_dates': [32]}
|
||||||
|
prefs = OperatorPreferences.from_dict("Тестовый", data)
|
||||||
|
assert prefs.off_on_dates == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestPreferencesBuilder:
|
||||||
|
"""Тесты для класса PreferencesBuilder"""
|
||||||
|
|
||||||
|
def test_creation(self):
|
||||||
|
"""Тест создания объекта"""
|
||||||
|
builder = PreferencesBuilder("Тестовый")
|
||||||
|
assert builder.operator_name == "Тестовый"
|
||||||
|
assert builder._preferences is None
|
||||||
|
|
||||||
|
def test_add_dates(self):
|
||||||
|
"""Тест добавления дат"""
|
||||||
|
builder = PreferencesBuilder("Тестовый")
|
||||||
|
builder.add_dates([10, 15])
|
||||||
|
assert builder._preferences.work_on_dates == [10, 15]
|
||||||
|
|
||||||
|
def test_add_day_of_week(self):
|
||||||
|
"""Тест добавления дней недели"""
|
||||||
|
builder = PreferencesBuilder("Тестовый")
|
||||||
|
builder.add_day_of_week([DayOfWeek.MONDAY, DayOfWeek.WEDNESDAY])
|
||||||
|
assert len(builder._preferences.work_on_day_of_week) == 2
|
||||||
|
|
||||||
|
def test_add_shift(self):
|
||||||
|
"""Тест добавления смены"""
|
||||||
|
builder = PreferencesBuilder("Тестовый")
|
||||||
|
builder.add_shift(2)
|
||||||
|
assert builder._preferences.work_only_shift == 2
|
||||||
|
|
||||||
|
def test_add_off_dates(self):
|
||||||
|
"""Тест добавления дней отдыха"""
|
||||||
|
builder = PreferencesBuilder("Тестовый")
|
||||||
|
builder.add_off_dates([5, 12])
|
||||||
|
assert builder._preferences.off_on_dates == [5, 12]
|
||||||
|
|
||||||
|
def test_to_dict(self):
|
||||||
|
"""Тест конвертации в словарь"""
|
||||||
|
builder = PreferencesBuilder("Тестовый")
|
||||||
|
builder.add_dates([10])
|
||||||
|
builder.add_shift(1)
|
||||||
|
result = builder.to_dict()
|
||||||
|
assert result['dates'] == [10]
|
||||||
|
assert result['shift'] == 1
|
||||||
|
|
||||||
|
def test_to_dict_empty(self):
|
||||||
|
"""Тест конвертации пустых предпочтений"""
|
||||||
|
builder = PreferencesBuilder("Тестовый")
|
||||||
|
result = builder.to_dict()
|
||||||
|
assert result == {}
|
||||||
|
|
||||||
|
def test_to_dict_invalid(self):
|
||||||
|
"""Тест обработки невалидных данных"""
|
||||||
|
builder = PreferencesBuilder("Тестовый")
|
||||||
|
builder.add_dates([32]) # Невалидная дата
|
||||||
|
result = builder.to_dict()
|
||||||
|
assert result == {}
|
||||||
|
|
||||||
|
def test_to_dict_invalid_day_of_week(self):
|
||||||
|
"""Тест обработки невалидного дня недели"""
|
||||||
|
builder = PreferencesBuilder("Тестовый")
|
||||||
|
builder.add_day_of_week([DayOfWeek.MONDAY, "invalid"])
|
||||||
|
result = builder.to_dict()
|
||||||
|
assert result['day_of_week'] == ['monday']
|
||||||
|
|
||||||
|
def test_to_dict_invalid_shift(self):
|
||||||
|
"""Тест обработки невалидной смены"""
|
||||||
|
builder = PreferencesBuilder("Тестовый")
|
||||||
|
builder.add_shift(5) # Невалидная смена
|
||||||
|
result = builder.to_dict()
|
||||||
|
assert result == {}
|
||||||
|
|
||||||
|
def test_to_dict_invalid_off_dates(self):
|
||||||
|
"""Тест обработки невалидных дней отдыха"""
|
||||||
|
builder = PreferencesBuilder("Тестовый")
|
||||||
|
builder.add_off_dates([32]) # Невалидная дата
|
||||||
|
result = builder.to_dict()
|
||||||
|
assert result == {}
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetDays:
|
||||||
|
"""Тесты для функции get_days"""
|
||||||
|
|
||||||
|
def test_days_count(self):
|
||||||
|
"""Проверка количества дней"""
|
||||||
|
days = get_days()
|
||||||
|
assert len(days) == 30 # Апрель 2026
|
||||||
|
|
||||||
|
def test_days_format(self):
|
||||||
|
"""Проверка формата дней"""
|
||||||
|
days = get_days()
|
||||||
|
for day in days:
|
||||||
|
assert isinstance(day, str)
|
||||||
|
assert len(day) == 10 # YYYY-MM-DD
|
||||||
|
assert '-' in day
|
||||||
|
|
||||||
|
def test_days_sorted(self):
|
||||||
|
"""Проверка сортировки дней"""
|
||||||
|
days = get_days()
|
||||||
|
assert days == sorted(days)
|
||||||
|
|
||||||
|
def test_days_start(self):
|
||||||
|
"""Проверка первого дня"""
|
||||||
|
days = get_days()
|
||||||
|
assert days[0] == "2026-04-01"
|
||||||
|
|
||||||
|
def test_days_end(self):
|
||||||
|
"""Проверка последнего дня"""
|
||||||
|
days = get_days()
|
||||||
|
assert days[-1] == "2026-04-30"
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateModel:
|
||||||
|
"""Тесты для функции create_model"""
|
||||||
|
|
||||||
|
def test_model_creation(self):
|
||||||
|
"""Тест создания модели"""
|
||||||
|
model, variables, operators_set = create_model()
|
||||||
|
assert model is not None
|
||||||
|
assert variables is not None
|
||||||
|
assert operators_set is not None
|
||||||
|
assert len(operators_set) == len(OPERATORS)
|
||||||
|
|
||||||
|
def test_variables_count(self):
|
||||||
|
"""Тест количества переменных"""
|
||||||
|
model, variables, operators_set = create_model()
|
||||||
|
expected_count = len(get_days()) * len(SHIFTS) * len(OPERATORS)
|
||||||
|
assert len(variables) == expected_count
|
||||||
|
|
||||||
|
def test_operators_set(self):
|
||||||
|
"""Тест множества операторов"""
|
||||||
|
model, variables, operators_set = create_model()
|
||||||
|
assert operators_set == set(OPERATORS)
|
||||||
|
|
||||||
|
def test_model_has_constraints(self):
|
||||||
|
"""Тест наличия ограничений"""
|
||||||
|
model, variables, operators_set = create_model()
|
||||||
|
# Модель должна иметь ограничения
|
||||||
|
assert model.NumConstraints() > 0
|
||||||
|
|
||||||
|
def test_model_has_hints(self):
|
||||||
|
"""Тест наличия подсказок"""
|
||||||
|
model, variables, operators_set = create_model()
|
||||||
|
# Модель должна иметь подсказки
|
||||||
|
assert model.NumHints() > 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestSolveModel:
|
||||||
|
"""Тесты для функции solve_model"""
|
||||||
|
|
||||||
|
def test_solve_returns_bool(self):
|
||||||
|
"""Тест возврата булевого значения"""
|
||||||
|
result = solve_model()
|
||||||
|
assert isinstance(result, bool)
|
||||||
|
|
||||||
|
def test_solve_with_valid_config(self):
|
||||||
|
"""Тест решения с валидной конфигурацией"""
|
||||||
|
# Это может занять время, поэтому тестуем только факт вызова
|
||||||
|
try:
|
||||||
|
result = solve_model()
|
||||||
|
# Результат может быть True или False в зависимости от конфигурации
|
||||||
|
assert isinstance(result, bool)
|
||||||
|
except Exception as e:
|
||||||
|
pytest.fail(f"Ошибка при решении модели: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetSolution:
|
||||||
|
"""Тесты для функции get_solution"""
|
||||||
|
|
||||||
|
def test_solution_structure(self):
|
||||||
|
"""Тест структуры решения"""
|
||||||
|
schedule = get_solution()
|
||||||
|
assert isinstance(schedule, dict)
|
||||||
|
assert len(schedule) > 0
|
||||||
|
|
||||||
|
def test_solution_days(self):
|
||||||
|
"""Тест дней в решении"""
|
||||||
|
schedule = get_solution()
|
||||||
|
days = get_days()
|
||||||
|
assert set(schedule.keys()) == set(days)
|
||||||
|
|
||||||
|
def test_solution_shifts(self):
|
||||||
|
"""Тест смен в решении"""
|
||||||
|
schedule = get_solution()
|
||||||
|
for day, day_shifts in schedule.items():
|
||||||
|
assert isinstance(day_shifts, dict)
|
||||||
|
assert len(day_shifts) == len(SHIFTS)
|
||||||
|
|
||||||
|
def test_solution_operators(self):
|
||||||
|
"""Тест операторов в решении"""
|
||||||
|
schedule = get_solution()
|
||||||
|
for day, day_shifts in schedule.items():
|
||||||
|
for shift_idx, shift_data in day_shifts.items():
|
||||||
|
assert isinstance(shift_data, dict)
|
||||||
|
assert len(shift_data) == len(OPERATORS)
|
||||||
|
|
||||||
|
def test_solution_values(self):
|
||||||
|
"""Тест значений в решении"""
|
||||||
|
schedule = get_solution()
|
||||||
|
for day, day_shifts in schedule.items():
|
||||||
|
for shift_idx, shift_data in day_shifts.items():
|
||||||
|
for op_name, value in shift_data.items():
|
||||||
|
assert isinstance(value, bool)
|
||||||
|
assert value in [True, False]
|
||||||
|
|
||||||
|
def test_solution_consistency(self):
|
||||||
|
"""Тест согласованности решения"""
|
||||||
|
schedule = get_solution()
|
||||||
|
# Каждый день и смена должны иметь ровно одного оператора
|
||||||
|
for day, day_shifts in schedule.items():
|
||||||
|
for shift_idx, shift_data in day_shifts.items():
|
||||||
|
active_operators = [op for op, value in shift_data.items() if value]
|
||||||
|
assert len(active_operators) == 1
|
||||||
Reference in New Issue
Block a user