"""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