97 lines
3.1 KiB
Python
97 lines
3.1 KiB
Python
|
|
"""Slot computation (§8.1): working hours − busy, quantization, notice, horizon."""
|
|||
|
|
|
|||
|
|
from datetime import date, datetime
|
|||
|
|
from zoneinfo import ZoneInfo
|
|||
|
|
|
|||
|
|
from gogo.scheduling.slots import compute_slots, subtract_busy
|
|||
|
|
|
|||
|
|
TZ = ZoneInfo("Europe/Sarajevo")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def dt(day: int, h: int, m: int = 0) -> datetime:
|
|||
|
|
return datetime(2026, 7, day, h, m, tzinfo=TZ)
|
|||
|
|
|
|||
|
|
|
|||
|
|
WH = {
|
|||
|
|
"mon": [["09:00", "13:00"], ["14:00", "18:00"]], # lunch break
|
|||
|
|
"tue": [["09:00", "18:00"]],
|
|||
|
|
"wed": [["09:00", "18:00"]],
|
|||
|
|
"thu": [["09:00", "18:00"]],
|
|||
|
|
"fri": [["09:00", "18:00"]],
|
|||
|
|
"sat": [["09:00", "14:00"]],
|
|||
|
|
"sun": [],
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# Mon 2026-07-13 .. Sun 2026-07-19
|
|||
|
|
NOW = dt(13, 8, 0) # Monday 08:00
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_subtract_busy_splits_interval():
|
|||
|
|
free = subtract_busy((dt(13, 9), dt(13, 13)), [(dt(13, 10), dt(13, 11))])
|
|||
|
|
assert free == [(dt(13, 9), dt(13, 10)), (dt(13, 11), dt(13, 13))]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_subtract_busy_no_overlap():
|
|||
|
|
free = subtract_busy((dt(13, 9), dt(13, 13)), [(dt(13, 14), dt(13, 15))])
|
|||
|
|
assert free == [(dt(13, 9), dt(13, 13))]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_slots_respect_break_and_duration():
|
|||
|
|
slots = compute_slots(
|
|||
|
|
working_hours=WH, busy=[], duration_min=60, date_from=date(2026, 7, 13),
|
|||
|
|
date_to=date(2026, 7, 13), tz=TZ, now=NOW, min_notice_hours=0, max_slots=50,
|
|||
|
|
)
|
|||
|
|
starts = [s.start for s in slots]
|
|||
|
|
# no slot may span the 13:00-14:00 break
|
|||
|
|
assert dt(13, 13) not in starts
|
|||
|
|
for s in slots:
|
|||
|
|
assert not (s.start < dt(13, 14) and s.end > dt(13, 13))
|
|||
|
|
assert slots[0].start == dt(13, 9)
|
|||
|
|
assert all((s.end - s.start).total_seconds() == 3600 for s in slots)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_min_notice_pushes_first_slot():
|
|||
|
|
slots = compute_slots(
|
|||
|
|
working_hours=WH, busy=[], duration_min=30, date_from=date(2026, 7, 13),
|
|||
|
|
date_to=date(2026, 7, 13), tz=TZ, now=dt(13, 9, 5), min_notice_hours=2,
|
|||
|
|
)
|
|||
|
|
assert slots[0].start >= dt(13, 11, 5)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_busy_blocks_remove_slots():
|
|||
|
|
slots = compute_slots(
|
|||
|
|
working_hours=WH, busy=[(dt(14, 9), dt(14, 12))], duration_min=60,
|
|||
|
|
date_from=date(2026, 7, 14), date_to=date(2026, 7, 14), tz=TZ, now=NOW,
|
|||
|
|
min_notice_hours=0,
|
|||
|
|
)
|
|||
|
|
assert slots[0].start == dt(14, 12)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_sunday_closed():
|
|||
|
|
slots = compute_slots(
|
|||
|
|
working_hours=WH, busy=[], duration_min=30, date_from=date(2026, 7, 19),
|
|||
|
|
date_to=date(2026, 7, 19), tz=TZ, now=NOW, min_notice_hours=0,
|
|||
|
|
)
|
|||
|
|
assert slots == []
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_max_days_ahead_horizon():
|
|||
|
|
slots = compute_slots(
|
|||
|
|
working_hours=WH, busy=[], duration_min=30, date_from=date(2026, 7, 13),
|
|||
|
|
date_to=date(2026, 8, 30), tz=TZ, now=NOW, min_notice_hours=0,
|
|||
|
|
max_days_ahead=2, max_slots=1000,
|
|||
|
|
)
|
|||
|
|
assert max(s.start for s in slots).date() <= date(2026, 7, 15)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_buffer_extends_step():
|
|||
|
|
slots = compute_slots(
|
|||
|
|
working_hours=WH, busy=[], duration_min=45, buffer_min=15,
|
|||
|
|
date_from=date(2026, 7, 14), date_to=date(2026, 7, 14), tz=TZ, now=NOW,
|
|||
|
|
min_notice_hours=0,
|
|||
|
|
)
|
|||
|
|
# steps of 60 (45+15), but slot end = start + 45
|
|||
|
|
assert slots[0].start == dt(14, 9)
|
|||
|
|
assert slots[0].end == dt(14, 9, 45)
|
|||
|
|
assert slots[1].start == dt(14, 10)
|