import 'package:adhd_scheduler_core/scheduler_core.dart'; import 'package:test/test.dart'; void main() { group('V1 application commands', () { final day = CivilDate(2026, 6, 25); final tomorrow = CivilDate(2026, 6, 26); final createdAt = DateTime.utc(2026, 6, 20, 8); test('quick capture to backlog is atomic and idempotent by operation id', () async { final store = storeWithSettings(); final useCases = commands(store); final context = appContext('capture-backlog', DateTime.utc(2026, 6, 25, 8)); final result = await useCases.quickCaptureToBacklog( context: context, title: ' Write down idea ', ); final duplicate = await useCases.quickCaptureToBacklog( context: context, title: 'Write down idea again', ); expect(result.isSuccess, isTrue); expect(result.requireValue.changedTasks.single.title, 'Write down idea'); expect( result.requireValue.changedTasks.single.status, TaskStatus.backlog); expect(store.currentTasks, hasLength(1)); expect(store.currentOperations.single.operationId, 'capture-backlog'); expect( duplicate.failure!.code, ApplicationFailureCode.duplicateOperation); expect(store.currentTasks, hasLength(1)); }); test('quick capture to next available slot schedules through the app layer', () async { final store = storeWithSettings(); final result = await commands(store).quickCaptureToNextAvailableSlot( context: appContext('capture-schedule', DateTime.utc(2026, 6, 25, 8)), localDate: day, title: 'Scheduled capture', durationMinutes: 30, ); expect(result.isSuccess, isTrue); final captured = result.requireValue.changedTasks.single; expect(captured.status, TaskStatus.planned); expect(captured.scheduledStart, DateTime.utc(2026, 6, 25, 9)); expect(captured.scheduledEnd, DateTime.utc(2026, 6, 25, 9, 30)); expect(store.currentTaskActivities.single.code, TaskActivityCode.restoredFromBacklog); }); test('schedules backlog item and persists movement activities', () async { final backlog = task( id: 'backlog', title: 'Backlog', type: TaskType.flexible, status: TaskStatus.backlog, createdAt: createdAt, durationMinutes: 30, ); final planned = task( id: 'planned', title: 'Planned', type: TaskType.flexible, status: TaskStatus.planned, createdAt: createdAt, start: DateTime.utc(2026, 6, 25, 9), end: DateTime.utc(2026, 6, 25, 9, 30), ); final store = storeWithSettings(tasks: [backlog, planned]); final result = await commands(store).scheduleBacklogItemToNextAvailableSlot( context: appContext('schedule-backlog', DateTime.utc(2026, 6, 25, 8)), localDate: day, taskId: backlog.id, expectedUpdatedAt: backlog.updatedAt, ); expect(result.isSuccess, isTrue); expect(result.requireValue.changedTasks.map((task) => task.id).toSet(), { 'backlog', 'planned', }); expect( taskById(store.currentTasks, 'backlog').status, TaskStatus.planned, ); expect( taskById(store.currentTasks, 'backlog').scheduledStart, DateTime.utc(2026, 6, 25, 9), ); expect( taskById(store.currentTasks, 'planned').scheduledStart, DateTime.utc(2026, 6, 25, 9, 30), ); expect(store.currentTaskActivities.map((activity) => activity.code), [ TaskActivityCode.restoredFromBacklog, TaskActivityCode.automaticallyPushed, ]); }); test('stale expected update leaves repositories unchanged', () async { final planned = task( id: 'planned', title: 'Planned', type: TaskType.flexible, status: TaskStatus.planned, createdAt: createdAt, start: DateTime.utc(2026, 6, 25, 9), end: DateTime.utc(2026, 6, 25, 9, 30), ); final store = storeWithSettings(tasks: [planned]); final result = await commands(store).moveFlexibleToBacklog( context: appContext('stale-move', DateTime.utc(2026, 6, 25, 8)), taskId: planned.id, expectedUpdatedAt: planned.updatedAt.subtract(const Duration(minutes: 1)), ); expect(result.failure!.code, ApplicationFailureCode.staleRevision); expect( taskById(store.currentTasks, planned.id).status, TaskStatus.planned); expect(store.currentOperations, isEmpty); }); test('commit persistence failure rolls back command-side task and activity', () async { final planned = task( id: 'planned', title: 'Planned', type: TaskType.flexible, status: TaskStatus.planned, createdAt: createdAt, start: DateTime.utc(2026, 6, 25, 9), end: DateTime.utc(2026, 6, 25, 9, 30), ); final store = storeWithSettings(tasks: [planned]) ..failNextCommitForTesting(); final result = await commands(store).completeFlexibleTask( context: appContext('rollback-complete', DateTime.utc(2026, 6, 25, 9)), localDate: day, taskId: planned.id, ); expect(result.failure!.code, ApplicationFailureCode.persistenceFailure); expect( taskById(store.currentTasks, planned.id).status, TaskStatus.planned); expect(store.currentTaskActivities, isEmpty); expect(store.currentProjectStatistics, isEmpty); expect(store.currentOperations, isEmpty); }); test('push commands and backlog move persist expected command outputs', () async { final first = task( id: 'first', title: 'First', type: TaskType.flexible, status: TaskStatus.planned, createdAt: createdAt, start: DateTime.utc(2026, 6, 25, 9), end: DateTime.utc(2026, 6, 25, 9, 30), ); final second = task( id: 'second', title: 'Second', type: TaskType.flexible, status: TaskStatus.planned, createdAt: createdAt, start: DateTime.utc(2026, 6, 25, 9, 30), end: DateTime.utc(2026, 6, 25, 10), ); final tomorrowTask = task( id: 'tomorrow-existing', title: 'Tomorrow existing', type: TaskType.flexible, status: TaskStatus.planned, createdAt: createdAt, start: DateTime.utc(2026, 6, 26, 9), end: DateTime.utc(2026, 6, 26, 9, 30), ); final store = storeWithSettings(tasks: [first, second, tomorrowTask]); final useCases = commands(store); final pushToday = await useCases.pushFlexibleToNextAvailableSlot( context: appContext('push-today', DateTime.utc(2026, 6, 25, 8)), localDate: day, taskId: first.id, ); final pushTomorrow = await useCases.pushFlexibleToTomorrowTopOfQueue( context: appContext('push-tomorrow', DateTime.utc(2026, 6, 25, 8, 5)), targetDate: tomorrow, taskId: second.id, ); final moveBacklog = await useCases.moveFlexibleToBacklog( context: appContext('move-backlog', DateTime.utc(2026, 6, 25, 8, 10)), taskId: first.id, ); expect(pushToday.isSuccess, isTrue); expect(pushToday.requireValue.activities.first.code, TaskActivityCode.manuallyPushed); expect(pushTomorrow.isSuccess, isTrue); expect( taskById(store.currentTasks, second.id).scheduledStart, DateTime.utc(2026, 6, 26, 9), ); expect(moveBacklog.isSuccess, isTrue); expect(taskById(store.currentTasks, first.id).status, TaskStatus.backlog); expect( store.currentTaskActivities .map((activity) => activity.operationId) .toSet(), containsAll({'push-today', 'push-tomorrow', 'move-backlog'}), ); }); test('completion commands persist activities and project statistics', () async { final flexible = task( id: 'flexible', title: 'Flexible', type: TaskType.flexible, status: TaskStatus.planned, createdAt: createdAt, start: DateTime.utc(2026, 6, 25, 9), end: DateTime.utc(2026, 6, 25, 9, 30), ); final critical = task( id: 'critical', title: 'Critical', type: TaskType.critical, status: TaskStatus.planned, createdAt: createdAt, start: DateTime.utc(2026, 6, 25, 10), end: DateTime.utc(2026, 6, 25, 10, 30), ); final store = storeWithSettings(tasks: [flexible, critical]); final useCases = commands(store); final done = await useCases.completeFlexibleTask( context: appContext('complete-flexible', DateTime.utc(2026, 6, 25, 9, 20)), localDate: day, taskId: flexible.id, actualStart: DateTime.utc(2026, 6, 25, 9), actualEnd: DateTime.utc(2026, 6, 25, 9, 20), ); final missed = await useCases.applyRequiredTaskAction( context: appContext('miss-critical', DateTime.utc(2026, 6, 25, 10, 45)), localDate: day, taskId: critical.id, action: RequiredTaskAction.missed, ); expect(done.isSuccess, isTrue); expect(taskById(store.currentTasks, flexible.id).status, TaskStatus.completed); expect(missed.isSuccess, isTrue); expect( taskById(store.currentTasks, critical.id).status, TaskStatus.backlog); expect( store.currentTaskActivities.map((activity) => activity.code).toSet(), { TaskActivityCode.completed, TaskActivityCode.missed, TaskActivityCode.movedToBacklog, }, ); expect(store.currentProjectStatistics.single.completedTaskCount, 1); }); test('surprise logging creates completed work and repairs flexible overlap', () async { final planned = task( id: 'planned', title: 'Planned', type: TaskType.flexible, status: TaskStatus.planned, createdAt: createdAt, start: DateTime.utc(2026, 6, 25, 9), end: DateTime.utc(2026, 6, 25, 10), ); final store = storeWithSettings(tasks: [planned]); final result = await commands(store).logSurpriseTask( context: appContext('surprise-op', DateTime.utc(2026, 6, 25, 9, 30)), localDate: day, title: 'Unexpected call', startedAt: DateTime.utc(2026, 6, 25, 9), timeUsedMinutes: 30, ); expect(result.isSuccess, isTrue); expect(taskById(store.currentTasks, 'surprise-op').status, TaskStatus.completed); expect( taskById(store.currentTasks, planned.id).scheduledStart, isNot(DateTime.utc(2026, 6, 25, 9)), ); expect( store.currentTaskActivities.single.code, TaskActivityCode.completed); expect(store.currentProjectStatistics.single.completedTaskCount, 1); }); test('protected Free Slot create update and remove are atomic commands', () async { final store = storeWithSettings(); final useCases = commands(store); final create = await useCases.createProtectedFreeSlot( context: appContext('free-create', DateTime.utc(2026, 6, 25, 8)), localDate: day, title: 'Rest', start: DateTime.utc(2026, 6, 25, 10), end: DateTime.utc(2026, 6, 25, 11), ); final freeSlotId = create.requireValue.changedTasks.single.id; final update = await useCases.updateProtectedFreeSlot( context: appContext('free-update', DateTime.utc(2026, 6, 25, 8, 5)), localDate: day, taskId: freeSlotId, title: 'Quiet rest', start: DateTime.utc(2026, 6, 25, 11), end: DateTime.utc(2026, 6, 25, 12), ); final remove = await useCases.removeProtectedFreeSlot( context: appContext('free-remove', DateTime.utc(2026, 6, 25, 8, 10)), localDate: day, taskId: freeSlotId, ); expect(create.isSuccess, isTrue); expect(update.isSuccess, isTrue); expect(taskById(store.currentTasks, freeSlotId).title, 'Quiet rest'); expect(remove.isSuccess, isTrue); final removed = taskById(store.currentTasks, freeSlotId); expect(removed.status, TaskStatus.cancelled); expect(removed.scheduledStart, isNull); expect(store.currentOperations.map((operation) => operation.operationId), ['free-create', 'free-update', 'free-remove']); }); test('child task commands create children and propagate completion', () async { final parent = task( id: 'parent', title: 'Parent', type: TaskType.flexible, status: TaskStatus.planned, createdAt: createdAt, start: DateTime.utc(2026, 6, 25, 9), end: DateTime.utc(2026, 6, 25, 10), ); final child = task( id: 'child', title: 'Child', type: TaskType.flexible, status: TaskStatus.planned, createdAt: createdAt, parentTaskId: parent.id, ); final sibling = task( id: 'sibling', title: 'Sibling', type: TaskType.flexible, status: TaskStatus.planned, createdAt: createdAt, parentTaskId: parent.id, ); final store = storeWithSettings(tasks: [parent, child, sibling]); final useCases = commands(store); final breakUp = await useCases.breakUpTask( context: appContext('break-up', DateTime.utc(2026, 6, 25, 8)), parentTaskId: parent.id, children: const [ ApplicationChildTaskDraft(title: 'New child'), ], ); final completeParent = await useCases.completeParentTask( context: appContext('complete-parent', DateTime.utc(2026, 6, 25, 8, 30)), parentTaskId: parent.id, ); expect(breakUp.isSuccess, isTrue); expect(breakUp.requireValue.childTaskIds, hasLength(1)); expect( taskById(store.currentTasks, breakUp.requireValue.childTaskIds.single) .parentTaskId, parent.id, ); expect(completeParent.isSuccess, isTrue); expect( taskById(store.currentTasks, parent.id).status, TaskStatus.completed); expect( taskById(store.currentTasks, child.id).status, TaskStatus.completed); expect(taskById(store.currentTasks, sibling.id).status, TaskStatus.completed); expect(store.currentProjectStatistics.single.completedTaskCount, 3); }); test('complete child and complete parent from child commands are exposed', () async { final parent = task( id: 'parent', title: 'Parent', type: TaskType.flexible, status: TaskStatus.planned, createdAt: createdAt, ); final child = task( id: 'child', title: 'Child', type: TaskType.flexible, status: TaskStatus.planned, createdAt: createdAt, parentTaskId: parent.id, ); final store = storeWithSettings(tasks: [parent, child]); final useCases = commands(store); final completeChild = await useCases.completeChildTask( context: appContext('complete-child', DateTime.utc(2026, 6, 25, 8)), childTaskId: child.id, ); final secondStore = storeWithSettings(tasks: [parent, child]); final completeFromChild = await commands(secondStore).completeParentFromChild( context: appContext( 'complete-parent-from-child', DateTime.utc(2026, 6, 25, 8)), childTaskId: child.id, ); expect(completeChild.isSuccess, isTrue); expect( taskById(store.currentTasks, parent.id).status, TaskStatus.completed); expect(completeFromChild.isSuccess, isTrue); expect(taskById(secondStore.currentTasks, parent.id).status, TaskStatus.completed); expect(taskById(secondStore.currentTasks, child.id).status, TaskStatus.completed); }); }); } V1ApplicationCommandUseCases commands(InMemoryApplicationUnitOfWork store) { return V1ApplicationCommandUseCases( applicationStore: store, timeZoneResolver: const FixedOffsetTimeZoneResolver.utc(), ); } InMemoryApplicationUnitOfWork storeWithSettings({ List tasks = const [], }) { return InMemoryApplicationUnitOfWork( initialTasks: tasks, initialOwnerSettings: [ OwnerSettings( ownerId: 'owner-1', timeZoneId: 'UTC', dayStart: WallTime(hour: 9, minute: 0), dayEnd: WallTime(hour: 17, minute: 0), ), ], ); } ApplicationOperationContext appContext(String operationId, DateTime now) { return ApplicationOperationContext.start( operationId: operationId, ownerTimeZone: OwnerTimeZoneContext(ownerId: 'owner-1', timeZoneId: 'UTC'), clock: FixedClock(now), idGenerator: SequentialIdGenerator(prefix: '$operationId-task'), ); } Task task({ required String id, required String title, required TaskType type, required TaskStatus status, required DateTime createdAt, DateTime? start, DateTime? end, int? durationMinutes, String projectId = 'home', String? parentTaskId, }) { return Task( id: id, title: title, projectId: projectId, type: type, status: status, durationMinutes: durationMinutes ?? (start != null && end != null ? end.difference(start).inMinutes : 30), scheduledStart: start, scheduledEnd: end, parentTaskId: parentTaskId, createdAt: createdAt, updatedAt: createdAt, ); } Task taskById(List tasks, String taskId) { return tasks.singleWhere((task) => task.id == taskId); }