focus-flow/test/application_recovery_test.dart

266 lines
8.6 KiB
Dart

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<Task> tasks = const <Task>[],
}) {
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<Task> tasks, String taskId) {
return tasks.singleWhere((task) => task.id == taskId);
}