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.activities.single.metadata, containsPair('previousStatus', 'planned'), ); expect( result.activities.single.metadata, containsPair('nextStatus', 'completed'), ); }); 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('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', }); }); }); } Task scheduledTask({ required String id, required DateTime now, DateTime? start, }) { 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: 15, scheduledStart: scheduledStart, scheduledEnd: scheduledStart.add(const Duration(minutes: 15)), 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, ); }