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); const utcTimeZoneId = 'UTC'; const utcResolver = FixedOffsetTimeZoneResolver.utc(); 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, PriorityLevel.medium); 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('quick capture requires a title', () { expect( () => Task.quickCapture( id: 'capture-1', title: ' ', createdAt: now, ), throwsArgumentError, ); }); test('backlog filters use derived categories over a unified list', () { final inbox = Task.quickCapture( id: 'inbox-1', title: 'Buy stamps', createdAt: now, ); final pushed = Task.quickCapture( id: 'pushed-1', title: 'Fold laundry', createdAt: now, projectId: 'home', ).copyWith( stats: const TaskStatistics(manuallyPushedCount: 1), ); final criticalMissed = Task.quickCapture( id: 'critical-1', title: 'Pay bill', createdAt: now, projectId: 'money', type: TaskType.critical, ).copyWith( stats: const TaskStatistics(missedCount: 1), ); final wishlist = Task.quickCapture( id: 'wishlist-1', title: 'Try new notebook', createdAt: now, projectId: 'ideas', backlogTags: const {BacklogTag.wishlist}, ); final stale = Task.quickCapture( id: 'stale-1', title: 'Sort cables', createdAt: now.subtract(const Duration(days: 40)), projectId: 'home', ).copyWith(updatedAt: now.subtract(const Duration(days: 40))); final planned = Task.quickCapture( id: 'planned-1', title: 'Scheduled task', createdAt: now, ).copyWith(status: TaskStatus.planned); final view = BacklogView( tasks: [inbox, pushed, criticalMissed, wishlist, stale, planned], now: now, ); expect(view.backlogTasks.map((task) => task.id), [ 'inbox-1', 'pushed-1', 'critical-1', 'wishlist-1', 'stale-1', ]); expect(view.filter(BacklogFilter.inbox).map((task) => task.id), [ 'inbox-1', ]); expect(view.filter(BacklogFilter.pushed).map((task) => task.id), [ 'pushed-1', ]); expect(view.filter(BacklogFilter.criticalMissed).map((task) => task.id), [ 'critical-1', ]); expect(view.filter(BacklogFilter.wishlist).map((task) => task.id), [ 'wishlist-1', ]); expect(view.filter(BacklogFilter.stale).map((task) => task.id), [ 'stale-1', ]); expect(view.filter(BacklogFilter.noRewardSet).map((task) => task.id), [ 'inbox-1', 'pushed-1', 'critical-1', 'wishlist-1', 'stale-1', ]); }); test('backlog sorting uses backlog keys instead of original schedule', () { final older = Task.quickCapture( id: 'older', title: 'Older backlog item', createdAt: DateTime(2026, 6, 1), ).copyWith( scheduledStart: DateTime(2026, 6, 19, 18), scheduledEnd: DateTime(2026, 6, 19, 18, 15), ); final newer = Task.quickCapture( id: 'newer', title: 'Newer backlog item', createdAt: DateTime(2026, 6, 10), ).copyWith( scheduledStart: DateTime(2026, 6, 19, 9), scheduledEnd: DateTime(2026, 6, 19, 9, 15), ); final movedOlder = const SchedulingEngine().moveToBacklog( older, updatedAt: now, ); final movedNewer = const SchedulingEngine().moveToBacklog( newer, updatedAt: now, ); final view = BacklogView(tasks: [movedNewer, movedOlder], now: now); expect(movedOlder.scheduledStart, isNull); expect(movedNewer.scheduledStart, isNull); expect(view.sorted(BacklogSortKey.age).map((task) => task.id), [ 'older', 'newer', ]); }); test('backlog sorting supports priority reward effort project and pushes', () { final lowPriority = Task.quickCapture( id: 'low', title: 'Low', createdAt: now, priority: PriorityLevel.low, reward: RewardLevel.low, difficulty: DifficultyLevel.hard, projectId: 'zeta', ); final highPriority = Task.quickCapture( id: 'high', title: 'High', createdAt: now, priority: PriorityLevel.high, reward: RewardLevel.high, difficulty: DifficultyLevel.easy, projectId: 'alpha', ).copyWith( stats: const TaskStatistics(autoPushedCount: 2), ); final mediumPriority = Task.quickCapture( id: 'medium', title: 'Medium', createdAt: now, priority: PriorityLevel.medium, reward: RewardLevel.medium, difficulty: DifficultyLevel.medium, projectId: 'beta', ).copyWith( stats: const TaskStatistics(manuallyPushedCount: 1), ); final view = BacklogView( tasks: [lowPriority, mediumPriority, highPriority], now: now, ); expect(view.sorted(BacklogSortKey.priority).map((task) => task.id), [ 'high', 'medium', 'low', ]); expect( view.sorted(BacklogSortKey.rewardVsEffort).map((task) => task.id), ['high', 'medium', 'low'], ); expect(view.sorted(BacklogSortKey.project).map((task) => task.id), [ 'high', 'medium', 'low', ]); expect(view.sorted(BacklogSortKey.timesPushed).map((task) => task.id), [ 'high', 'medium', 'low', ]); }); test('backlog staleness markers cover default thresholds', () { final fresh = Task.quickCapture( id: 'fresh', title: 'Fresh', createdAt: now.subtract(const Duration(days: 7)), ); final aging = Task.quickCapture( id: 'aging', title: 'Aging', createdAt: now.subtract(const Duration(days: 8)), ); final stale = Task.quickCapture( id: 'stale', title: 'Stale', createdAt: now.subtract(const Duration(days: 31)), ); final view = BacklogView(tasks: [fresh, aging, stale], now: now); expect(view.stalenessMarkerFor(fresh), BacklogStalenessMarker.green); expect(view.stalenessMarkerFor(aging), BacklogStalenessMarker.blue); expect(view.stalenessMarkerFor(stale), BacklogStalenessMarker.purple); }); test('backlog staleness markers support configurable thresholds', () { final task = Task.quickCapture( id: 'custom', title: 'Custom', createdAt: now.subtract(const Duration(days: 5)), ); final view = BacklogView( tasks: [task], now: now, stalenessSettings: const BacklogStalenessSettings( greenMaxAge: Duration(days: 2), blueMaxAge: Duration(days: 4), ), ); expect(view.stalenessMarkerFor(task), BacklogStalenessMarker.purple); }); test('unchecked quick capture goes to backlog', () { const service = QuickCaptureService(); final result = service.capture( QuickCaptureRequest( id: 'capture-1', title: 'Buy stamps', createdAt: now, ), updatedAt: now, ); expect(result.status, QuickCaptureStatus.addedToBacklog); expect(result.isValid, isTrue); expect(result.task.status, TaskStatus.backlog); expect(result.task.scheduledStart, isNull); expect(result.task.priority, PriorityLevel.medium); expect(result.task.reward, RewardLevel.notSet); expect(result.schedulingResult, isNull); }); test('checked quick capture with duration schedules next available slot', () { const service = QuickCaptureService(); final plannedFlexible = Task.quickCapture( id: 'flexible-1', title: 'Existing flexible task', createdAt: now, ).copyWith( status: TaskStatus.planned, durationMinutes: 30, scheduledStart: DateTime(2026, 6, 19, 13), scheduledEnd: DateTime(2026, 6, 19, 13, 30), ); final result = service.capture( QuickCaptureRequest( id: 'capture-1', title: 'Buy stamps', createdAt: now, addToNextAvailableSlot: true, durationMinutes: 15, projectId: 'errands', reward: RewardLevel.low, ), schedulingInput: SchedulingInput( tasks: [plannedFlexible], window: SchedulingWindow( start: DateTime(2026, 6, 19, 13), end: DateTime(2026, 6, 19, 14), ), ), updatedAt: now, ); expect(result.status, QuickCaptureStatus.scheduled); expect(result.task.status, TaskStatus.planned); expect(result.task.projectId, 'errands'); expect(result.task.reward, RewardLevel.low); expect(result.task.scheduledStart, DateTime(2026, 6, 19, 13)); expect(result.task.scheduledEnd, DateTime(2026, 6, 19, 13, 15)); expect( result.schedulingResult!.changes.map((change) => change.taskId), ['capture-1', 'flexible-1'], ); }); test('checked quick capture without duration returns validation result', () { const service = QuickCaptureService(); final result = service.capture( QuickCaptureRequest( id: 'capture-1', title: 'Buy stamps', createdAt: now, addToNextAvailableSlot: true, ), schedulingInput: SchedulingInput( tasks: const [], window: SchedulingWindow( start: DateTime(2026, 6, 19, 13), end: DateTime(2026, 6, 19, 14), ), ), updatedAt: now, ); expect(result.status, QuickCaptureStatus.validationError); expect(result.isValid, isFalse); expect(result.task.status, TaskStatus.backlog); expect(result.task.scheduledStart, isNull); expect(result.messages, [ 'Duration is required to schedule quick capture.', ]); expect(result.schedulingResult, isNull); }); test('project defaults apply while task overrides remain possible', () { final 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); }); test('locked block model supports one-off and recurring constraints', () { final oneOff = LockedBlock( id: 'appointment', name: 'Appointment', startTime: ClockTime(hour: 13, minute: 0), endTime: ClockTime(hour: 14, minute: 0), date: CivilDate(2026, 6, 19), createdAt: now, updatedAt: now, projectId: 'health', ); final workHours = LockedBlock( id: 'work-hours', name: 'Work', startTime: ClockTime(hour: 9, minute: 0), endTime: ClockTime(hour: 17, minute: 0), recurrence: LockedBlockRecurrence.weekly( weekdays: { LockedWeekday.monday, LockedWeekday.tuesday, LockedWeekday.wednesday, LockedWeekday.thursday, LockedWeekday.friday, }, ), createdAt: now, updatedAt: now, ); expect(oneOff.isRecurring, isFalse); expect(oneOff.hiddenByDefault, isTrue); expect(oneOff.projectId, 'health'); expect(workHours.isRecurring, isTrue); expect(workHours.hiddenByDefault, isTrue); expect(workHours.recurrence!.occursOn(CivilDate(2026, 6, 19)), isTrue); expect(workHours.recurrence!.occursOn(CivilDate(2026, 6, 20)), isFalse); }); test('locked block override removes only one date', () { final override = LockedBlockOverride.remove( id: 'friday-off', lockedBlockId: 'work-hours', date: CivilDate(2026, 6, 19), createdAt: now, ); expect(override.type, LockedBlockOverrideType.remove); expect( override.appliesTo( blockId: 'work-hours', occurrenceDate: CivilDate(2026, 6, 19), ), isTrue, ); expect( override.appliesTo( blockId: 'work-hours', occurrenceDate: CivilDate(2026, 6, 20), ), isFalse, ); }); test('locked block override modifies one date without recurrence changes', () { final recurrence = LockedBlockRecurrence.weekly( weekdays: { LockedWeekday.monday, LockedWeekday.tuesday, LockedWeekday.wednesday, LockedWeekday.thursday, LockedWeekday.friday, }, ); final workHours = LockedBlock( id: 'work-hours', name: 'Work', startTime: ClockTime(hour: 9, minute: 0), endTime: ClockTime(hour: 17, minute: 0), recurrence: recurrence, createdAt: now, updatedAt: now, ); final halfDay = LockedBlockOverride.replace( id: 'half-day', lockedBlockId: 'work-hours', date: CivilDate(2026, 6, 19), startTime: ClockTime(hour: 9, minute: 0), endTime: ClockTime(hour: 12, minute: 0), createdAt: now, ); final interval = halfDay.intervalForDate( targetDate: CivilDate(2026, 6, 19), timeZoneId: utcTimeZoneId, timeZoneResolver: utcResolver, ); expect(interval, isNotNull); expect(interval!.start, DateTime.utc(2026, 6, 19, 9)); expect(interval.end, DateTime.utc(2026, 6, 19, 12)); expect(workHours.recurrence, same(recurrence)); expect(workHours.recurrence!.occursOn(CivilDate(2026, 6, 19)), isTrue); }); test('locked block override can add surprise locked time', () { final surprise = LockedBlockOverride.add( id: 'surprise-lock', date: CivilDate(2026, 6, 19), name: 'Urgent errand', startTime: ClockTime(hour: 15, minute: 0), endTime: ClockTime(hour: 16, minute: 0), createdAt: now, projectId: 'home', ); final interval = surprise.intervalForDate( targetDate: CivilDate(2026, 6, 19), timeZoneId: utcTimeZoneId, timeZoneResolver: utcResolver, ); expect(surprise.type, LockedBlockOverrideType.add); expect(surprise.lockedBlockId, isNull); expect(surprise.projectId, 'home'); expect(interval, isNotNull); expect(interval!.start, DateTime.utc(2026, 6, 19, 15)); expect(interval.end, DateTime.utc(2026, 6, 19, 16)); }); test('locked block expansion returns weekday occurrences', () { final workHours = LockedBlock( id: 'work-hours', name: 'Work', startTime: ClockTime(hour: 9, minute: 0), endTime: ClockTime(hour: 17, minute: 0), recurrence: LockedBlockRecurrence.weekly( weekdays: { LockedWeekday.monday, LockedWeekday.tuesday, LockedWeekday.wednesday, LockedWeekday.thursday, LockedWeekday.friday, }, ), createdAt: now, updatedAt: now, ); final fridayExpansion = expandLockedBlocksForDay( blocks: [workHours], overrides: const [], date: CivilDate(2026, 6, 19), timeZoneId: utcTimeZoneId, timeZoneResolver: utcResolver, ); final saturdayExpansion = expandLockedBlocksForDay( blocks: [workHours], overrides: const [], date: CivilDate(2026, 6, 20), timeZoneId: utcTimeZoneId, timeZoneResolver: utcResolver, ); final occurrence = fridayExpansion.occurrences.single; expect(fridayExpansion.date, CivilDate(2026, 6, 19)); expect(occurrence.lockedBlockId, 'work-hours'); expect(occurrence.overrideId, isNull); expect(occurrence.name, 'Work'); expect(occurrence.interval.start, DateTime.utc(2026, 6, 19, 9)); expect(occurrence.interval.end, DateTime.utc(2026, 6, 19, 17)); expect(occurrence.hiddenByDefault, isTrue); expect(occurrence.schedulingInterval.label, 'Work'); expect(saturdayExpansion.occurrences, isEmpty); }); test('locked block expansion applies remove and replace overrides', () { final workHours = LockedBlock( id: 'work-hours', name: 'Work', startTime: ClockTime(hour: 9, minute: 0), endTime: ClockTime(hour: 17, minute: 0), recurrence: LockedBlockRecurrence.weekly( weekdays: { LockedWeekday.monday, LockedWeekday.tuesday, LockedWeekday.wednesday, LockedWeekday.thursday, LockedWeekday.friday, }, ), hiddenByDefault: true, createdAt: now, updatedAt: now, ); final fridayOff = LockedBlockOverride.remove( id: 'friday-off', lockedBlockId: 'work-hours', date: CivilDate(2026, 6, 19), createdAt: now, ); final halfDay = LockedBlockOverride.replace( id: 'half-day', lockedBlockId: 'work-hours', date: CivilDate(2026, 6, 22), name: 'Half-day work', startTime: ClockTime(hour: 9, minute: 0), endTime: ClockTime(hour: 12, minute: 0), hiddenByDefault: false, createdAt: now.add(const Duration(minutes: 1)), ); final removed = expandLockedBlocksForDay( blocks: [workHours], overrides: [fridayOff, halfDay], date: CivilDate(2026, 6, 19), timeZoneId: utcTimeZoneId, timeZoneResolver: utcResolver, ); final modified = expandLockedBlocksForDay( blocks: [workHours], overrides: [fridayOff, halfDay], date: CivilDate(2026, 6, 22), timeZoneId: utcTimeZoneId, timeZoneResolver: utcResolver, ); final normal = expandLockedBlocksForDay( blocks: [workHours], overrides: [fridayOff, halfDay], date: CivilDate(2026, 6, 23), timeZoneId: utcTimeZoneId, timeZoneResolver: utcResolver, ); final halfDayOccurrence = modified.occurrences.single; expect(removed.occurrences, isEmpty); expect(halfDayOccurrence.lockedBlockId, 'work-hours'); expect(halfDayOccurrence.overrideId, 'half-day'); expect(halfDayOccurrence.name, 'Half-day work'); expect(halfDayOccurrence.interval.start, DateTime.utc(2026, 6, 22, 9)); expect(halfDayOccurrence.interval.end, DateTime.utc(2026, 6, 22, 12)); expect(halfDayOccurrence.hiddenByDefault, isFalse); expect( normal.occurrences.single.interval.end, DateTime.utc(2026, 6, 23, 17), ); expect(workHours.recurrence!.occursOn(CivilDate(2026, 6, 22)), isTrue); }); test('locked block expansion includes surprise locked time overrides', () { final surprise = LockedBlockOverride.add( id: 'surprise-lock', date: CivilDate(2026, 6, 19), name: 'Urgent errand', startTime: ClockTime(hour: 15, minute: 0), endTime: ClockTime(hour: 16, minute: 0), createdAt: now, projectId: 'home', ); final expansion = expandLockedBlocksForDay( blocks: const [], overrides: [surprise], date: CivilDate(2026, 6, 19), timeZoneId: utcTimeZoneId, timeZoneResolver: utcResolver, ); final occurrence = expansion.occurrences.single; expect(occurrence.lockedBlockId, isNull); expect(occurrence.overrideId, 'surprise-lock'); expect(occurrence.name, 'Urgent errand'); expect(occurrence.projectId, 'home'); expect(occurrence.interval.start, DateTime.utc(2026, 6, 19, 15)); expect(occurrence.interval.end, DateTime.utc(2026, 6, 19, 16)); }); test( 'locked hour completion tracking covers task entirely inside locked time', () { final task = Task.quickCapture( id: 'task-1', title: 'Handle paperwork', createdAt: now, ).copyWith( status: TaskStatus.completed, scheduledStart: DateTime(2026, 6, 19, 13, 15), scheduledEnd: DateTime(2026, 6, 19, 13, 45), ); final updated = trackCompletedDuringLockedHours( task: task, lockedIntervals: [ TimeInterval( start: DateTime(2026, 6, 19, 13), end: DateTime(2026, 6, 19, 14), label: 'work', ), ], ); expect(updated.stats.completedDuringLockedHoursCount, 1); expect(updated.stats.completedDuringLockedHoursMinutes, 30); expect(task.stats.completedDuringLockedHoursCount, 0); }); test('locked hour completion tracking covers partial overlap', () { final task = Task.quickCapture( id: 'task-1', title: 'Handle paperwork', createdAt: now, ).copyWith( status: TaskStatus.completed, scheduledStart: DateTime(2026, 6, 19, 13, 45), scheduledEnd: DateTime(2026, 6, 19, 14, 15), ); final minutes = completedDuringLockedHoursMinutes( task: task, lockedIntervals: [ TimeInterval( start: DateTime(2026, 6, 19, 13), end: DateTime(2026, 6, 19, 14), label: 'work', ), ], ); expect(minutes, 15); }); test('locked hour completion tracking ignores no-overlap tasks', () { final task = Task.quickCapture( id: 'task-1', title: 'Handle paperwork', createdAt: now, ).copyWith( status: TaskStatus.completed, scheduledStart: DateTime(2026, 6, 19, 14), scheduledEnd: DateTime(2026, 6, 19, 14, 30), ); final updated = trackCompletedDuringLockedHours( task: task, lockedIntervals: [ TimeInterval( start: DateTime(2026, 6, 19, 13), end: DateTime(2026, 6, 19, 14), label: 'work', ), ], ); expect(updated, same(task)); expect(updated.stats.completedDuringLockedHoursCount, 0); expect(updated.stats.completedDuringLockedHoursMinutes, 0); }); test('locked hour completion tracking covers surprise task overlap', () { final task = Task.quickCapture( id: 'surprise-1', title: 'Unexpected call', createdAt: now, ).copyWith( type: TaskType.surprise, scheduledStart: DateTime(2026, 6, 19, 13, 45), scheduledEnd: DateTime(2026, 6, 19, 14, 15), ); final updated = trackCompletedDuringLockedHours( task: task, lockedIntervals: [ TimeInterval( start: DateTime(2026, 6, 19, 13), end: DateTime(2026, 6, 19, 14), label: 'work', ), ], ); expect(updated.stats.completedDuringLockedHoursCount, 1); expect(updated.stats.completedDuringLockedHoursMinutes, 15); }); test('locked hour completion tracking does not double count overlaps', () { final task = Task.quickCapture( id: 'task-1', title: 'Handle paperwork', createdAt: now, ).copyWith( status: TaskStatus.completed, scheduledStart: DateTime(2026, 6, 19, 13), scheduledEnd: DateTime(2026, 6, 19, 14), ); final minutes = completedDuringLockedHoursMinutes( task: task, lockedIntervals: [ TimeInterval( start: DateTime(2026, 6, 19, 13), end: DateTime(2026, 6, 19, 13, 45), label: 'work', ), TimeInterval( start: DateTime(2026, 6, 19, 13, 30), end: DateTime(2026, 6, 19, 14), label: 'appointment', ), ], ); expect(minutes, 60); }); test('flexible quick action done marks task completed', () { const service = FlexibleTaskActionService(); final task = Task.quickCapture( id: 'task-1', title: 'Handle paperwork', createdAt: now, ).copyWith( status: TaskStatus.planned, scheduledStart: DateTime(2026, 6, 19, 13), scheduledEnd: DateTime(2026, 6, 19, 13, 30), ); final result = service.apply( task: task, action: FlexibleTaskQuickAction.done, updatedAt: now, ); expect(result.action, FlexibleTaskQuickAction.done); expect(result.task.status, TaskStatus.completed); expect(result.task.scheduledStart, DateTime(2026, 6, 19, 13)); expect(result.task.updatedAt, now); expect(result.changedTask, isTrue); }); test('flexible quick action backlog clears schedule placement', () { const service = FlexibleTaskActionService(); final task = Task.quickCapture( id: 'task-1', title: 'Handle paperwork', createdAt: now, ).copyWith( status: TaskStatus.planned, scheduledStart: DateTime(2026, 6, 19, 13), scheduledEnd: DateTime(2026, 6, 19, 13, 30), ); final result = service.apply( task: task, action: FlexibleTaskQuickAction.backlog, updatedAt: now, ); expect(result.action, FlexibleTaskQuickAction.backlog); expect(result.task.status, TaskStatus.backlog); expect(result.task.scheduledStart, isNull); expect(result.task.scheduledEnd, isNull); expect(result.task.stats.movedToBacklogCount, 1); expect(result.changedTask, isTrue); }); test('flexible quick action push exposes domain destinations', () { const service = FlexibleTaskActionService(); final task = Task.quickCapture( id: 'task-1', title: 'Handle paperwork', createdAt: now, ).copyWith(status: TaskStatus.planned); final result = service.apply( task: task, action: FlexibleTaskQuickAction.push, updatedAt: now, ); expect(result.task, same(task)); expect(result.pushDestinations, const [ PushDestination.nextAvailableSlot, PushDestination.tomorrowTopOfQueue, PushDestination.backlog, ]); expect(PushDestination.values, const [ PushDestination.nextAvailableSlot, PushDestination.tomorrowTopOfQueue, PushDestination.backlog, ]); expect(result.changedTask, isFalse); }); test('flexible quick action break up starts child task flow', () { const service = FlexibleTaskActionService(); final task = Task.quickCapture( id: 'task-1', title: 'Handle paperwork', createdAt: now, ).copyWith(status: TaskStatus.planned); final result = service.apply( task: task, action: FlexibleTaskQuickAction.breakUp, updatedAt: now, ); expect(result.task, same(task)); expect(result.startsChildTaskFlow, isTrue); expect(result.changedTask, isFalse); }); test('push destination next available uses scheduling engine', () { const service = FlexibleTaskActionService(); final task = Task.quickCapture( id: 'task-1', title: 'Handle paperwork', createdAt: now, ).copyWith( status: TaskStatus.planned, durationMinutes: 15, scheduledStart: DateTime(2026, 6, 19, 13), scheduledEnd: DateTime(2026, 6, 19, 13, 15), ); final result = service.applyPushDestination( destination: PushDestination.nextAvailableSlot, 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.schedulingResult.tasks.single; expect(result.destination, PushDestination.nextAvailableSlot); expect(pushed.scheduledStart, DateTime(2026, 6, 19, 13, 30)); expect(pushed.scheduledEnd, DateTime(2026, 6, 19, 13, 45)); expect(pushed.stats.manuallyPushedCount, 1); expect(result.schedulingResult.notices.single.type, SchedulingNoticeType.moved); }); test('push destination tomorrow sets tomorrow top-of-queue metadata', () { const service = FlexibleTaskActionService(); final task = Task.quickCapture( id: 'task-1', title: 'Handle paperwork', createdAt: now, ).copyWith( status: TaskStatus.planned, durationMinutes: 15, scheduledStart: DateTime(2026, 6, 19, 13), scheduledEnd: DateTime(2026, 6, 19, 13, 15), ); final tomorrowTask = Task.quickCapture( id: 'tomorrow-1', title: 'Fold laundry', createdAt: now, ).copyWith( status: TaskStatus.planned, durationMinutes: 30, scheduledStart: DateTime(2026, 6, 20, 9), scheduledEnd: DateTime(2026, 6, 20, 9, 30), ); final result = service.applyPushDestination( destination: PushDestination.tomorrowTopOfQueue, input: SchedulingInput( tasks: [task, tomorrowTask], window: SchedulingWindow( start: DateTime(2026, 6, 20, 9), end: DateTime(2026, 6, 20, 10), ), ), taskId: 'task-1', updatedAt: now, ); final pushed = result.schedulingResult.tasks.singleWhere( (current) => current.id == 'task-1', ); final shiftedTomorrow = result.schedulingResult.tasks.singleWhere( (current) => current.id == 'tomorrow-1', ); expect(result.destination, PushDestination.tomorrowTopOfQueue); expect(result.placesAtTomorrowTopOfQueue, isTrue); expect(pushed.scheduledStart, DateTime(2026, 6, 20, 9)); expect(pushed.scheduledEnd, DateTime(2026, 6, 20, 9, 15)); expect(shiftedTomorrow.scheduledStart, DateTime(2026, 6, 20, 9, 15)); expect(shiftedTomorrow.scheduledEnd, DateTime(2026, 6, 20, 9, 45)); }); test('push destination backlog clears schedule placement', () { const service = FlexibleTaskActionService(); final task = Task.quickCapture( id: 'task-1', title: 'Handle paperwork', createdAt: now, ).copyWith( status: TaskStatus.planned, scheduledStart: DateTime(2026, 6, 19, 13), scheduledEnd: DateTime(2026, 6, 19, 13, 30), ); final result = service.applyPushDestination( destination: PushDestination.backlog, input: SchedulingInput( tasks: [task], window: SchedulingWindow( start: DateTime(2026, 6, 19, 13), end: DateTime(2026, 6, 19, 14), ), ), taskId: 'task-1', updatedAt: now, ); final moved = result.schedulingResult.tasks.single; expect(result.destination, PushDestination.backlog); expect(moved.status, TaskStatus.backlog); expect(moved.scheduledStart, isNull); expect(moved.scheduledEnd, isNull); expect(moved.stats.movedToBacklogCount, 1); expect(result.schedulingResult.changes.single.nextStart, isNull); }); }); group('SchedulingEngine starter behavior', () { final now = DateTime(2026, 6, 19, 12); const utcTimeZoneId = 'UTC'; const utcResolver = FixedOffsetTimeZoneResolver.utc(); 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.single, same(flexible)); expect(() => result.tasks.add(flexible), throwsUnsupportedError); 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 respects expanded locked blocks', () { final backlogTask = Task.quickCapture( id: 'backlog-1', title: 'Start laundry', createdAt: now, ).copyWith(durationMinutes: 15); final workHours = LockedBlock( id: 'work-hours', name: 'Work', startTime: ClockTime(hour: 13, minute: 0), endTime: ClockTime(hour: 13, minute: 20), recurrence: LockedBlockRecurrence.weekly( weekdays: {LockedWeekday.friday}, ), hiddenByDefault: true, createdAt: now, updatedAt: now, ); final replacement = LockedBlockOverride.replace( id: 'longer-work', lockedBlockId: 'work-hours', date: CivilDate(2026, 6, 19), startTime: ClockTime(hour: 13, minute: 0), endTime: ClockTime(hour: 13, minute: 30), createdAt: now, ); final lockedIntervals = lockedSchedulingIntervalsForDay( blocks: [workHours], overrides: [replacement], date: CivilDate(2026, 6, 19), timeZoneId: utcTimeZoneId, timeZoneResolver: utcResolver, ); final result = engine.insertBacklogTaskIntoNextAvailableSlot( input: SchedulingInput( tasks: [backlogTask], window: SchedulingWindow( start: DateTime.utc(2026, 6, 19, 13), end: DateTime.utc(2026, 6, 19, 14), ), lockedIntervals: lockedIntervals, ), taskId: 'backlog-1', updatedAt: now, ); final inserted = result.tasks.single; expect(lockedIntervals.single.start, DateTime.utc(2026, 6, 19, 13)); expect(lockedIntervals.single.end, DateTime.utc(2026, 6, 19, 13, 30)); expect(inserted.scheduledStart, DateTime.utc(2026, 6, 19, 13, 30)); expect(inserted.scheduledEnd, DateTime.utc(2026, 6, 19, 13, 45)); }); 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.single, same(backlogTask)); expect(() => result.tasks.add(backlogTask), throwsUnsupportedError); 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)); }); }); }