import 'package:adhd_scheduler_core/scheduler_core.dart'; import 'package:test/test.dart'; void main() { group('Domain model', () { final now = DateTime(2026, 6, 19, 12); test('core enum values are importable', () { expect(TaskType.locked, TaskType.locked); expect(TaskStatus.noLongerRelevant, TaskStatus.noLongerRelevant); expect(PriorityLevel.veryHigh, PriorityLevel.veryHigh); expect(RewardLevel.notSet, RewardLevel.notSet); expect(DifficultyLevel.notSet, DifficultyLevel.notSet); expect(ReminderProfile.gentle, ReminderProfile.gentle); }); test('quick capture uses neutral defaults without schedule details', () { final task = Task.quickCapture( id: 'capture-1', title: 'Buy stamps', createdAt: now, ); expect(task.projectId, 'inbox'); expect(task.type, TaskType.flexible); expect(task.status, TaskStatus.backlog); expect(task.priority, isNull); expect(task.reward, RewardLevel.notSet); expect(task.difficulty, DifficultyLevel.notSet); expect(task.durationMinutes, isNull); expect(task.scheduledStart, isNull); expect(task.scheduledEnd, isNull); expect(task.createdAt, now); expect(task.updatedAt, now); }); test('project defaults apply while task overrides remain possible', () { const project = ProjectProfile( id: 'home', name: 'Home', colorKey: 'green', defaultPriority: PriorityLevel.high, defaultReward: RewardLevel.medium, defaultDifficulty: DifficultyLevel.easy, defaultReminderProfile: ReminderProfile.persistent, defaultDurationMinutes: 25, ); final defaulted = project.createTask( id: 'task-1', title: 'Clean desk', createdAt: now, ); final overridden = project.createTask( id: 'task-2', title: 'Fold laundry', createdAt: now, priority: PriorityLevel.low, reward: RewardLevel.high, difficulty: DifficultyLevel.hard, durationMinutes: 10, ); expect(defaulted.projectId, 'home'); expect(defaulted.priority, PriorityLevel.high); expect(defaulted.reward, RewardLevel.medium); expect(defaulted.difficulty, DifficultyLevel.easy); expect(defaulted.durationMinutes, 25); expect(overridden.priority, PriorityLevel.low); expect(overridden.reward, RewardLevel.high); expect(overridden.difficulty, DifficultyLevel.hard); expect(overridden.durationMinutes, 10); }); test('statistics increments preserve existing values immutably', () { const stats = TaskStatistics(missedCount: 2); final updated = stats.incrementCompletedDuringLockedHours(15); expect(stats.completedDuringLockedHoursCount, 0); expect(stats.completedDuringLockedHoursMinutes, 0); expect(updated.missedCount, 2); expect(updated.completedDuringLockedHoursCount, 1); expect(updated.completedDuringLockedHoursMinutes, 15); }); }); group('SchedulingEngine starter behavior', () { final now = DateTime(2026, 6, 19, 12); const engine = SchedulingEngine(); Task flexibleTask() { return Task( id: 'task-1', title: 'Clean coffee maker', projectId: 'home', type: TaskType.flexible, status: TaskStatus.planned, priority: PriorityLevel.medium, durationMinutes: 15, createdAt: now, updatedAt: now, ); } Task taskById(List tasks, String id) { return tasks.singleWhere((task) => task.id == id); } test('scheduling input classifies in-memory task groups', () { final flexible = flexibleTask().copyWith( scheduledStart: DateTime(2026, 6, 19, 13), scheduledEnd: DateTime(2026, 6, 19, 13, 15), ); final locked = flexibleTask().copyWith( id: 'locked-1', type: TaskType.locked, scheduledStart: DateTime(2026, 6, 19, 14), scheduledEnd: DateTime(2026, 6, 19, 15), ); final critical = flexibleTask().copyWith( id: 'critical-1', type: TaskType.critical, scheduledStart: DateTime(2026, 6, 19, 16), scheduledEnd: DateTime(2026, 6, 19, 16, 30), ); final input = SchedulingInput( tasks: [flexible, locked, critical], window: SchedulingWindow( start: DateTime(2026, 6, 19, 12), end: DateTime(2026, 6, 19, 18), ), ); expect(input.flexibleTasks.map((task) => task.id), ['task-1']); expect(input.lockedTasks.map((task) => task.id), ['locked-1']); expect(input.requiredVisibleTasks.map((task) => task.id), ['critical-1']); expect(input.blockedIntervals.map((interval) => interval.label), [ 'locked-1', 'critical-1', ]); }); test('analyzeSchedule reports flexible overlaps without moving tasks', () { final flexible = flexibleTask().copyWith( scheduledStart: DateTime(2026, 6, 19, 13), scheduledEnd: DateTime(2026, 6, 19, 13, 30), ); final tasks = [flexible]; final result = engine.analyzeSchedule( SchedulingInput( tasks: tasks, window: SchedulingWindow( start: DateTime(2026, 6, 19, 12), end: DateTime(2026, 6, 19, 18), ), lockedIntervals: [ TimeInterval( start: DateTime(2026, 6, 19, 13, 15), end: DateTime(2026, 6, 19, 14), label: 'locked-block', ), ], ), ); expect(result.tasks, same(tasks)); expect(result.changes, isEmpty); expect(result.overlaps.single.taskId, 'task-1'); expect(result.overlaps.single.blockedInterval.label, 'locked-block'); expect(result.notices.single.type, SchedulingNoticeType.overlap); expect(result.notices.single.taskId, 'task-1'); }); test('scheduling result can describe exact task movement', () { final moved = flexibleTask().copyWith( scheduledStart: DateTime(2026, 6, 19, 14), scheduledEnd: DateTime(2026, 6, 19, 14, 15), ); final result = SchedulingResult( tasks: [moved], notices: const [ SchedulingNotice( 'Flexible task moved.', type: SchedulingNoticeType.moved, taskId: 'task-1', ), ], changes: [ SchedulingChange( taskId: 'task-1', previousStart: DateTime(2026, 6, 19, 13), previousEnd: DateTime(2026, 6, 19, 13, 15), nextStart: DateTime(2026, 6, 19, 14), nextEnd: DateTime(2026, 6, 19, 14, 15), ), ], ); expect(result.tasks.single.scheduledStart, DateTime(2026, 6, 19, 14)); expect(result.changes.single.previousStart, DateTime(2026, 6, 19, 13)); expect(result.changes.single.nextEnd, DateTime(2026, 6, 19, 14, 15)); expect(result.notices.single.type, SchedulingNoticeType.moved); }); test('insert backlog task into an open 20-minute slot', () { final backlogTask = Task.quickCapture( id: 'backlog-1', title: 'Start laundry', createdAt: now, ).copyWith(durationMinutes: 15); final result = engine.insertBacklogTaskIntoNextAvailableSlot( input: SchedulingInput( tasks: [backlogTask], window: SchedulingWindow( start: DateTime(2026, 6, 19, 13), end: DateTime(2026, 6, 19, 13, 20), ), ), taskId: 'backlog-1', updatedAt: now, ); final inserted = result.tasks.single; expect(inserted.status, TaskStatus.planned); expect(inserted.scheduledStart, DateTime(2026, 6, 19, 13)); expect(inserted.scheduledEnd, DateTime(2026, 6, 19, 13, 15)); expect(inserted.stats.restoredFromBacklogCount, 1); expect(result.changes.single.taskId, 'backlog-1'); expect(result.notices.single.type, SchedulingNoticeType.moved); }); test('insert backlog task before flexible task and push later task', () { final backlogTask = Task.quickCapture( id: 'backlog-1', title: 'Start laundry', createdAt: now, ).copyWith(durationMinutes: 15); final plannedFlexible = flexibleTask().copyWith( id: 'flexible-1', durationMinutes: 30, scheduledStart: DateTime(2026, 6, 19, 13), scheduledEnd: DateTime(2026, 6, 19, 13, 30), ); final result = engine.insertBacklogTaskIntoNextAvailableSlot( input: SchedulingInput( tasks: [backlogTask, plannedFlexible], window: SchedulingWindow( start: DateTime(2026, 6, 19, 13), end: DateTime(2026, 6, 19, 14), ), ), taskId: 'backlog-1', updatedAt: now, ); final inserted = taskById(result.tasks, 'backlog-1'); final pushed = taskById(result.tasks, 'flexible-1'); expect(inserted.scheduledStart, DateTime(2026, 6, 19, 13)); expect(inserted.scheduledEnd, DateTime(2026, 6, 19, 13, 15)); expect(pushed.scheduledStart, DateTime(2026, 6, 19, 13, 15)); expect(pushed.scheduledEnd, DateTime(2026, 6, 19, 13, 45)); expect(pushed.stats.autoPushedCount, 1); expect(result.changes.map((change) => change.taskId), [ 'backlog-1', 'flexible-1', ]); }); test('insert backlog task skips locked time', () { final backlogTask = Task.quickCapture( id: 'backlog-1', title: 'Start laundry', createdAt: now, ).copyWith(durationMinutes: 15); final result = engine.insertBacklogTaskIntoNextAvailableSlot( input: SchedulingInput( tasks: [backlogTask], window: SchedulingWindow( start: DateTime(2026, 6, 19, 13), end: DateTime(2026, 6, 19, 14), ), lockedIntervals: [ TimeInterval( start: DateTime(2026, 6, 19, 13), end: DateTime(2026, 6, 19, 13, 20), label: 'appointment', ), ], ), taskId: 'backlog-1', updatedAt: now, ); final inserted = result.tasks.single; expect(inserted.scheduledStart, DateTime(2026, 6, 19, 13, 20)); expect(inserted.scheduledEnd, DateTime(2026, 6, 19, 13, 35)); }); test('insert backlog task does not move inflexible or critical blocks', () { final backlogTask = Task.quickCapture( id: 'backlog-1', title: 'Start laundry', createdAt: now, ).copyWith(durationMinutes: 15); final inflexibleTask = flexibleTask().copyWith( id: 'inflexible-1', type: TaskType.inflexible, scheduledStart: DateTime(2026, 6, 19, 13), scheduledEnd: DateTime(2026, 6, 19, 13, 30), ); final criticalTask = flexibleTask().copyWith( id: 'critical-1', type: TaskType.critical, scheduledStart: DateTime(2026, 6, 19, 14), scheduledEnd: DateTime(2026, 6, 19, 14, 30), ); final result = engine.insertBacklogTaskIntoNextAvailableSlot( input: SchedulingInput( tasks: [backlogTask, inflexibleTask, criticalTask], window: SchedulingWindow( start: DateTime(2026, 6, 19, 13), end: DateTime(2026, 6, 19, 15), ), ), taskId: 'backlog-1', updatedAt: now, ); final inserted = taskById(result.tasks, 'backlog-1'); final inflexible = taskById(result.tasks, 'inflexible-1'); final critical = taskById(result.tasks, 'critical-1'); expect(inserted.scheduledStart, DateTime(2026, 6, 19, 13, 30)); expect(inserted.scheduledEnd, DateTime(2026, 6, 19, 13, 45)); expect(inflexible.scheduledStart, DateTime(2026, 6, 19, 13)); expect(inflexible.scheduledEnd, DateTime(2026, 6, 19, 13, 30)); expect(critical.scheduledStart, DateTime(2026, 6, 19, 14)); expect(critical.scheduledEnd, DateTime(2026, 6, 19, 14, 30)); expect(result.changes.map((change) => change.taskId), ['backlog-1']); }); test('insert backlog task returns overflow when no slot fits today', () { final backlogTask = Task.quickCapture( id: 'backlog-1', title: 'Start laundry', createdAt: now, ).copyWith(durationMinutes: 15); final tasks = [backlogTask]; final result = engine.insertBacklogTaskIntoNextAvailableSlot( input: SchedulingInput( tasks: tasks, window: SchedulingWindow( start: DateTime(2026, 6, 19, 13), end: DateTime(2026, 6, 19, 13, 30), ), lockedIntervals: [ TimeInterval( start: DateTime(2026, 6, 19, 13), end: DateTime(2026, 6, 19, 13, 30), label: 'appointment', ), ], ), taskId: 'backlog-1', updatedAt: now, ); expect(result.tasks, same(tasks)); expect(result.changes, isEmpty); expect(result.notices.single.type, SchedulingNoticeType.overflow); expect(result.notices.single.taskId, 'backlog-1'); expect(backlogTask.status, TaskStatus.backlog); }); test('push flexible task moves it after current slot', () { final task = flexibleTask().copyWith( scheduledStart: DateTime(2026, 6, 19, 13), scheduledEnd: DateTime(2026, 6, 19, 13, 15), ); final result = engine.pushFlexibleTaskToNextAvailableSlot( input: SchedulingInput( tasks: [task], window: SchedulingWindow( start: DateTime(2026, 6, 19, 13), end: DateTime(2026, 6, 19, 14), ), ), taskId: 'task-1', updatedAt: now, ); final pushed = result.tasks.single; expect(pushed.scheduledStart, DateTime(2026, 6, 19, 13, 15)); expect(pushed.scheduledEnd, DateTime(2026, 6, 19, 13, 30)); expect(pushed.stats.manuallyPushedCount, 1); expect(result.changes.single.previousStart, DateTime(2026, 6, 19, 13)); expect(result.changes.single.nextStart, DateTime(2026, 6, 19, 13, 15)); expect(result.notices.single.type, SchedulingNoticeType.moved); }); test('push flexible task bumps downstream flexible tasks in order', () { final first = flexibleTask().copyWith( id: 'flexible-1', scheduledStart: DateTime(2026, 6, 19, 13), scheduledEnd: DateTime(2026, 6, 19, 13, 15), ); final second = flexibleTask().copyWith( id: 'flexible-2', durationMinutes: 30, scheduledStart: DateTime(2026, 6, 19, 13, 15), scheduledEnd: DateTime(2026, 6, 19, 13, 45), ); final third = flexibleTask().copyWith( id: 'flexible-3', scheduledStart: DateTime(2026, 6, 19, 13, 45), scheduledEnd: DateTime(2026, 6, 19, 14), ); final result = engine.pushFlexibleTaskToNextAvailableSlot( input: SchedulingInput( tasks: [first, second, third], window: SchedulingWindow( start: DateTime(2026, 6, 19, 13), end: DateTime(2026, 6, 19, 14, 30), ), ), taskId: 'flexible-1', updatedAt: now, ); final pushed = taskById(result.tasks, 'flexible-1'); final bumpedSecond = taskById(result.tasks, 'flexible-2'); final bumpedThird = taskById(result.tasks, 'flexible-3'); expect(pushed.scheduledStart, DateTime(2026, 6, 19, 13, 15)); expect(pushed.scheduledEnd, DateTime(2026, 6, 19, 13, 30)); expect(bumpedSecond.scheduledStart, DateTime(2026, 6, 19, 13, 30)); expect(bumpedSecond.scheduledEnd, DateTime(2026, 6, 19, 14)); expect(bumpedThird.scheduledStart, DateTime(2026, 6, 19, 14)); expect(bumpedThird.scheduledEnd, DateTime(2026, 6, 19, 14, 15)); expect(bumpedSecond.stats.autoPushedCount, 1); expect(bumpedThird.stats.autoPushedCount, 1); expect(result.changes.map((change) => change.taskId), [ 'flexible-1', 'flexible-2', 'flexible-3', ]); }); test('push flexible task respects locked intervals', () { final task = flexibleTask().copyWith( scheduledStart: DateTime(2026, 6, 19, 13), scheduledEnd: DateTime(2026, 6, 19, 13, 15), ); final result = engine.pushFlexibleTaskToNextAvailableSlot( input: SchedulingInput( tasks: [task], window: SchedulingWindow( start: DateTime(2026, 6, 19, 13), end: DateTime(2026, 6, 19, 14), ), lockedIntervals: [ TimeInterval( start: DateTime(2026, 6, 19, 13, 15), end: DateTime(2026, 6, 19, 13, 30), label: 'locked-block', ), ], ), taskId: 'task-1', updatedAt: now, ); final pushed = result.tasks.single; expect(pushed.scheduledStart, DateTime(2026, 6, 19, 13, 30)); expect(pushed.scheduledEnd, DateTime(2026, 6, 19, 13, 45)); }); test('push flexible task leaves required items fixed and visible', () { final task = flexibleTask().copyWith( scheduledStart: DateTime(2026, 6, 19, 13), scheduledEnd: DateTime(2026, 6, 19, 13, 15), ); final inflexibleTask = flexibleTask().copyWith( id: 'inflexible-1', type: TaskType.inflexible, scheduledStart: DateTime(2026, 6, 19, 13, 15), scheduledEnd: DateTime(2026, 6, 19, 13, 45), ); final criticalTask = flexibleTask().copyWith( id: 'critical-1', type: TaskType.critical, scheduledStart: DateTime(2026, 6, 19, 14), scheduledEnd: DateTime(2026, 6, 19, 14, 30), ); final result = engine.pushFlexibleTaskToNextAvailableSlot( input: SchedulingInput( tasks: [task, inflexibleTask, criticalTask], window: SchedulingWindow( start: DateTime(2026, 6, 19, 13), end: DateTime(2026, 6, 19, 15), ), ), taskId: 'task-1', updatedAt: now, ); final pushed = taskById(result.tasks, 'task-1'); final inflexible = taskById(result.tasks, 'inflexible-1'); final critical = taskById(result.tasks, 'critical-1'); expect(pushed.scheduledStart, DateTime(2026, 6, 19, 13, 45)); expect(pushed.scheduledEnd, DateTime(2026, 6, 19, 14)); expect(inflexible.scheduledStart, DateTime(2026, 6, 19, 13, 15)); expect(inflexible.scheduledEnd, DateTime(2026, 6, 19, 13, 45)); expect(critical.scheduledStart, DateTime(2026, 6, 19, 14)); expect(critical.scheduledEnd, DateTime(2026, 6, 19, 14, 30)); expect(result.changes.map((change) => change.taskId), ['task-1']); }); test('push flexible task to tomorrow top of queue', () { final task = flexibleTask().copyWith( scheduledStart: DateTime(2026, 6, 19, 13), scheduledEnd: DateTime(2026, 6, 19, 13, 15), ); final tomorrowFlexible = flexibleTask().copyWith( id: 'tomorrow-flexible-1', durationMinutes: 30, scheduledStart: DateTime(2026, 6, 20, 9), scheduledEnd: DateTime(2026, 6, 20, 9, 30), ); final result = engine.pushFlexibleTaskToTomorrowTopOfQueue( input: SchedulingInput( tasks: [task, tomorrowFlexible], window: SchedulingWindow( start: DateTime(2026, 6, 20, 9), end: DateTime(2026, 6, 20, 10), ), ), taskId: 'task-1', updatedAt: now, ); final movedToTomorrow = taskById(result.tasks, 'task-1'); final bumpedTomorrow = taskById(result.tasks, 'tomorrow-flexible-1'); expect(movedToTomorrow.scheduledStart, DateTime(2026, 6, 20, 9)); expect(movedToTomorrow.scheduledEnd, DateTime(2026, 6, 20, 9, 15)); expect(movedToTomorrow.stats.manuallyPushedCount, 1); expect(bumpedTomorrow.scheduledStart, DateTime(2026, 6, 20, 9, 15)); expect(bumpedTomorrow.scheduledEnd, DateTime(2026, 6, 20, 9, 45)); expect(bumpedTomorrow.stats.autoPushedCount, 1); expect(result.changes.map((change) => change.taskId), [ 'task-1', 'tomorrow-flexible-1', ]); expect(result.notices.first.message, 'Flexible task moved to tomorrow.'); }); test('push flexible task to tomorrow respects required and locked time', () { final task = flexibleTask().copyWith( scheduledStart: DateTime(2026, 6, 19, 13), scheduledEnd: DateTime(2026, 6, 19, 13, 15), ); final criticalTask = flexibleTask().copyWith( id: 'critical-1', type: TaskType.critical, scheduledStart: DateTime(2026, 6, 20, 9, 30), scheduledEnd: DateTime(2026, 6, 20, 10), ); final tomorrowFlexible = flexibleTask().copyWith( id: 'tomorrow-flexible-1', scheduledStart: DateTime(2026, 6, 20, 10), scheduledEnd: DateTime(2026, 6, 20, 10, 15), ); final result = engine.pushFlexibleTaskToTomorrowTopOfQueue( input: SchedulingInput( tasks: [task, criticalTask, tomorrowFlexible], window: SchedulingWindow( start: DateTime(2026, 6, 20, 9), end: DateTime(2026, 6, 20, 11), ), lockedIntervals: [ TimeInterval( start: DateTime(2026, 6, 20, 9), end: DateTime(2026, 6, 20, 9, 30), label: 'locked-block', ), ], ), taskId: 'task-1', updatedAt: now, ); final movedToTomorrow = taskById(result.tasks, 'task-1'); final critical = taskById(result.tasks, 'critical-1'); final bumpedTomorrow = taskById(result.tasks, 'tomorrow-flexible-1'); expect(movedToTomorrow.scheduledStart, DateTime(2026, 6, 20, 10)); expect(movedToTomorrow.scheduledEnd, DateTime(2026, 6, 20, 10, 15)); expect(critical.scheduledStart, DateTime(2026, 6, 20, 9, 30)); expect(critical.scheduledEnd, DateTime(2026, 6, 20, 10)); expect(bumpedTomorrow.scheduledStart, DateTime(2026, 6, 20, 10, 15)); expect(bumpedTomorrow.scheduledEnd, DateTime(2026, 6, 20, 10, 30)); expect(result.changes.map((change) => change.taskId), [ 'task-1', 'tomorrow-flexible-1', ]); }); test('rollover moves unfinished flexible tasks to tomorrow in order', () { final first = flexibleTask().copyWith( id: 'flexible-1', scheduledStart: DateTime(2026, 6, 19, 13), scheduledEnd: DateTime(2026, 6, 19, 13, 15), ); final second = flexibleTask().copyWith( id: 'flexible-2', status: TaskStatus.active, scheduledStart: DateTime(2026, 6, 19, 14), scheduledEnd: DateTime(2026, 6, 19, 14, 30), ); final tomorrowFlexible = flexibleTask().copyWith( id: 'tomorrow-flexible-1', durationMinutes: 15, scheduledStart: DateTime(2026, 6, 20, 9), scheduledEnd: DateTime(2026, 6, 20, 9, 15), ); final result = engine.rollOverUnfinishedFlexibleTasks( input: SchedulingInput( tasks: [first, second, tomorrowFlexible], window: SchedulingWindow( start: DateTime(2026, 6, 20, 9), end: DateTime(2026, 6, 20, 10, 30), ), ), updatedAt: now, ); final rolledFirst = taskById(result.tasks, 'flexible-1'); final rolledSecond = taskById(result.tasks, 'flexible-2'); final bumpedTomorrow = taskById(result.tasks, 'tomorrow-flexible-1'); expect(rolledFirst.status, TaskStatus.planned); expect(rolledFirst.scheduledStart, DateTime(2026, 6, 20, 9)); expect(rolledFirst.scheduledEnd, DateTime(2026, 6, 20, 9, 15)); expect(rolledSecond.status, TaskStatus.planned); expect(rolledSecond.scheduledStart, DateTime(2026, 6, 20, 9, 15)); expect(rolledSecond.scheduledEnd, DateTime(2026, 6, 20, 9, 45)); expect(bumpedTomorrow.scheduledStart, DateTime(2026, 6, 20, 9, 45)); expect(bumpedTomorrow.scheduledEnd, DateTime(2026, 6, 20, 10)); expect(result.changes.map((change) => change.taskId), [ 'flexible-1', 'flexible-2', 'tomorrow-flexible-1', ]); expect( result.notices.first.message, '2 unfinished flexible tasks were moved to tomorrow.', ); }); test('rollover excludes locked required completed and cancelled tasks', () { final flexible = flexibleTask().copyWith( id: 'flexible-1', scheduledStart: DateTime(2026, 6, 19, 13), scheduledEnd: DateTime(2026, 6, 19, 13, 15), ); final locked = flexibleTask().copyWith( id: 'locked-1', type: TaskType.locked, scheduledStart: DateTime(2026, 6, 19, 14), scheduledEnd: DateTime(2026, 6, 19, 15), ); final inflexible = flexibleTask().copyWith( id: 'inflexible-1', type: TaskType.inflexible, scheduledStart: DateTime(2026, 6, 19, 15), scheduledEnd: DateTime(2026, 6, 19, 15, 30), ); final critical = flexibleTask().copyWith( id: 'critical-1', type: TaskType.critical, scheduledStart: DateTime(2026, 6, 19, 16), scheduledEnd: DateTime(2026, 6, 19, 16, 30), ); final completed = flexibleTask().copyWith( id: 'completed-1', status: TaskStatus.completed, scheduledStart: DateTime(2026, 6, 19, 17), scheduledEnd: DateTime(2026, 6, 19, 17, 15), ); final cancelled = flexibleTask().copyWith( id: 'cancelled-1', status: TaskStatus.cancelled, scheduledStart: DateTime(2026, 6, 19, 18), scheduledEnd: DateTime(2026, 6, 19, 18, 15), ); final result = engine.rollOverUnfinishedFlexibleTasks( input: SchedulingInput( tasks: [flexible, locked, inflexible, critical, completed, cancelled], window: SchedulingWindow( start: DateTime(2026, 6, 20, 9), end: DateTime(2026, 6, 20, 10), ), ), updatedAt: now, ); expect(taskById(result.tasks, 'flexible-1').scheduledStart, DateTime(2026, 6, 20, 9)); expect(taskById(result.tasks, 'locked-1').scheduledStart, DateTime(2026, 6, 19, 14)); expect(taskById(result.tasks, 'inflexible-1').scheduledStart, DateTime(2026, 6, 19, 15)); expect(taskById(result.tasks, 'critical-1').scheduledStart, DateTime(2026, 6, 19, 16)); expect(taskById(result.tasks, 'completed-1').scheduledStart, DateTime(2026, 6, 19, 17)); expect(taskById(result.tasks, 'cancelled-1').scheduledStart, DateTime(2026, 6, 19, 18)); expect(result.changes.map((change) => change.taskId), ['flexible-1']); expect( result.notices.first.message, '1 unfinished flexible tasks were moved to tomorrow.', ); }); test('moveToBacklog clears active schedule placement', () { final task = flexibleTask().copyWith( scheduledStart: DateTime(2026, 6, 19, 18), scheduledEnd: DateTime(2026, 6, 19, 18, 15), ); final moved = engine.moveToBacklog(task, updatedAt: now); expect(moved.status, TaskStatus.backlog); expect(moved.scheduledStart, isNull); expect(moved.scheduledEnd, isNull); expect(moved.stats.movedToBacklogCount, 1); }); test('critical missed task goes to backlog', () { final task = flexibleTask().copyWith(type: TaskType.critical); final missed = engine.markMissed(task, updatedAt: now); expect(missed.status, TaskStatus.backlog); expect(missed.stats.missedCount, 1); expect(missed.stats.movedToBacklogCount, 1); }); test('inflexible missed task remains missed in place', () { final task = flexibleTask().copyWith(type: TaskType.inflexible); final missed = engine.markMissed(task, updatedAt: now); expect(missed.status, TaskStatus.missed); expect(missed.stats.missedCount, 1); }); test('findFirstOpenInterval skips blocked time', () { final slot = engine.findFirstOpenInterval( windowStart: DateTime(2026, 6, 19, 17), windowEnd: DateTime(2026, 6, 19, 20), duration: const Duration(minutes: 30), blocked: [ TimeInterval( start: DateTime(2026, 6, 19, 17), end: DateTime(2026, 6, 19, 18), label: 'Work overflow', ), ], ); expect(slot, isNotNull); expect(slot!.start, DateTime(2026, 6, 19, 18)); expect(slot.end, DateTime(2026, 6, 19, 18, 30)); }); }); }