import 'package:adhd_scheduler_core/scheduler_core.dart'; import 'package:test/test.dart'; void main() { group('Block 06.3 domain model regressions', () { final now = DateTime(2026, 6, 19, 12); test('quick capture trims titles before storing them', () { final task = Task.quickCapture( id: 'capture-1', title: ' Refill medication ', createdAt: now, ); expect(task.title, 'Refill medication'); }); test('copyWith can clear schedule while preserving omitted values', () { final scheduled = Task.quickCapture( id: 'task-1', title: 'Send invoice', createdAt: now, projectId: 'admin', priority: PriorityLevel.high, reward: RewardLevel.medium, ).copyWith( status: TaskStatus.planned, durationMinutes: 30, scheduledStart: DateTime(2026, 6, 19, 13), scheduledEnd: DateTime(2026, 6, 19, 13, 30), ); final cleared = scheduled.copyWith(clearSchedule: true); expect(cleared.scheduledStart, isNull); expect(cleared.scheduledEnd, isNull); expect(cleared.id, scheduled.id); expect(cleared.title, scheduled.title); expect(cleared.projectId, 'admin'); expect(cleared.priority, PriorityLevel.high); expect(cleared.reward, RewardLevel.medium); expect(cleared.durationMinutes, 30); }); test('project profile trims and validates task titles like quick capture', () { final project = ProjectProfile( id: 'health', name: 'Health', colorKey: 'green', ); final task = project.createTask( id: 'task-1', title: ' Book appointment ', createdAt: now, ); expect(task.title, 'Book appointment'); expect( () => project.createTask( id: 'task-2', title: ' ', createdAt: now, ), throwsArgumentError, ); }); }); group('Block 06.3 scheduling regressions', () { final now = DateTime(2026, 6, 19, 12); const engine = SchedulingEngine(); test('next-available insertion uses earliest fitting open slot', () { final backlog = backlogTask( id: 'new-task', title: 'Make call', createdAt: now, durationMinutes: 15, ); final laterFlexible = plannedFlexibleTask( id: 'later-flex', title: 'Fold laundry', createdAt: now, start: DateTime(2026, 6, 19, 10), durationMinutes: 30, ); final result = engine.insertBacklogTaskIntoNextAvailableSlot( input: SchedulingInput( tasks: [backlog, laterFlexible], window: SchedulingWindow( start: DateTime(2026, 6, 19, 9), end: DateTime(2026, 6, 19, 11), ), lockedIntervals: [ TimeInterval( start: DateTime(2026, 6, 19, 9), end: DateTime(2026, 6, 19, 9, 20), label: 'locked', ), ], ), taskId: 'new-task', updatedAt: now, ); final inserted = taskById(result.tasks, 'new-task'); expect(inserted.status, TaskStatus.planned); expect(inserted.scheduledStart, DateTime(2026, 6, 19, 9, 20)); expect(inserted.scheduledEnd, DateTime(2026, 6, 19, 9, 35)); }); test('no-fit insertion leaves the task backlogged and unplaced', () { final backlog = backlogTask( id: 'new-task', title: 'Make call', createdAt: now, durationMinutes: 45, ); final result = engine.insertBacklogTaskIntoNextAvailableSlot( input: SchedulingInput( tasks: [backlog], window: SchedulingWindow( start: DateTime(2026, 6, 19, 9), end: DateTime(2026, 6, 19, 9, 30), ), ), taskId: 'new-task', updatedAt: now, ); final unchanged = taskById(result.tasks, 'new-task'); expect(unchanged.status, TaskStatus.backlog); expect(unchanged.scheduledStart, isNull); expect(unchanged.scheduledEnd, isNull); expect(result.notices.single.type, SchedulingNoticeType.overflow); }); test('pushing flexible tasks preserves order without moving fixed items', () { final first = plannedFlexibleTask( id: 'first', title: 'Email reply', createdAt: now, start: DateTime(2026, 6, 19, 9), durationMinutes: 15, ); final second = plannedFlexibleTask( id: 'second', title: 'Laundry', createdAt: now, start: DateTime(2026, 6, 19, 9, 15), durationMinutes: 15, ); final inflexible = fixedTask( id: 'appointment', type: TaskType.inflexible, createdAt: now, start: DateTime(2026, 6, 19, 10), durationMinutes: 15, ); final critical = fixedTask( id: 'meds', type: TaskType.critical, createdAt: now, start: DateTime(2026, 6, 19, 10, 15), durationMinutes: 15, ); final locked = fixedTask( id: 'locked-task', type: TaskType.locked, createdAt: now, start: DateTime(2026, 6, 19, 9, 30), durationMinutes: 15, ); final result = engine.pushFlexibleTaskToNextAvailableSlot( input: SchedulingInput( tasks: [first, second, locked, inflexible, critical], window: SchedulingWindow( start: DateTime(2026, 6, 19, 9), end: DateTime(2026, 6, 19, 11), ), ), taskId: 'first', updatedAt: now, ); expect( result.changes.map((change) => change.taskId), ['first', 'second'], ); expect( taskById(result.tasks, 'first').scheduledStart, DateTime(2026, 6, 19, 9, 15), ); expect( taskById(result.tasks, 'second').scheduledStart, DateTime(2026, 6, 19, 9, 45), ); expect(taskById(result.tasks, 'appointment').type, TaskType.inflexible); expect(taskById(result.tasks, 'appointment').scheduledStart, inflexible.scheduledStart); expect(taskById(result.tasks, 'meds').type, TaskType.critical); expect(taskById(result.tasks, 'meds').scheduledStart, critical.scheduledStart); expect(taskById(result.tasks, 'locked-task').type, TaskType.locked); expect(taskById(result.tasks, 'locked-task').scheduledStart, locked.scheduledStart); }); test('rollover uses an explicit source window and ignores future tasks', () { final sourceWindow = SchedulingWindow( start: DateTime(2026, 6, 19), end: DateTime(2026, 6, 20), ); final targetWindow = SchedulingWindow( start: DateTime(2026, 6, 20, 9), end: DateTime(2026, 6, 20, 12), ); final sourcePlanned = plannedFlexibleTask( id: 'source-planned', title: 'Source planned', createdAt: now, start: DateTime(2026, 6, 19, 13), durationMinutes: 15, ); final sourceActive = plannedFlexibleTask( id: 'source-active', title: 'Source active', createdAt: now, start: DateTime(2026, 6, 19, 14), durationMinutes: 15, ).copyWith(status: TaskStatus.active); final completed = plannedFlexibleTask( id: 'completed', title: 'Completed', createdAt: now, start: DateTime(2026, 6, 19, 15), durationMinutes: 15, ).copyWith(status: TaskStatus.completed); final cancelled = plannedFlexibleTask( id: 'cancelled', title: 'Cancelled', createdAt: now, start: DateTime(2026, 6, 19, 16), durationMinutes: 15, ).copyWith(status: TaskStatus.cancelled); final backlogged = backlogTask( id: 'backlog', title: 'Already backlog', createdAt: now, durationMinutes: 15, ); final critical = fixedTask( id: 'critical', type: TaskType.critical, createdAt: now, start: DateTime(2026, 6, 19, 17), durationMinutes: 15, ); final inflexible = fixedTask( id: 'inflexible', type: TaskType.inflexible, createdAt: now, start: DateTime(2026, 6, 19, 18), durationMinutes: 15, ); final futureFlexible = plannedFlexibleTask( id: 'future', title: 'Future', createdAt: now, start: DateTime(2026, 6, 21, 9), durationMinutes: 15, ); final tomorrowFlexible = plannedFlexibleTask( id: 'tomorrow', title: 'Tomorrow', createdAt: now, start: DateTime(2026, 6, 20, 9), durationMinutes: 30, ); final result = engine.rollOverUnfinishedFlexibleTasks( input: SchedulingInput( tasks: [ futureFlexible, sourceActive, sourcePlanned, completed, cancelled, backlogged, critical, inflexible, tomorrowFlexible, ], window: targetWindow, ), sourceWindow: sourceWindow, updatedAt: now, ); expect(taskById(result.tasks, 'source-planned').scheduledStart, DateTime(2026, 6, 20, 9)); expect(taskById(result.tasks, 'source-active').scheduledStart, DateTime(2026, 6, 20, 9, 15)); expect(taskById(result.tasks, 'tomorrow').scheduledStart, DateTime(2026, 6, 20, 9, 30)); expect(taskById(result.tasks, 'future').scheduledStart, futureFlexible.scheduledStart); expect(taskById(result.tasks, 'completed').status, TaskStatus.completed); expect(taskById(result.tasks, 'cancelled').status, TaskStatus.cancelled); expect(taskById(result.tasks, 'backlog').status, TaskStatus.backlog); expect(taskById(result.tasks, 'critical').type, TaskType.critical); expect(taskById(result.tasks, 'inflexible').type, TaskType.inflexible); expect( result.changes.map((change) => change.taskId), ['source-active', 'source-planned', 'tomorrow'], ); }); }); group('Block 06.3 backlog regressions', () { final now = DateTime(2026, 6, 19, 12); test('filters only include backlog tasks in the expected category', () { final inbox = backlogTask( id: 'inbox', title: 'Inbox', createdAt: now, reward: RewardLevel.low, ); final pushed = backlogTask( id: 'pushed', title: 'Pushed', createdAt: now, projectId: 'home', reward: RewardLevel.low, ).copyWith(stats: const TaskStatistics(manuallyPushedCount: 1)); final criticalMissed = backlogTask( id: 'critical', title: 'Critical', createdAt: now, type: TaskType.critical, projectId: 'health', reward: RewardLevel.low, ).copyWith(stats: const TaskStatistics(missedCount: 1)); final wishlist = backlogTask( id: 'wishlist', title: 'Wishlist', createdAt: now, projectId: 'ideas', reward: RewardLevel.low, backlogTags: const {BacklogTag.wishlist}, ); final stale = backlogTask( id: 'stale', title: 'Stale', createdAt: now.subtract(const Duration(days: 40)), projectId: 'archive', reward: RewardLevel.low, ); final noReward = backlogTask( id: 'no-reward', title: 'No reward', createdAt: now, projectId: 'admin', ); final plannedNoReward = noReward.copyWith( id: 'planned-no-reward', status: TaskStatus.planned, ); final view = BacklogView( tasks: [ inbox, pushed, criticalMissed, wishlist, stale, noReward, plannedNoReward, ], now: now, ); expect(view.filter(BacklogFilter.inbox).map((task) => task.id), [ 'inbox', ]); expect(view.filter(BacklogFilter.pushed).map((task) => task.id), [ 'pushed', ]); expect(view.filter(BacklogFilter.criticalMissed).map((task) => task.id), [ 'critical', ]); expect(view.filter(BacklogFilter.wishlist).map((task) => task.id), [ 'wishlist', ]); expect(view.filter(BacklogFilter.stale).map((task) => task.id), [ 'stale', ]); expect(view.filter(BacklogFilter.noRewardSet).map((task) => task.id), [ 'no-reward', ]); }); test('priority sorting preserves input order for equal priorities', () { final first = backlogTask(id: 'first', title: 'First', createdAt: now); final second = backlogTask(id: 'second', title: 'Second', createdAt: now); final third = backlogTask(id: 'third', title: 'Third', createdAt: now); final view = BacklogView(tasks: [second, first, third], now: now); expect(view.sorted(BacklogSortKey.priority).map((task) => task.id), [ 'second', 'first', 'third', ]); }); test('reward not-set sorts as distinct from very-low reward', () { final notSet = backlogTask( id: 'not-set', title: 'Unknown reward', createdAt: now, reward: RewardLevel.notSet, difficulty: DifficultyLevel.easy, ); final veryLow = backlogTask( id: 'very-low', title: 'Very low reward', createdAt: now, reward: RewardLevel.veryLow, difficulty: DifficultyLevel.easy, ); final view = BacklogView(tasks: [notSet, veryLow], now: now); expect( view.sorted(BacklogSortKey.rewardVsEffort).map((task) => task.id), [ 'very-low', 'not-set', ]); }); test('age sorting preserves input order when createdAt values match', () { final createdAt = DateTime(2026, 6, 1); final first = backlogTask(id: 'first', title: 'First', createdAt: createdAt); final second = backlogTask(id: 'second', title: 'Second', createdAt: createdAt); final view = BacklogView(tasks: [second, first], now: now); expect(view.sorted(BacklogSortKey.age).map((task) => task.id), [ 'second', 'first', ]); }); test('stale filter and marker use the same creation-age semantics', () { final oldButRecentlyUpdated = backlogTask( id: 'old', title: 'Old idea', createdAt: now.subtract(const Duration(days: 40)), ).copyWith(updatedAt: now); final newButOldUpdate = backlogTask( id: 'new', title: 'New idea', createdAt: now, ).copyWith(updatedAt: now.subtract(const Duration(days: 40))); final view = BacklogView( tasks: [oldButRecentlyUpdated, newButOldUpdate], now: now, ); expect(view.filter(BacklogFilter.stale).map((task) => task.id), ['old']); expect( view.stalenessMarkerFor(oldButRecentlyUpdated), BacklogStalenessMarker.purple, ); expect( view.stalenessMarkerFor(newButOldUpdate), BacklogStalenessMarker.green, ); }); }); group('Block 06.3 quick capture regressions', () { final now = DateTime(2026, 6, 19, 12); const service = QuickCaptureService(); test('immediate scheduling uses normal insertion and push rules', () { final existing = plannedFlexibleTask( id: 'existing', title: 'Existing', createdAt: now, start: DateTime(2026, 6, 19, 9), durationMinutes: 15, ); final result = service.capture( QuickCaptureRequest( id: 'capture', title: 'Captured task', createdAt: now, addToNextAvailableSlot: true, durationMinutes: 15, ), schedulingInput: SchedulingInput( tasks: [existing], window: SchedulingWindow( start: DateTime(2026, 6, 19, 9), end: DateTime(2026, 6, 19, 10), ), ), updatedAt: now, ); final scheduledTasks = result.schedulingResult!.tasks; expect(result.status, QuickCaptureStatus.scheduled); expect(taskById(scheduledTasks, 'capture').scheduledStart, DateTime(2026, 6, 19, 9)); expect(taskById(scheduledTasks, 'existing').scheduledStart, DateTime(2026, 6, 19, 9, 15)); }); test('immediate scheduling requires enough scheduling data', () { final result = service.capture( QuickCaptureRequest( id: 'capture', title: 'Captured task', createdAt: now, addToNextAvailableSlot: true, durationMinutes: 15, ), updatedAt: now, ); expect(result.status, QuickCaptureStatus.validationError); expect(result.messages.single, 'Scheduling input is required.'); }); test('quick capture cannot create blank tasks', () { expect( () => service.capture( QuickCaptureRequest( id: 'capture', title: ' ', createdAt: now, ), ), throwsArgumentError, ); }); }); group('Block 06.3 locked-time regressions', () { final now = DateTime(2026, 6, 19, 12); test('locked occurrence exposes overlay data without becoming a task', () { final block = LockedBlock( id: 'work', name: 'Work', startTime: ClockTime(hour: 9, minute: 0), endTime: ClockTime(hour: 17, minute: 0), recurrence: LockedBlockRecurrence.weekly( weekdays: {LockedWeekday.friday}, ), createdAt: now, updatedAt: now, ); final expansion = expandLockedBlocksForDay( blocks: [block], overrides: const [], date: DateTime(2026, 6, 19), ); final occurrence = expansion.occurrences.single; expect(occurrence, isA()); expect(occurrence, isNot(isA())); expect(occurrence.hiddenByDefault, isTrue); expect(occurrence.schedulingInterval.label, 'Work'); expect(expansion.schedulingIntervals.single.label, 'Work'); }); test('completed tasks outside locked hours do not increment counters', () { final task = plannedFlexibleTask( id: 'task', title: 'Task', createdAt: now, start: DateTime(2026, 6, 19, 8), durationMinutes: 30, ).copyWith(status: TaskStatus.completed); final tracked = trackCompletedDuringLockedHours( task: task, lockedIntervals: [ TimeInterval( start: DateTime(2026, 6, 19, 9), end: DateTime(2026, 6, 19, 10), ), ], ); expect(tracked.stats.completedDuringLockedHoursCount, 0); expect(tracked.stats.completedDuringLockedHoursMinutes, 0); }); }); group('Block 06.3 action-service regressions', () { final now = DateTime(2026, 6, 19, 12); const service = FlexibleTaskActionService(); test('unsupported flexible quick action types fail predictably', () { final inflexible = fixedTask( id: 'appointment', type: TaskType.inflexible, createdAt: now, start: DateTime(2026, 6, 19, 9), durationMinutes: 30, ); expect( () => service.apply( task: inflexible, action: FlexibleTaskQuickAction.done, updatedAt: now, ), throwsArgumentError, ); }); test('push backlog destination does not create duplicate task ids', () { final task = plannedFlexibleTask( id: 'task', title: 'Task', createdAt: now, start: DateTime(2026, 6, 19, 9), durationMinutes: 30, ); final result = service.applyPushDestination( destination: PushDestination.backlog, input: SchedulingInput( tasks: [task], window: SchedulingWindow( start: DateTime(2026, 6, 19, 9), end: DateTime(2026, 6, 19, 10), ), ), taskId: 'task', updatedAt: now, ); expect(result.schedulingResult.tasks.map((task) => task.id), ['task']); expect(result.schedulingResult.tasks.toSet().length, 1); expect(result.schedulingResult.changes.single.previousStart, DateTime(2026, 6, 19, 9)); expect(result.schedulingResult.changes.single.nextStart, isNull); }); test('public scheduling services do not reorder the input task list', () { final first = plannedFlexibleTask( id: 'first', title: 'First', createdAt: now, start: DateTime(2026, 6, 19, 9), durationMinutes: 15, ); final second = plannedFlexibleTask( id: 'second', title: 'Second', createdAt: now, start: DateTime(2026, 6, 19, 9, 15), durationMinutes: 15, ); final tasks = [second, first]; const SchedulingEngine().pushFlexibleTaskToNextAvailableSlot( input: SchedulingInput( tasks: tasks, window: SchedulingWindow( start: DateTime(2026, 6, 19, 9), end: DateTime(2026, 6, 19, 10), ), ), taskId: 'first', updatedAt: now, ); expect(tasks.map((task) => task.id), ['second', 'first']); }); test('invalid push destinations leave fixed task types unchanged', () { final critical = fixedTask( id: 'critical', type: TaskType.critical, createdAt: now, start: DateTime(2026, 6, 19, 9), durationMinutes: 30, ); final result = service.applyPushDestination( destination: PushDestination.nextAvailableSlot, input: SchedulingInput( tasks: [critical], window: SchedulingWindow( start: DateTime(2026, 6, 19, 9), end: DateTime(2026, 6, 19, 10), ), ), taskId: 'critical', updatedAt: now, ); expect(result.schedulingResult.notices.single.type, SchedulingNoticeType.noFit); expect(result.schedulingResult.tasks.single.type, TaskType.critical); expect(result.schedulingResult.tasks.single.status, TaskStatus.planned); }); }); } Task backlogTask({ required String id, required String title, required DateTime createdAt, String projectId = 'inbox', TaskType type = TaskType.flexible, RewardLevel reward = RewardLevel.notSet, DifficultyLevel difficulty = DifficultyLevel.notSet, int? durationMinutes, Set backlogTags = const {}, }) { return Task.quickCapture( id: id, title: title, createdAt: createdAt, projectId: projectId, type: type, reward: reward, difficulty: difficulty, backlogTags: backlogTags, ).copyWith(durationMinutes: durationMinutes); } Task plannedFlexibleTask({ required String id, required String title, required DateTime createdAt, required DateTime start, required int durationMinutes, }) { return backlogTask( id: id, title: title, createdAt: createdAt, durationMinutes: durationMinutes, ).copyWith( status: TaskStatus.planned, scheduledStart: start, scheduledEnd: start.add(Duration(minutes: durationMinutes)), ); } Task fixedTask({ required String id, required TaskType type, required DateTime createdAt, required DateTime start, required int durationMinutes, }) { return Task( id: id, title: id, projectId: 'fixed', type: type, status: TaskStatus.planned, priority: PriorityLevel.medium, durationMinutes: durationMinutes, scheduledStart: start, scheduledEnd: start.add(Duration(minutes: durationMinutes)), createdAt: createdAt, updatedAt: createdAt, ); } Task taskById(List tasks, String id) { return tasks.singleWhere((task) => task.id == id); }