import 'package:adhd_scheduler_core/scheduler_core.dart'; import 'package:test/test.dart'; void main() { group('Task transition service', () { final now = DateTime(2026, 6, 25, 9); const service = TaskTransitionService(); test('completion records completedAt actual interval and activity', () { final task = scheduledTask(id: 'task-1', now: now); final actualStart = DateTime(2026, 6, 25, 9, 5); final actualEnd = DateTime(2026, 6, 25, 9, 20); final result = service.apply( task: task, transitionCode: TaskTransitionCode.complete, operationId: 'op-complete', activityId: 'activity-complete', occurredAt: now.add(const Duration(minutes: 30)), actualStart: actualStart, actualEnd: actualEnd, ); expect(result.outcomeCode, TaskTransitionOutcomeCode.applied); expect(result.task.status, TaskStatus.completed); expect(result.task.completedAt, now.add(const Duration(minutes: 30))); expect(result.task.actualStart, actualStart); expect(result.task.actualEnd, actualEnd); expect(result.activities.single.id, 'activity-complete'); expect(result.activities.single.operationId, 'op-complete'); expect(result.activities.single.code, TaskActivityCode.completed); expect(result.task.stats.completedLateCount, 1); expect( result.activities.single.metadata, containsPair('previousStatus', 'planned'), ); expect( result.activities.single.metadata, containsPair('nextStatus', 'completed'), ); expect( result.activities.single.metadata, containsPair('pushesBeforeCompletion', 0), ); }); test('late-by-zero completion is not counted late', () { final task = scheduledTask(id: 'task-1', now: now); final result = service.apply( task: task, transitionCode: TaskTransitionCode.complete, operationId: 'op-complete', activityId: 'activity-complete', occurredAt: task.scheduledEnd, ); expect(result.task.completedAt, task.scheduledEnd); expect(result.task.stats.completedLateCount, 0); }); test('completion accounting uses actual locked overlap boundaries', () { final task = scheduledTask( id: 'task-1', now: now, start: DateTime(2026, 6, 25, 9), durationMinutes: 90, ); final result = service.apply( task: task, transitionCode: TaskTransitionCode.complete, operationId: 'op-complete', activityId: 'activity-complete', occurredAt: DateTime(2026, 6, 25, 10, 30), actualStart: DateTime(2026, 6, 25, 9, 15), actualEnd: DateTime(2026, 6, 25, 10, 15), lockedIntervals: [ TimeInterval( start: DateTime(2026, 6, 25, 8), end: DateTime(2026, 6, 25, 9, 15), ), TimeInterval( start: DateTime(2026, 6, 25, 9, 30), end: DateTime(2026, 6, 25, 9, 45), ), TimeInterval( start: DateTime(2026, 6, 25, 9, 40), end: DateTime(2026, 6, 25, 10), ), ], ); expect(result.task.stats.completedDuringLockedHoursCount, 1); expect(result.task.stats.completedDuringLockedHoursMinutes, 30); }); test('completion without actual interval does not fabricate locked minutes', () { final task = scheduledTask(id: 'task-1', now: now); final result = service.apply( task: task, transitionCode: TaskTransitionCode.complete, operationId: 'op-complete', activityId: 'activity-complete', occurredAt: DateTime(2026, 6, 25, 9, 20), lockedIntervals: [ TimeInterval( start: DateTime(2026, 6, 25, 9), end: DateTime(2026, 6, 25, 10), ), ], ); expect(result.task.actualStart, isNull); expect(result.task.stats.completedDuringLockedHoursCount, 0); expect(result.task.stats.completedDuringLockedHoursMinutes, 0); }); test('completion captures pushes present at completion', () { final task = scheduledTask(id: 'task-1', now: now).copyWith( stats: const TaskStatistics( manuallyPushedCount: 2, autoPushedCount: 1, ), ); final result = service.apply( task: task, transitionCode: TaskTransitionCode.complete, operationId: 'op-complete', activityId: 'activity-complete', occurredAt: task.scheduledEnd, ); expect(result.activities.single.metadata, containsPair('pushesBeforeCompletion', 3)); expect(result.task.stats.completedAfterPushCount, 1); expect(result.task.stats.totalPushesBeforeCompletion, 3); }); test('duplicate operation ids do not create activities or mutate again', () { final task = scheduledTask(id: 'task-1', now: now); final first = service.apply( task: task, transitionCode: TaskTransitionCode.manualPush, operationId: 'op-push', activityId: 'activity-push', occurredAt: now, ); final duplicate = service.apply( task: first.task, transitionCode: TaskTransitionCode.manualPush, operationId: 'op-push', activityId: 'activity-push-again', occurredAt: now.add(const Duration(minutes: 5)), existingActivities: first.activities, ); expect(first.task.stats.manuallyPushedCount, 1); expect(first.activities.single.code, TaskActivityCode.manuallyPushed); expect( duplicate.outcomeCode, TaskTransitionOutcomeCode.duplicateOperation); expect(duplicate.duplicateOfActivityId, 'activity-push'); expect(duplicate.activities, isEmpty); expect(duplicate.task.stats.manuallyPushedCount, 1); }); test('terminal states no-op for same transition and reject reopening', () { final completed = scheduledTask(id: 'done', now: now).copyWith( status: TaskStatus.completed, completedAt: now, ); final repeatComplete = service.apply( task: completed, transitionCode: TaskTransitionCode.complete, operationId: 'op-repeat', activityId: 'activity-repeat', occurredAt: now, ); final activateCompleted = service.apply( task: completed, transitionCode: TaskTransitionCode.activate, operationId: 'op-activate', activityId: 'activity-activate', occurredAt: now, ); expect(repeatComplete.outcomeCode, TaskTransitionOutcomeCode.noOp); expect(repeatComplete.task, completed); expect(repeatComplete.activities, isEmpty); expect(activateCompleted.outcomeCode, TaskTransitionOutcomeCode.invalidState); expect(activateCompleted.task.status, TaskStatus.completed); }); test('critical missed records miss and backlog movement activities', () { final task = requiredTask( id: 'meds', type: TaskType.critical, now: now, ); final result = service.apply( task: task, transitionCode: TaskTransitionCode.miss, operationId: 'op-missed', activityId: 'activity-missed', occurredAt: now, ); expect(result.outcomeCode, TaskTransitionOutcomeCode.applied); expect(result.task.status, TaskStatus.backlog); expect(result.task.scheduledStart, isNull); expect(result.task.stats.missedCount, 1); expect(result.task.stats.movedToBacklogCount, 1); expect(result.activities.map((activity) => activity.code), [ TaskActivityCode.missed, TaskActivityCode.movedToBacklog, ]); }); test( 'restore and activation use activity records without movement statuses', () { final backlog = Task.quickCapture( id: 'backlog', title: 'Backlog', createdAt: now, ); final restored = service.apply( task: backlog, transitionCode: TaskTransitionCode.restoreFromBacklog, operationId: 'op-restore', activityId: 'activity-restore', occurredAt: now, ); final activated = service.apply( task: restored.task, transitionCode: TaskTransitionCode.activate, operationId: 'op-activate', activityId: 'activity-activate', occurredAt: now, ); expect(restored.task.status, TaskStatus.planned); expect(restored.task.stats.restoredFromBacklogCount, 1); expect(restored.activities.single.code, TaskActivityCode.restoredFromBacklog); expect(activated.task.status, TaskStatus.active); expect(activated.activities.single.code, TaskActivityCode.activated); expect(TaskStatus.values.map((status) => status.name), isNot(contains('pushed'))); }); }); group('Task activity accounting service', () { final now = DateTime(2026, 6, 25, 9); const service = TaskActivityAccountingService(); test('applies activity counters once by activity id', () { final task = scheduledTask(id: 'task-1', now: now); final activity = TaskActivity( id: 'activity-push', operationId: 'op-push', code: TaskActivityCode.manuallyPushed, taskId: task.id, projectId: task.projectId, occurredAt: now, ); final first = service.apply(task: task, activities: [activity]); final replay = service.apply( task: first.task, activities: [activity], alreadyAppliedActivityIds: first.appliedActivityIds, ); expect(first.task.stats.manuallyPushedCount, 1); expect(first.appliedActivityIds, ['activity-push']); expect(replay.task.stats.manuallyPushedCount, 1); expect(replay.appliedActivityIds, isEmpty); }); }); group('Action services use canonical transitions', () { final now = DateTime(2026, 6, 25, 9); test('flexible done returns completion activity', () { const service = FlexibleTaskActionService(); final task = scheduledTask(id: 'flex', now: now); final result = service.apply( task: task, action: FlexibleTaskQuickAction.done, updatedAt: now, operationId: 'op-flex-done', ); expect(result.transitionOutcomeCode, TaskTransitionOutcomeCode.applied); expect(result.task.completedAt, now); expect(result.activities.single.code, TaskActivityCode.completed); expect(result.activities.single.operationId, 'op-flex-done'); }); test('required missed returns canonical activities', () { const service = RequiredTaskActionService(); final task = requiredTask( id: 'critical', type: TaskType.critical, now: now, ); final result = service.apply( task: task, action: RequiredTaskAction.missed, updatedAt: now, operationId: 'op-required-missed', ); expect(result.transitionOutcomeCode, TaskTransitionOutcomeCode.applied); expect(result.task.status, TaskStatus.backlog); expect(result.activities.map((activity) => activity.code), [ TaskActivityCode.missed, TaskActivityCode.movedToBacklog, ]); }); test('push destination returns movement activities for changed tasks', () { const service = FlexibleTaskActionService(); final first = scheduledTask(id: 'first', now: now); final second = scheduledTask( id: 'second', now: now, start: DateTime(2026, 6, 25, 9, 20), ); final result = service.applyPushDestination( destination: PushDestination.nextAvailableSlot, input: SchedulingInput( tasks: [first, second], window: SchedulingWindow( start: DateTime(2026, 6, 25, 9), end: DateTime(2026, 6, 25, 11), ), ), taskId: first.id, updatedAt: now, operationId: 'op-push', ); expect( result.schedulingResult.outcomeCode, SchedulingOutcomeCode.success); expect(result.activities.map((activity) => activity.code), [ TaskActivityCode.manuallyPushed, TaskActivityCode.automaticallyPushed, ]); expect( result.activities.map((activity) => activity.operationId).toSet(), { 'op-push', }); }); test('duplicate push destination operation id returns no-op result', () { const service = FlexibleTaskActionService(); final task = scheduledTask(id: 'first', now: now); final previousActivity = TaskActivity( id: 'activity-push', operationId: 'op-push', code: TaskActivityCode.manuallyPushed, taskId: task.id, projectId: task.projectId, occurredAt: now, ); final result = service.applyPushDestination( destination: PushDestination.nextAvailableSlot, input: SchedulingInput( tasks: [task], window: SchedulingWindow( start: DateTime(2026, 6, 25, 9), end: DateTime(2026, 6, 25, 11), ), ), taskId: task.id, updatedAt: now, operationId: 'op-push', existingActivities: [previousActivity], ); expect(result.schedulingResult.outcomeCode, SchedulingOutcomeCode.noOp); expect(result.schedulingResult.tasks.single, task); expect(result.activities, isEmpty); }); test('surprise logging applies completion accounting', () { const service = SurpriseTaskLogService(); final result = service.log( request: SurpriseTaskLogRequest( id: 'surprise', title: 'Unexpected call', createdAt: now, startedAt: DateTime(2026, 6, 25, 9, 10), timeUsedMinutes: 40, ), input: SchedulingInput( tasks: const [], window: SchedulingWindow( start: DateTime(2026, 6, 25, 9), end: DateTime(2026, 6, 25, 11), ), lockedIntervals: [ TimeInterval( start: DateTime(2026, 6, 25, 9, 30), end: DateTime(2026, 6, 25, 10), ), ], ), updatedAt: now, ); expect(result.activities.single.code, TaskActivityCode.completed); expect(result.surpriseTask.completedAt, now); expect(result.surpriseTask.stats.completedDuringLockedHoursCount, 1); expect(result.surpriseTask.stats.completedDuringLockedHoursMinutes, 20); }); }); } Task scheduledTask({ required String id, required DateTime now, DateTime? start, int durationMinutes = 15, }) { final scheduledStart = start ?? DateTime(2026, 6, 25, 9); return Task( id: id, title: id, projectId: 'project', type: TaskType.flexible, status: TaskStatus.planned, priority: PriorityLevel.medium, durationMinutes: durationMinutes, scheduledStart: scheduledStart, scheduledEnd: scheduledStart.add(Duration(minutes: durationMinutes)), createdAt: now, updatedAt: now, ); } Task requiredTask({ required String id, required TaskType type, required DateTime now, }) { return Task( id: id, title: id, projectId: 'required', type: type, status: TaskStatus.planned, priority: PriorityLevel.high, durationMinutes: 30, scheduledStart: DateTime(2026, 6, 25, 9), scheduledEnd: DateTime(2026, 6, 25, 9, 30), createdAt: now, updatedAt: now, ); }