import 'package:adhd_scheduler_core/scheduler_core.dart'; import 'package:test/test.dart'; void main() { group('Surprise task logging', () { final now = DateTime(2026, 6, 19, 12); const service = SurpriseTaskLogService(); test('creates a completed surprise task that occupies the logged time', () { final result = service.log( request: SurpriseTaskLogRequest( id: 'surprise-1', title: 'Answered urgent text', createdAt: now, startedAt: DateTime(2026, 6, 19, 9), timeUsedMinutes: 20, projectId: 'relationships', priority: PriorityLevel.high, reward: RewardLevel.medium, ), input: emptyInput(), updatedAt: now, ); expect(result.surpriseTask.type, TaskType.surprise); expect(result.surpriseTask.status, TaskStatus.completed); expect(result.surpriseTask.title, 'Answered urgent text'); expect(result.surpriseTask.projectId, 'relationships'); expect(result.surpriseTask.priority, PriorityLevel.high); expect(result.surpriseTask.reward, RewardLevel.medium); expect(result.surpriseTask.durationMinutes, 20); expect(result.surpriseTask.scheduledStart, DateTime(2026, 6, 19, 9)); expect(result.surpriseTask.scheduledEnd, DateTime(2026, 6, 19, 9, 20)); expect(result.surpriseTask.actualStart, DateTime(2026, 6, 19, 9)); expect(result.surpriseTask.actualEnd, DateTime(2026, 6, 19, 9, 20)); expect( result.schedulingResult.tasks.map((task) => task.id), ['surprise-1'], ); }); test('pushes overlapping flexible tasks using normal push rules', () { final flexible = plannedFlexibleTask( id: 'flexible', title: 'Write notes', createdAt: now, start: DateTime(2026, 6, 19, 9), durationMinutes: 30, ); final result = service.log( request: SurpriseTaskLogRequest( id: 'surprise-1', title: 'Unplanned call', createdAt: now, startedAt: DateTime(2026, 6, 19, 9, 10), timeUsedMinutes: 20, ), input: SchedulingInput( tasks: [flexible], window: SchedulingWindow( start: DateTime(2026, 6, 19, 9), end: DateTime(2026, 6, 19, 11), ), ), updatedAt: now, ); final moved = taskById(result.schedulingResult.tasks, 'flexible'); expect(moved.scheduledStart, DateTime(2026, 6, 19, 9, 30)); expect(moved.scheduledEnd, DateTime(2026, 6, 19, 10)); expect(moved.stats.autoPushedCount, 1); expect(moved.stats.manuallyPushedCount, 0); expect(result.schedulingResult.changes.single.taskId, 'flexible'); expect(result.usedNormalPushRules, isTrue); }); test('does not move inflexible or critical overlaps and reports them', () { final inflexible = fixedTask( id: 'appointment', type: TaskType.inflexible, createdAt: now, start: DateTime(2026, 6, 19, 9), durationMinutes: 30, ); final critical = fixedTask( id: 'meds', type: TaskType.critical, createdAt: now, start: DateTime(2026, 6, 19, 9, 20), durationMinutes: 20, ); final result = service.log( request: SurpriseTaskLogRequest( id: 'surprise-1', title: 'Unplanned call', createdAt: now, startedAt: DateTime(2026, 6, 19, 9, 10), timeUsedMinutes: 20, ), input: SchedulingInput( tasks: [inflexible, critical], window: SchedulingWindow( start: DateTime(2026, 6, 19, 9), end: DateTime(2026, 6, 19, 11), ), ), updatedAt: now, ); expect(taskById(result.schedulingResult.tasks, 'appointment'), sameScheduleAs(inflexible)); expect(taskById(result.schedulingResult.tasks, 'meds'), sameScheduleAs(critical)); expect( result.requiredOverlaps.map((overlap) => overlap.taskId), ['appointment', 'meds'], ); expect( result.schedulingResult.overlaps.map((overlap) => overlap.taskId), ['appointment', 'meds'], ); }); test('tracks locked overlap without leaking hidden details by default', () { final result = service.log( request: SurpriseTaskLogRequest( id: 'surprise-1', title: 'Unplanned call', createdAt: now, startedAt: DateTime(2026, 6, 19, 9, 10), timeUsedMinutes: 20, ), input: SchedulingInput( tasks: const [], 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, 10), label: 'hidden-work', ), ], ), updatedAt: now, ); expect(result.lockedOverlaps.single.label, isNull); expect(result.lockedOverlaps.single.start, DateTime(2026, 6, 19, 9)); expect(result.lockedOverlaps.single.end, DateTime(2026, 6, 19, 10)); expect(result.schedulingResult.tasks.map((task) => task.type), [ TaskType.surprise, ]); expect(result.schedulingResult.overlaps, isEmpty); }); test('can reveal hidden locked overlap details when explicitly requested', () { final result = service.log( request: SurpriseTaskLogRequest( id: 'surprise-1', title: 'Unplanned call', createdAt: now, startedAt: DateTime(2026, 6, 19, 9, 10), timeUsedMinutes: 20, ), input: SchedulingInput( tasks: const [], 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, 10), label: 'hidden-work', ), ], ), updatedAt: now, revealHiddenLockedDetails: true, ); expect(result.lockedOverlaps.single.label, 'hidden-work'); }); test('optional fields can be omitted without blocking save', () { final result = service.log( request: SurpriseTaskLogRequest( id: 'surprise-1', title: ' Took a break ', createdAt: now, ), input: emptyInput(), updatedAt: now, ); expect(result.surpriseTask.title, 'Took a break'); expect(result.surpriseTask.projectId, 'inbox'); expect(result.surpriseTask.priority, isNull); expect(result.surpriseTask.reward, RewardLevel.notSet); expect(result.surpriseTask.durationMinutes, isNull); expect(result.surpriseTask.scheduledStart, isNull); expect(result.surpriseTask.scheduledEnd, isNull); expect(result.surpriseTask.actualStart, isNull); expect(result.surpriseTask.actualEnd, isNull); expect(result.schedulingResult.tasks.single.id, 'surprise-1'); }); test('does not push the same flexible task twice after cascade movement', () { final first = plannedFlexibleTask( id: 'first', title: 'First', createdAt: now, start: DateTime(2026, 6, 19, 9), durationMinutes: 30, ); final second = plannedFlexibleTask( id: 'second', title: 'Second', createdAt: now, start: DateTime(2026, 6, 19, 9, 30), durationMinutes: 30, ); final result = service.log( request: SurpriseTaskLogRequest( id: 'surprise-1', title: 'Unplanned call', createdAt: now, startedAt: DateTime(2026, 6, 19, 9, 10), timeUsedMinutes: 40, ), input: SchedulingInput( tasks: [first, second], window: SchedulingWindow( start: DateTime(2026, 6, 19, 9), end: DateTime(2026, 6, 19, 12), ), ), updatedAt: now, ); expect(taskById(result.schedulingResult.tasks, 'first').scheduledStart, DateTime(2026, 6, 19, 9, 50)); expect(taskById(result.schedulingResult.tasks, 'second').scheduledStart, DateTime(2026, 6, 19, 10, 20)); expect( result.schedulingResult.changes.map((change) => change.taskId), ['first', 'second'], ); }); test('retrying the same surprise id is a no-op', () { final flexible = plannedFlexibleTask( id: 'flexible', title: 'Write notes', createdAt: now, start: DateTime(2026, 6, 19, 9), durationMinutes: 30, ); final first = service.log( request: SurpriseTaskLogRequest( id: 'surprise-1', title: 'Unplanned call', createdAt: now, startedAt: DateTime(2026, 6, 19, 9, 10), timeUsedMinutes: 20, ), input: SchedulingInput( tasks: [flexible], window: SchedulingWindow( start: DateTime(2026, 6, 19, 9), end: DateTime(2026, 6, 19, 11), ), ), updatedAt: now, ); final retry = service.log( request: SurpriseTaskLogRequest( id: 'surprise-1', title: 'Unplanned call', createdAt: now, startedAt: DateTime(2026, 6, 19, 9, 10), timeUsedMinutes: 20, ), input: SchedulingInput( tasks: first.schedulingResult.tasks, window: SchedulingWindow( start: DateTime(2026, 6, 19, 9), end: DateTime(2026, 6, 19, 11), ), ), updatedAt: now, ); expect( retry.schedulingResult.tasks .where((task) => task.id == 'surprise-1') .length, 1, ); expect(retry.schedulingResult.changes, isEmpty); expect( taskById(retry.schedulingResult.tasks, 'flexible').scheduledStart, taskById(first.schedulingResult.tasks, 'flexible').scheduledStart, ); expect(retry.schedulingResult.notices.single.message, 'Surprise task already logged.'); }); test('later insertion avoids persisted surprise actual occupancy', () { final surpriseResult = service.log( request: SurpriseTaskLogRequest( id: 'surprise-1', title: 'Unplanned call', createdAt: now, startedAt: DateTime(2026, 6, 19, 9), timeUsedMinutes: 30, ), input: SchedulingInput( tasks: const [], window: SchedulingWindow( start: DateTime(2026, 6, 19, 9), end: DateTime(2026, 6, 19, 11), ), ), updatedAt: now, ); final backlog = Task.quickCapture( id: 'backlog', title: 'Make call', createdAt: now, ).copyWith(durationMinutes: 15); final later = const SchedulingEngine().insertBacklogTaskIntoNextAvailableSlot( input: SchedulingInput( tasks: [backlog, ...surpriseResult.schedulingResult.tasks], window: SchedulingWindow( start: DateTime(2026, 6, 19, 9), end: DateTime(2026, 6, 19, 11), ), ), taskId: 'backlog', updatedAt: now, ); expect(taskById(later.tasks, 'backlog').scheduledStart, DateTime(2026, 6, 19, 9, 30)); }); test('active and completed actual intervals block later insertion', () { final active = plannedFlexibleTask( id: 'active', title: 'Active', createdAt: now, start: DateTime(2026, 6, 19, 8), durationMinutes: 30, ).copyWith( status: TaskStatus.active, actualStart: DateTime(2026, 6, 19, 9), actualEnd: DateTime(2026, 6, 19, 9, 20), ); final completed = plannedFlexibleTask( id: 'completed', title: 'Completed', createdAt: now, start: DateTime(2026, 6, 19, 8, 30), durationMinutes: 30, ).copyWith( status: TaskStatus.completed, actualStart: DateTime(2026, 6, 19, 9, 20), actualEnd: DateTime(2026, 6, 19, 9, 40), ); final backlog = Task.quickCapture( id: 'backlog', title: 'Make call', createdAt: now, ).copyWith(durationMinutes: 15); final result = const SchedulingEngine().insertBacklogTaskIntoNextAvailableSlot( input: SchedulingInput( tasks: [backlog, active, completed], window: SchedulingWindow( start: DateTime(2026, 6, 19, 9), end: DateTime(2026, 6, 19, 11), ), ), taskId: 'backlog', updatedAt: now, ); expect(taskById(result.tasks, 'backlog').scheduledStart, DateTime(2026, 6, 19, 9, 40)); expect(taskById(result.tasks, 'active').scheduledStart, active.scheduledStart); expect(taskById(result.tasks, 'completed').actualStart, completed.actualStart); }); test('exact surprise boundaries do not move adjacent flexible tasks', () { final before = plannedFlexibleTask( id: 'before', title: 'Before', createdAt: now, start: DateTime(2026, 6, 19, 9), durationMinutes: 30, ); final after = plannedFlexibleTask( id: 'after', title: 'After', createdAt: now, start: DateTime(2026, 6, 19, 10), durationMinutes: 30, ); final result = service.log( request: SurpriseTaskLogRequest( id: 'surprise-1', title: 'Unplanned call', createdAt: now, startedAt: DateTime(2026, 6, 19, 9, 30), timeUsedMinutes: 30, ), input: SchedulingInput( tasks: [before, after], window: SchedulingWindow( start: DateTime(2026, 6, 19, 9), end: DateTime(2026, 6, 19, 11), ), ), updatedAt: now, ); expect(result.schedulingResult.changes, isEmpty); expect(taskById(result.schedulingResult.tasks, 'before').scheduledStart, before.scheduledStart); expect(taskById(result.schedulingResult.tasks, 'after').scheduledStart, after.scheduledStart); }); test('multiple surprise entries remain occupied for later scheduling', () { final first = service.log( request: SurpriseTaskLogRequest( id: 'surprise-1', title: 'First', createdAt: now, startedAt: DateTime(2026, 6, 19, 9), timeUsedMinutes: 15, ), input: SchedulingInput( tasks: const [], window: SchedulingWindow( start: DateTime(2026, 6, 19, 9), end: DateTime(2026, 6, 19, 11), ), ), updatedAt: now, ); final second = service.log( request: SurpriseTaskLogRequest( id: 'surprise-2', title: 'Second', createdAt: now, startedAt: DateTime(2026, 6, 19, 9, 15), timeUsedMinutes: 15, ), input: SchedulingInput( tasks: first.schedulingResult.tasks, window: SchedulingWindow( start: DateTime(2026, 6, 19, 9), end: DateTime(2026, 6, 19, 11), ), ), updatedAt: now, ); final backlog = Task.quickCapture( id: 'backlog', title: 'Make call', createdAt: now, ).copyWith(durationMinutes: 15); final later = const SchedulingEngine().insertBacklogTaskIntoNextAvailableSlot( input: SchedulingInput( tasks: [backlog, ...second.schedulingResult.tasks], window: SchedulingWindow( start: DateTime(2026, 6, 19, 9), end: DateTime(2026, 6, 19, 11), ), ), taskId: 'backlog', updatedAt: now, ); expect(later.tasks.where((task) => task.type == TaskType.surprise).length, 2); expect(taskById(later.tasks, 'backlog').scheduledStart, DateTime(2026, 6, 19, 9, 30)); }); }); } SchedulingInput emptyInput() { return SchedulingInput( tasks: const [], window: SchedulingWindow( start: DateTime(2026, 6, 19, 9), end: DateTime(2026, 6, 19, 17), ), ); } Task plannedFlexibleTask({ required String id, required String title, required DateTime createdAt, required DateTime start, required int durationMinutes, }) { return Task.quickCapture( id: id, title: title, createdAt: createdAt, ).copyWith( status: TaskStatus.planned, durationMinutes: durationMinutes, 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.high, 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); } Matcher sameScheduleAs(Task expected) { return predicate((actual) { return actual.scheduledStart == expected.scheduledStart && actual.scheduledEnd == expected.scheduledEnd && actual.status == expected.status && actual.type == expected.type; }, 'same schedule and state as ${expected.id}'); }