import 'package:adhd_scheduler_core/scheduler_core.dart'; import 'package:test/test.dart'; void main() { group('V1AppOpenRecoveryUseCases', () { final sourceDay = CivilDate(2026, 6, 25); final targetDay = CivilDate(2026, 6, 26); final createdAt = DateTime.utc(2026, 6, 20, 8); test('rolls source-day flexible work once and exposes notice lifecycle', () async { final targetExisting = task( id: 'target-existing', title: 'Target 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: [targetExisting]); final commandUseCases = commands(store); final recoveryUseCases = recovery(store); final managementUseCases = V1ApplicationManagementUseCases(applicationStore: store); final todayQuery = today(store); final capture = await commandUseCases.quickCaptureToNextAvailableSlot( context: appContext('capture-source', DateTime.utc(2026, 6, 25, 8)), localDate: sourceDay, title: 'Captured source task', durationMinutes: 30, ); expect(capture.isSuccess, isTrue); final capturedTaskId = capture.requireValue.changedTasks.single.id; final firstOpen = await recoveryUseCases.runAppOpenRecovery( RunAppOpenRecoveryRequest( context: appContext('open-next-day', DateTime.utc(2026, 6, 26, 8)), sourceDate: sourceDay, targetDate: targetDay, ), ); expect(firstOpen.requireValue.outcome, AppOpenRecoveryOutcome.rolledOver); expect(firstOpen.requireValue.changedTasks.map((task) => task.id), [ capturedTaskId, 'target-existing', ]); expect(taskById(store.currentTasks, capturedTaskId).status, TaskStatus.planned); expect(taskById(store.currentTasks, capturedTaskId).scheduledStart, DateTime.utc(2026, 6, 26, 9)); expect(taskById(store.currentTasks, 'target-existing').scheduledStart, DateTime.utc(2026, 6, 26, 9, 30)); expect( store.currentSchedulingSnapshots.single.notices.single.movementCode, SchedulingMovementCode.unfinishedFlexibleTasksRolledOver); expect( store.currentSchedulingSnapshots.single.notices.single .parameters['count'], 1); final todayBeforeAck = (await todayQuery.execute( GetTodayStateRequest( context: appContext('today-before-ack', DateTime.utc(2026, 6, 26, 8)), date: targetDay, ), )) .requireValue; expect(todayBeforeAck.pendingNotices.single.parameters['count'], 1); await managementUseCases.acknowledgeNotice( context: appContext('ack-rollover', DateTime.utc(2026, 6, 26, 8, 5)), noticeId: todayBeforeAck.pendingNotices.single.noticeId, ); final todayAfterAck = (await todayQuery.execute( GetTodayStateRequest( context: appContext('today-after-ack', DateTime.utc(2026, 6, 26, 8, 6)), date: targetDay, ), )) .requireValue; final secondOpen = await recoveryUseCases.runAppOpenRecovery( RunAppOpenRecoveryRequest( context: appContext('open-next-day-again', DateTime.utc(2026, 6, 26, 9)), sourceDate: sourceDay, targetDate: targetDay, ), ); expect(todayAfterAck.pendingNotices, isEmpty); expect( secondOpen.requireValue.outcome, AppOpenRecoveryOutcome.alreadyProcessed, ); expect(secondOpen.requireValue.changedTasks, isEmpty); expect(store.currentSchedulingSnapshots, hasLength(1)); expect( store.currentOperations .map((operation) => operation.operationId) .toSet(), containsAll({ 'capture-source', 'open-next-day', 'ack-rollover', 'open-next-day-again', }), ); }); test('records deterministic no-op without creating a notice', () async { final completed = task( id: 'done', title: 'Done', type: TaskType.flexible, status: TaskStatus.completed, createdAt: createdAt, start: DateTime.utc(2026, 6, 25, 10), end: DateTime.utc(2026, 6, 25, 10, 30), ); final store = storeWithSettings(tasks: [completed]); final result = await recovery(store).runAppOpenRecovery( RunAppOpenRecoveryRequest( context: appContext('open-noop', DateTime.utc(2026, 6, 26, 8)), sourceDate: sourceDay, targetDate: targetDay, ), ); final duplicate = await recovery(store).runAppOpenRecovery( RunAppOpenRecoveryRequest( context: appContext('open-noop-again', DateTime.utc(2026, 6, 26, 9)), sourceDate: sourceDay, targetDate: targetDay, ), ); expect( result.requireValue.outcome, AppOpenRecoveryOutcome.noUnfinishedFlexibleTasks, ); expect(result.requireValue.notices, isEmpty); expect(store.currentSchedulingSnapshots.single.notices, isEmpty); expect(duplicate.requireValue.outcome, AppOpenRecoveryOutcome.alreadyProcessed); expect(store.currentSchedulingSnapshots, hasLength(1)); }); test('does not pull future-day tasks into source-day rollover', () async { final sourceTask = task( id: 'source', title: 'Source', type: TaskType.flexible, status: TaskStatus.planned, createdAt: createdAt, start: DateTime.utc(2026, 6, 25, 10), end: DateTime.utc(2026, 6, 25, 10, 30), ); final futureTask = task( id: 'future', title: 'Future', type: TaskType.flexible, status: TaskStatus.planned, createdAt: createdAt, start: DateTime.utc(2026, 6, 27, 9), end: DateTime.utc(2026, 6, 27, 9, 30), ); final store = storeWithSettings(tasks: [sourceTask, futureTask]); final result = await recovery(store).runAppOpenRecovery( RunAppOpenRecoveryRequest( context: appContext('open-future-safe', DateTime.utc(2026, 6, 26, 8)), sourceDate: sourceDay, targetDate: targetDay, ), ); expect(result.isSuccess, isTrue); expect(taskById(store.currentTasks, sourceTask.id).scheduledStart, DateTime.utc(2026, 6, 26, 9)); expect(taskById(store.currentTasks, futureTask.id).scheduledStart, DateTime.utc(2026, 6, 27, 9)); expect( result.requireValue.changedTasks.map((task) => task.id), isNot(contains('future')), ); }); }); } V1ApplicationCommandUseCases commands(InMemoryApplicationUnitOfWork store) { return V1ApplicationCommandUseCases( applicationStore: store, timeZoneResolver: const FixedOffsetTimeZoneResolver.utc(), ); } V1AppOpenRecoveryUseCases recovery(InMemoryApplicationUnitOfWork store) { return V1AppOpenRecoveryUseCases( applicationStore: store, timeZoneResolver: const FixedOffsetTimeZoneResolver.utc(), ); } GetTodayStateQuery today(InMemoryApplicationUnitOfWork store) { return GetTodayStateQuery( 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-id'), ); } Task task({ required String id, required String title, required TaskType type, required TaskStatus status, required DateTime createdAt, DateTime? start, DateTime? end, }) { return Task( id: id, title: title, projectId: 'home', type: type, status: status, durationMinutes: start != null && end != null ? end.difference(start).inMinutes : 30, scheduledStart: start, scheduledEnd: end, createdAt: createdAt, updatedAt: createdAt, ); } Task taskById(List tasks, String taskId) { return tasks.singleWhere((task) => task.id == taskId); }