- New overlap.py service with check_overlap(), check_overlap_for_create(), and check_overlap_for_edit() functions - Detects same-day time conflicts for a user's calendar - Checks both real (materialized) TimeSlots and virtual (plan-generated) slots - Excludes skipped/aborted slots from conflict checks - Edit scenario excludes the slot being edited from conflict candidates - Returns structured SlotConflict objects with human-readable messages - 24 passing tests covering no-conflict, conflict detection, inactive exclusion, edit self-exclusion, virtual slot overlap, and message content
375 lines
12 KiB
Python
375 lines
12 KiB
Python
"""Tests for BE-CAL-006: Calendar overlap detection.
|
|
|
|
Covers:
|
|
- No conflict when slots don't overlap
|
|
- Conflict detected for overlapping time ranges
|
|
- Create vs edit scenarios (edit excludes own slot)
|
|
- Skipped/aborted slots are not considered
|
|
- Virtual (plan-generated) slots are checked
|
|
- Edge cases: adjacent slots, exact same time, partial overlap
|
|
"""
|
|
|
|
import pytest
|
|
from datetime import date, time
|
|
|
|
from app.models.calendar import (
|
|
SchedulePlan,
|
|
SlotStatus,
|
|
SlotType,
|
|
EventType,
|
|
TimeSlot,
|
|
DayOfWeek,
|
|
)
|
|
from app.services.overlap import (
|
|
check_overlap,
|
|
check_overlap_for_create,
|
|
check_overlap_for_edit,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
TARGET_DATE = date(2026, 4, 1) # A Wednesday
|
|
USER_ID = 1
|
|
USER_ID_2 = 2
|
|
|
|
|
|
def _make_slot(db, *, scheduled_at, duration=30, status=SlotStatus.NOT_STARTED, user_id=USER_ID, slot_date=TARGET_DATE, plan_id=None):
|
|
"""Insert a real TimeSlot and return it."""
|
|
slot = TimeSlot(
|
|
user_id=user_id,
|
|
date=slot_date,
|
|
slot_type=SlotType.WORK,
|
|
estimated_duration=duration,
|
|
scheduled_at=scheduled_at,
|
|
status=status,
|
|
priority=0,
|
|
plan_id=plan_id,
|
|
)
|
|
db.add(slot)
|
|
db.flush()
|
|
return slot
|
|
|
|
|
|
def _make_plan(db, *, at_time, duration=30, user_id=USER_ID, on_day=None, is_active=True):
|
|
"""Insert a SchedulePlan and return it."""
|
|
plan = SchedulePlan(
|
|
user_id=user_id,
|
|
slot_type=SlotType.WORK,
|
|
estimated_duration=duration,
|
|
at_time=at_time,
|
|
on_day=on_day,
|
|
is_active=is_active,
|
|
)
|
|
db.add(plan)
|
|
db.flush()
|
|
return plan
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _ensure_users(seed):
|
|
"""All overlap tests need seeded users (id=1, id=2) for FK constraints."""
|
|
pass
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# No-conflict cases
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestNoConflict:
|
|
|
|
def test_empty_calendar(self, db):
|
|
"""No existing slots → no conflicts."""
|
|
conflicts = check_overlap_for_create(
|
|
db, USER_ID, TARGET_DATE, time(9, 0), 30,
|
|
)
|
|
assert conflicts == []
|
|
|
|
def test_adjacent_before(self, db):
|
|
"""Existing 09:00-09:30, proposed 09:30-10:00 → no overlap."""
|
|
_make_slot(db, scheduled_at=time(9, 0), duration=30)
|
|
db.commit()
|
|
|
|
conflicts = check_overlap_for_create(
|
|
db, USER_ID, TARGET_DATE, time(9, 30), 30,
|
|
)
|
|
assert conflicts == []
|
|
|
|
def test_adjacent_after(self, db):
|
|
"""Existing 10:00-10:30, proposed 09:30-10:00 → no overlap."""
|
|
_make_slot(db, scheduled_at=time(10, 0), duration=30)
|
|
db.commit()
|
|
|
|
conflicts = check_overlap_for_create(
|
|
db, USER_ID, TARGET_DATE, time(9, 30), 30,
|
|
)
|
|
assert conflicts == []
|
|
|
|
def test_different_user(self, db):
|
|
"""Slot for user 2 should not conflict with user 1's new slot."""
|
|
_make_slot(db, scheduled_at=time(9, 0), duration=30, user_id=USER_ID_2)
|
|
db.commit()
|
|
|
|
conflicts = check_overlap_for_create(
|
|
db, USER_ID, TARGET_DATE, time(9, 0), 30,
|
|
)
|
|
assert conflicts == []
|
|
|
|
def test_different_date(self, db):
|
|
"""Same time on a different date → no conflict."""
|
|
_make_slot(db, scheduled_at=time(9, 0), duration=30, slot_date=date(2026, 4, 2))
|
|
db.commit()
|
|
|
|
conflicts = check_overlap_for_create(
|
|
db, USER_ID, TARGET_DATE, time(9, 0), 30,
|
|
)
|
|
assert conflicts == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Conflict detection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestConflictDetected:
|
|
|
|
def test_exact_same_time(self, db):
|
|
"""Same start + same duration = overlap."""
|
|
_make_slot(db, scheduled_at=time(9, 0), duration=30)
|
|
db.commit()
|
|
|
|
conflicts = check_overlap_for_create(
|
|
db, USER_ID, TARGET_DATE, time(9, 0), 30,
|
|
)
|
|
assert len(conflicts) == 1
|
|
assert conflicts[0].conflicting_slot_id is not None
|
|
assert "overlaps" in conflicts[0].message
|
|
|
|
def test_partial_overlap_start(self, db):
|
|
"""Existing 09:00-09:30, proposed 09:15-09:45 → overlap."""
|
|
_make_slot(db, scheduled_at=time(9, 0), duration=30)
|
|
db.commit()
|
|
|
|
conflicts = check_overlap_for_create(
|
|
db, USER_ID, TARGET_DATE, time(9, 15), 30,
|
|
)
|
|
assert len(conflicts) == 1
|
|
|
|
def test_partial_overlap_end(self, db):
|
|
"""Existing 09:15-09:45, proposed 09:00-09:30 → overlap."""
|
|
_make_slot(db, scheduled_at=time(9, 15), duration=30)
|
|
db.commit()
|
|
|
|
conflicts = check_overlap_for_create(
|
|
db, USER_ID, TARGET_DATE, time(9, 0), 30,
|
|
)
|
|
assert len(conflicts) == 1
|
|
|
|
def test_proposed_contains_existing(self, db):
|
|
"""Proposed 09:00-10:00 contains existing 09:15-09:45."""
|
|
_make_slot(db, scheduled_at=time(9, 15), duration=30)
|
|
db.commit()
|
|
|
|
conflicts = check_overlap_for_create(
|
|
db, USER_ID, TARGET_DATE, time(9, 0), 50,
|
|
)
|
|
assert len(conflicts) == 1
|
|
|
|
def test_existing_contains_proposed(self, db):
|
|
"""Existing 09:00-10:00 contains proposed 09:15-09:30."""
|
|
_make_slot(db, scheduled_at=time(9, 0), duration=50)
|
|
db.commit()
|
|
|
|
conflicts = check_overlap_for_create(
|
|
db, USER_ID, TARGET_DATE, time(9, 15), 15,
|
|
)
|
|
assert len(conflicts) == 1
|
|
|
|
def test_multiple_conflicts(self, db):
|
|
"""Proposed overlaps with two existing slots."""
|
|
_make_slot(db, scheduled_at=time(9, 0), duration=30)
|
|
_make_slot(db, scheduled_at=time(9, 20), duration=30)
|
|
db.commit()
|
|
|
|
conflicts = check_overlap_for_create(
|
|
db, USER_ID, TARGET_DATE, time(9, 10), 30,
|
|
)
|
|
assert len(conflicts) == 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Inactive slots excluded
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestInactiveExcluded:
|
|
|
|
def test_skipped_slot_ignored(self, db):
|
|
"""Skipped slot at same time should not cause conflict."""
|
|
_make_slot(db, scheduled_at=time(9, 0), duration=30, status=SlotStatus.SKIPPED)
|
|
db.commit()
|
|
|
|
conflicts = check_overlap_for_create(
|
|
db, USER_ID, TARGET_DATE, time(9, 0), 30,
|
|
)
|
|
assert conflicts == []
|
|
|
|
def test_aborted_slot_ignored(self, db):
|
|
"""Aborted slot at same time should not cause conflict."""
|
|
_make_slot(db, scheduled_at=time(9, 0), duration=30, status=SlotStatus.ABORTED)
|
|
db.commit()
|
|
|
|
conflicts = check_overlap_for_create(
|
|
db, USER_ID, TARGET_DATE, time(9, 0), 30,
|
|
)
|
|
assert conflicts == []
|
|
|
|
def test_ongoing_slot_conflicts(self, db):
|
|
"""Ongoing slot should still cause conflict."""
|
|
_make_slot(db, scheduled_at=time(9, 0), duration=30, status=SlotStatus.ONGOING)
|
|
db.commit()
|
|
|
|
conflicts = check_overlap_for_create(
|
|
db, USER_ID, TARGET_DATE, time(9, 0), 30,
|
|
)
|
|
assert len(conflicts) == 1
|
|
|
|
def test_deferred_slot_conflicts(self, db):
|
|
"""Deferred slot should still cause conflict."""
|
|
_make_slot(db, scheduled_at=time(9, 0), duration=30, status=SlotStatus.DEFERRED)
|
|
db.commit()
|
|
|
|
conflicts = check_overlap_for_create(
|
|
db, USER_ID, TARGET_DATE, time(9, 0), 30,
|
|
)
|
|
assert len(conflicts) == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Edit scenario (exclude own slot)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestEditExcludeSelf:
|
|
|
|
def test_edit_no_self_conflict(self, db):
|
|
"""Editing a slot to the same time should not conflict with itself."""
|
|
slot = _make_slot(db, scheduled_at=time(9, 0), duration=30)
|
|
db.commit()
|
|
|
|
conflicts = check_overlap_for_edit(
|
|
db, USER_ID, slot.id, TARGET_DATE, time(9, 0), 30,
|
|
)
|
|
assert conflicts == []
|
|
|
|
def test_edit_still_detects_others(self, db):
|
|
"""Editing a slot detects overlap with *other* slots."""
|
|
slot = _make_slot(db, scheduled_at=time(9, 0), duration=30)
|
|
_make_slot(db, scheduled_at=time(9, 30), duration=30)
|
|
db.commit()
|
|
|
|
# Move slot to overlap with the second one
|
|
conflicts = check_overlap_for_edit(
|
|
db, USER_ID, slot.id, TARGET_DATE, time(9, 20), 30,
|
|
)
|
|
assert len(conflicts) == 1
|
|
|
|
def test_edit_self_excluded_others_fine(self, db):
|
|
"""Moving a slot to a free spot should report no conflicts."""
|
|
slot = _make_slot(db, scheduled_at=time(9, 0), duration=30)
|
|
_make_slot(db, scheduled_at=time(10, 0), duration=30)
|
|
db.commit()
|
|
|
|
# Move to 11:00 — no overlap
|
|
conflicts = check_overlap_for_edit(
|
|
db, USER_ID, slot.id, TARGET_DATE, time(11, 0), 30,
|
|
)
|
|
assert conflicts == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Virtual slot (plan-generated) overlap
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestVirtualSlotOverlap:
|
|
|
|
def test_conflict_with_virtual_slot(self, db):
|
|
"""A plan that generates a virtual slot at 09:00 should conflict."""
|
|
# TARGET_DATE is 2026-04-01 (Wednesday)
|
|
_make_plan(db, at_time=time(9, 0), duration=30, on_day=DayOfWeek.WED)
|
|
db.commit()
|
|
|
|
conflicts = check_overlap_for_create(
|
|
db, USER_ID, TARGET_DATE, time(9, 0), 30,
|
|
)
|
|
assert len(conflicts) == 1
|
|
assert conflicts[0].conflicting_virtual_id is not None
|
|
assert conflicts[0].conflicting_slot_id is None
|
|
|
|
def test_no_conflict_with_inactive_plan(self, db):
|
|
"""Cancelled plan should not generate a virtual slot to conflict with."""
|
|
_make_plan(db, at_time=time(9, 0), duration=30, on_day=DayOfWeek.WED, is_active=False)
|
|
db.commit()
|
|
|
|
conflicts = check_overlap_for_create(
|
|
db, USER_ID, TARGET_DATE, time(9, 0), 30,
|
|
)
|
|
assert conflicts == []
|
|
|
|
def test_no_conflict_with_non_matching_plan(self, db):
|
|
"""Plan for Monday should not generate a virtual slot on Wednesday."""
|
|
_make_plan(db, at_time=time(9, 0), duration=30, on_day=DayOfWeek.MON)
|
|
db.commit()
|
|
|
|
conflicts = check_overlap_for_create(
|
|
db, USER_ID, TARGET_DATE, time(9, 0), 30,
|
|
)
|
|
assert conflicts == []
|
|
|
|
def test_materialized_plan_not_double_counted(self, db):
|
|
"""A plan that's already materialized should only be counted as a real slot, not also virtual."""
|
|
plan = _make_plan(db, at_time=time(9, 0), duration=30, on_day=DayOfWeek.WED)
|
|
_make_slot(db, scheduled_at=time(9, 0), duration=30, plan_id=plan.id)
|
|
db.commit()
|
|
|
|
conflicts = check_overlap_for_create(
|
|
db, USER_ID, TARGET_DATE, time(9, 0), 30,
|
|
)
|
|
# Should only have 1 conflict (the real slot), not 2
|
|
assert len(conflicts) == 1
|
|
assert conflicts[0].conflicting_slot_id is not None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Conflict message content
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestConflictMessage:
|
|
|
|
def test_message_has_time_info(self, db):
|
|
"""Conflict message should include time range information."""
|
|
_make_slot(db, scheduled_at=time(9, 0), duration=30)
|
|
db.commit()
|
|
|
|
conflicts = check_overlap_for_create(
|
|
db, USER_ID, TARGET_DATE, time(9, 15), 30,
|
|
)
|
|
assert len(conflicts) == 1
|
|
msg = conflicts[0].message
|
|
assert "09:00" in msg
|
|
assert "overlaps" in msg
|
|
|
|
def test_to_dict(self, db):
|
|
"""SlotConflict.to_dict() should return a proper dict."""
|
|
_make_slot(db, scheduled_at=time(9, 0), duration=30)
|
|
db.commit()
|
|
|
|
conflicts = check_overlap_for_create(
|
|
db, USER_ID, TARGET_DATE, time(9, 0), 30,
|
|
)
|
|
d = conflicts[0].to_dict()
|
|
assert "scheduled_at" in d
|
|
assert "estimated_duration" in d
|
|
assert "slot_type" in d
|
|
assert "message" in d
|
|
assert "conflicting_slot_id" in d
|