focus-flow/test/application_commands_test.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);
}