forked from eva/focus-flow
266 lines
8.6 KiB
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);
|
|
}
|