import 'package:adhd_scheduler_core/scheduler_core.dart'; import 'package:test/test.dart'; void main() { group('FreeSlotService', () { final now = DateTime(2026, 6, 19, 8); const service = FreeSlotService(); test('creates and updates scheduled Free Slot records', () { final freeSlot = service.create( id: 'rest', title: 'Rest', projectId: 'health', start: DateTime(2026, 6, 19, 9), end: DateTime(2026, 6, 19, 9, 30), createdAt: now, ); final updated = service.update( freeSlot: freeSlot, title: 'Recovery time', start: DateTime(2026, 6, 19, 10), end: DateTime(2026, 6, 19, 11), updatedAt: now.add(const Duration(minutes: 1)), ); expect(freeSlot.type, TaskType.freeSlot); expect(freeSlot.status, TaskStatus.planned); expect(freeSlot.durationMinutes, 30); expect(freeSlot.scheduledStart, DateTime(2026, 6, 19, 9)); expect(freeSlot.scheduledEnd, DateTime(2026, 6, 19, 9, 30)); expect(updated.type, TaskType.freeSlot); expect(updated.title, 'Recovery time'); expect(updated.durationMinutes, 60); expect(updated.scheduledStart, DateTime(2026, 6, 19, 10)); expect(updated.scheduledEnd, DateTime(2026, 6, 19, 11)); expect(updated.updatedAt, now.add(const Duration(minutes: 1))); }); test('rejects invalid Free Slot updates', () { final flexible = flexibleTask( id: 'flexible', createdAt: now, start: DateTime(2026, 6, 19, 9), durationMinutes: 30, ); expect( () => service.update( freeSlot: flexible, start: DateTime(2026, 6, 19, 10), end: DateTime(2026, 6, 19, 11), ), throwsArgumentError, ); expect( () => service.create( id: 'bad', title: 'Bad rest', start: DateTime(2026, 6, 19, 10), end: DateTime(2026, 6, 19, 10), createdAt: now, ), throwsA(isA()), ); }); test('explicit required overlap returns typed conflict without moving rest', () { final freeSlot = service.create( id: 'rest', title: 'Rest', start: DateTime(2026, 6, 19, 9), end: DateTime(2026, 6, 19, 10), createdAt: now, ); final required = requiredTask( id: 'appointment', type: TaskType.inflexible, createdAt: now, ); final result = service.scheduleRequiredCommitment( input: inputFor([freeSlot, required]), taskId: 'appointment', start: DateTime(2026, 6, 19, 9, 30), end: DateTime(2026, 6, 19, 10, 30), updatedAt: now, ); final scheduled = taskById(result.schedulingResult.tasks, 'appointment'); final rest = taskById(result.schedulingResult.tasks, 'rest'); expect( result.status, RequiredCommitmentScheduleStatus.scheduledWithProtectedFreeSlotConflict, ); expect(result.hasProtectedFreeSlotConflict, isTrue); expect(result.protectedFreeSlotConflicts.single.freeSlotTaskId, 'rest'); expect(scheduled.scheduledStart, DateTime(2026, 6, 19, 9, 30)); expect(scheduled.scheduledEnd, DateTime(2026, 6, 19, 10, 30)); expect(rest.scheduledStart, freeSlot.scheduledStart); expect(rest.scheduledEnd, freeSlot.scheduledEnd); expect(result.schedulingResult.overlaps.single.taskId, 'appointment'); expect(result.schedulingResult.changes.single.taskId, 'appointment'); }); test('required boundary touch does not conflict with protected rest', () { final freeSlot = service.create( id: 'rest', title: 'Rest', start: DateTime(2026, 6, 19, 9), end: DateTime(2026, 6, 19, 10), createdAt: now, ); final required = requiredTask( id: 'meds', type: TaskType.critical, createdAt: now, ); final result = service.scheduleRequiredCommitment( input: inputFor([freeSlot, required]), taskId: 'meds', start: DateTime(2026, 6, 19, 10), end: DateTime(2026, 6, 19, 10, 15), updatedAt: now, ); expect(result.status, RequiredCommitmentScheduleStatus.scheduled); expect(result.protectedFreeSlotConflicts, isEmpty); expect(result.schedulingResult.overlaps, isEmpty); }); }); group('Free Slot automatic scheduling protection', () { final now = DateTime(2026, 6, 19, 8); const service = FreeSlotService(); const engine = SchedulingEngine(); test('insertion skips multiple adjacent Free Slots', () { final backlog = Task.quickCapture( id: 'backlog', title: 'Make call', createdAt: now, ).copyWith(durationMinutes: 15); final firstRest = service.create( id: 'rest-1', title: 'Rest 1', start: DateTime(2026, 6, 19, 9), end: DateTime(2026, 6, 19, 9, 30), createdAt: now, ); final secondRest = service.create( id: 'rest-2', title: 'Rest 2', start: DateTime(2026, 6, 19, 9, 30), end: DateTime(2026, 6, 19, 10), createdAt: now, ); final result = engine.insertBacklogTaskIntoNextAvailableSlot( input: inputFor([backlog, firstRest, secondRest]), taskId: 'backlog', updatedAt: now, ); expect(taskById(result.tasks, 'backlog').scheduledStart, DateTime(2026, 6, 19, 10)); expect(taskById(result.tasks, 'rest-1').scheduledStart, firstRest.scheduledStart); expect(taskById(result.tasks, 'rest-2').scheduledStart, secondRest.scheduledStart); expect(result.changes.map((change) => change.taskId), ['backlog']); }); test('push skips a Free Slot at the push boundary', () { final flexible = flexibleTask( id: 'flexible', createdAt: now, start: DateTime(2026, 6, 19, 9), durationMinutes: 30, ); final rest = service.create( id: 'rest', title: 'Rest', start: DateTime(2026, 6, 19, 9, 30), end: DateTime(2026, 6, 19, 10), createdAt: now, ); final result = engine.pushFlexibleTaskToNextAvailableSlot( input: inputFor([flexible, rest]), taskId: 'flexible', updatedAt: now, ); expect(taskById(result.tasks, 'flexible').scheduledStart, DateTime(2026, 6, 19, 10)); expect(taskById(result.tasks, 'flexible').scheduledEnd, DateTime(2026, 6, 19, 10, 30)); expect( taskById(result.tasks, 'rest').scheduledStart, rest.scheduledStart); }); test('tomorrow placement preserves Free Slots at window edges', () { final task = flexibleTask( id: 'today', createdAt: now, start: DateTime(2026, 6, 19, 9), durationMinutes: 30, ); final startRest = service.create( id: 'start-rest', title: 'Start rest', start: DateTime(2026, 6, 20, 9), end: DateTime(2026, 6, 20, 9, 30), createdAt: now, ); final endRest = service.create( id: 'end-rest', title: 'End rest', start: DateTime(2026, 6, 20, 10, 30), end: DateTime(2026, 6, 20, 11), createdAt: now, ); final result = engine.pushFlexibleTaskToTomorrowTopOfQueue( input: SchedulingInput( tasks: [task, startRest, endRest], window: SchedulingWindow( start: DateTime(2026, 6, 20, 9), end: DateTime(2026, 6, 20, 11), ), ), taskId: 'today', updatedAt: now, ); expect(taskById(result.tasks, 'today').scheduledStart, DateTime(2026, 6, 20, 9, 30)); expect(taskById(result.tasks, 'start-rest').scheduledStart, startRest.scheduledStart); expect(taskById(result.tasks, 'end-rest').scheduledStart, endRest.scheduledStart); }); test('whole-window Free Slot leaves no fit and does not move tasks', () { final flexible = flexibleTask( id: 'flexible', createdAt: now, start: DateTime(2026, 6, 19, 9), durationMinutes: 30, ); final rest = service.create( id: 'whole-rest', title: 'Whole morning rest', start: DateTime(2026, 6, 20, 9), end: DateTime(2026, 6, 20, 10), createdAt: now, ); final result = engine.rollOverUnfinishedFlexibleTasks( input: SchedulingInput( tasks: [flexible, rest], window: SchedulingWindow( start: DateTime(2026, 6, 20, 9), end: DateTime(2026, 6, 20, 10), ), ), sourceWindow: SchedulingWindow( start: DateTime(2026, 6, 19, 0), end: DateTime(2026, 6, 20, 0), ), updatedAt: now, ); expect(taskById(result.tasks, 'flexible'), same(flexible)); expect(taskById(result.tasks, 'whole-rest'), same(rest)); expect(result.changes, isEmpty); expect(result.notices.single.type, SchedulingNoticeType.overflow); expect(taskById(result.tasks, 'whole-rest').scheduledStart, rest.scheduledStart); }); }); group('Free Slot timeline metadata', () { final now = DateTime(2026, 6, 19, 8); const service = FreeSlotService(); test('Free Slot read model is visible without flexible quick actions', () { final freeSlot = service.create( id: 'rest', title: 'Rest', projectId: 'health', start: DateTime(2026, 6, 19, 9), end: DateTime(2026, 6, 19, 10), createdAt: now, ); final item = TimelineItemMapper().fromTask(freeSlot); expect(item.taskType, TaskType.freeSlot); expect(item.backgroundToken, TimelineBackgroundToken.freeSlot); expect(item.category, TimelineItemCategory.taskCard); expect(item.quickActions, isEmpty); expect(item.start, freeSlot.scheduledStart); expect(item.end, freeSlot.scheduledEnd); }); }); } SchedulingInput inputFor(List tasks) { return SchedulingInput( tasks: tasks, window: SchedulingWindow( start: DateTime(2026, 6, 19, 9), end: DateTime(2026, 6, 19, 12), ), ); } Task flexibleTask({ required String id, required DateTime createdAt, required DateTime start, required int durationMinutes, }) { return Task.quickCapture( id: id, title: id, createdAt: createdAt, ).copyWith( status: TaskStatus.planned, durationMinutes: durationMinutes, scheduledStart: start, scheduledEnd: start.add(Duration(minutes: durationMinutes)), ); } Task requiredTask({ required String id, required TaskType type, required DateTime createdAt, }) { return Task( id: id, title: id, projectId: 'required', type: type, status: TaskStatus.planned, priority: PriorityLevel.high, createdAt: createdAt, updatedAt: createdAt, ); } Task taskById(List tasks, String id) { return tasks.singleWhere((task) => task.id == id); }