forked from eva/focus-flow
517 lines
17 KiB
Dart
517 lines
17 KiB
Dart
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<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-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<Task> tasks, String taskId) {
|
|
return tasks.singleWhere((task) => task.id == taskId);
|
|
}
|