focus-flow/test/task_lifecycle_test.dart

475 lines
15 KiB
Dart

import 'package:adhd_scheduler_core/scheduler_core.dart';
import 'package:test/test.dart';
void main() {
group('Task transition service', () {
final now = DateTime(2026, 6, 25, 9);
const service = TaskTransitionService();
test('completion records completedAt actual interval and activity', () {
final task = scheduledTask(id: 'task-1', now: now);
final actualStart = DateTime(2026, 6, 25, 9, 5);
final actualEnd = DateTime(2026, 6, 25, 9, 20);
final result = service.apply(
task: task,
transitionCode: TaskTransitionCode.complete,
operationId: 'op-complete',
activityId: 'activity-complete',
occurredAt: now.add(const Duration(minutes: 30)),
actualStart: actualStart,
actualEnd: actualEnd,
);
expect(result.outcomeCode, TaskTransitionOutcomeCode.applied);
expect(result.task.status, TaskStatus.completed);
expect(result.task.completedAt, now.add(const Duration(minutes: 30)));
expect(result.task.actualStart, actualStart);
expect(result.task.actualEnd, actualEnd);
expect(result.activities.single.id, 'activity-complete');
expect(result.activities.single.operationId, 'op-complete');
expect(result.activities.single.code, TaskActivityCode.completed);
expect(result.task.stats.completedLateCount, 1);
expect(
result.activities.single.metadata,
containsPair('previousStatus', 'planned'),
);
expect(
result.activities.single.metadata,
containsPair('nextStatus', 'completed'),
);
expect(
result.activities.single.metadata,
containsPair('pushesBeforeCompletion', 0),
);
});
test('late-by-zero completion is not counted late', () {
final task = scheduledTask(id: 'task-1', now: now);
final result = service.apply(
task: task,
transitionCode: TaskTransitionCode.complete,
operationId: 'op-complete',
activityId: 'activity-complete',
occurredAt: task.scheduledEnd,
);
expect(result.task.completedAt, task.scheduledEnd);
expect(result.task.stats.completedLateCount, 0);
});
test('completion accounting uses actual locked overlap boundaries', () {
final task = scheduledTask(
id: 'task-1',
now: now,
start: DateTime(2026, 6, 25, 9),
durationMinutes: 90,
);
final result = service.apply(
task: task,
transitionCode: TaskTransitionCode.complete,
operationId: 'op-complete',
activityId: 'activity-complete',
occurredAt: DateTime(2026, 6, 25, 10, 30),
actualStart: DateTime(2026, 6, 25, 9, 15),
actualEnd: DateTime(2026, 6, 25, 10, 15),
lockedIntervals: [
TimeInterval(
start: DateTime(2026, 6, 25, 8),
end: DateTime(2026, 6, 25, 9, 15),
),
TimeInterval(
start: DateTime(2026, 6, 25, 9, 30),
end: DateTime(2026, 6, 25, 9, 45),
),
TimeInterval(
start: DateTime(2026, 6, 25, 9, 40),
end: DateTime(2026, 6, 25, 10),
),
],
);
expect(result.task.stats.completedDuringLockedHoursCount, 1);
expect(result.task.stats.completedDuringLockedHoursMinutes, 30);
});
test('completion without actual interval does not fabricate locked minutes',
() {
final task = scheduledTask(id: 'task-1', now: now);
final result = service.apply(
task: task,
transitionCode: TaskTransitionCode.complete,
operationId: 'op-complete',
activityId: 'activity-complete',
occurredAt: DateTime(2026, 6, 25, 9, 20),
lockedIntervals: [
TimeInterval(
start: DateTime(2026, 6, 25, 9),
end: DateTime(2026, 6, 25, 10),
),
],
);
expect(result.task.actualStart, isNull);
expect(result.task.stats.completedDuringLockedHoursCount, 0);
expect(result.task.stats.completedDuringLockedHoursMinutes, 0);
});
test('completion captures pushes present at completion', () {
final task = scheduledTask(id: 'task-1', now: now).copyWith(
stats: const TaskStatistics(
manuallyPushedCount: 2,
autoPushedCount: 1,
),
);
final result = service.apply(
task: task,
transitionCode: TaskTransitionCode.complete,
operationId: 'op-complete',
activityId: 'activity-complete',
occurredAt: task.scheduledEnd,
);
expect(result.activities.single.metadata,
containsPair('pushesBeforeCompletion', 3));
expect(result.task.stats.completedAfterPushCount, 1);
expect(result.task.stats.totalPushesBeforeCompletion, 3);
});
test('duplicate operation ids do not create activities or mutate again',
() {
final task = scheduledTask(id: 'task-1', now: now);
final first = service.apply(
task: task,
transitionCode: TaskTransitionCode.manualPush,
operationId: 'op-push',
activityId: 'activity-push',
occurredAt: now,
);
final duplicate = service.apply(
task: first.task,
transitionCode: TaskTransitionCode.manualPush,
operationId: 'op-push',
activityId: 'activity-push-again',
occurredAt: now.add(const Duration(minutes: 5)),
existingActivities: first.activities,
);
expect(first.task.stats.manuallyPushedCount, 1);
expect(first.activities.single.code, TaskActivityCode.manuallyPushed);
expect(
duplicate.outcomeCode, TaskTransitionOutcomeCode.duplicateOperation);
expect(duplicate.duplicateOfActivityId, 'activity-push');
expect(duplicate.activities, isEmpty);
expect(duplicate.task.stats.manuallyPushedCount, 1);
});
test('terminal states no-op for same transition and reject reopening', () {
final completed = scheduledTask(id: 'done', now: now).copyWith(
status: TaskStatus.completed,
completedAt: now,
);
final repeatComplete = service.apply(
task: completed,
transitionCode: TaskTransitionCode.complete,
operationId: 'op-repeat',
activityId: 'activity-repeat',
occurredAt: now,
);
final activateCompleted = service.apply(
task: completed,
transitionCode: TaskTransitionCode.activate,
operationId: 'op-activate',
activityId: 'activity-activate',
occurredAt: now,
);
expect(repeatComplete.outcomeCode, TaskTransitionOutcomeCode.noOp);
expect(repeatComplete.task, completed);
expect(repeatComplete.activities, isEmpty);
expect(activateCompleted.outcomeCode,
TaskTransitionOutcomeCode.invalidState);
expect(activateCompleted.task.status, TaskStatus.completed);
});
test('critical missed records miss and backlog movement activities', () {
final task = requiredTask(
id: 'meds',
type: TaskType.critical,
now: now,
);
final result = service.apply(
task: task,
transitionCode: TaskTransitionCode.miss,
operationId: 'op-missed',
activityId: 'activity-missed',
occurredAt: now,
);
expect(result.outcomeCode, TaskTransitionOutcomeCode.applied);
expect(result.task.status, TaskStatus.backlog);
expect(result.task.scheduledStart, isNull);
expect(result.task.stats.missedCount, 1);
expect(result.task.stats.movedToBacklogCount, 1);
expect(result.activities.map((activity) => activity.code), [
TaskActivityCode.missed,
TaskActivityCode.movedToBacklog,
]);
});
test(
'restore and activation use activity records without movement statuses',
() {
final backlog = Task.quickCapture(
id: 'backlog',
title: 'Backlog',
createdAt: now,
);
final restored = service.apply(
task: backlog,
transitionCode: TaskTransitionCode.restoreFromBacklog,
operationId: 'op-restore',
activityId: 'activity-restore',
occurredAt: now,
);
final activated = service.apply(
task: restored.task,
transitionCode: TaskTransitionCode.activate,
operationId: 'op-activate',
activityId: 'activity-activate',
occurredAt: now,
);
expect(restored.task.status, TaskStatus.planned);
expect(restored.task.stats.restoredFromBacklogCount, 1);
expect(restored.activities.single.code,
TaskActivityCode.restoredFromBacklog);
expect(activated.task.status, TaskStatus.active);
expect(activated.activities.single.code, TaskActivityCode.activated);
expect(TaskStatus.values.map((status) => status.name),
isNot(contains('pushed')));
});
});
group('Task activity accounting service', () {
final now = DateTime(2026, 6, 25, 9);
const service = TaskActivityAccountingService();
test('applies activity counters once by activity id', () {
final task = scheduledTask(id: 'task-1', now: now);
final activity = TaskActivity(
id: 'activity-push',
operationId: 'op-push',
code: TaskActivityCode.manuallyPushed,
taskId: task.id,
projectId: task.projectId,
occurredAt: now,
);
final first = service.apply(task: task, activities: [activity]);
final replay = service.apply(
task: first.task,
activities: [activity],
alreadyAppliedActivityIds: first.appliedActivityIds,
);
expect(first.task.stats.manuallyPushedCount, 1);
expect(first.appliedActivityIds, ['activity-push']);
expect(replay.task.stats.manuallyPushedCount, 1);
expect(replay.appliedActivityIds, isEmpty);
});
});
group('Action services use canonical transitions', () {
final now = DateTime(2026, 6, 25, 9);
test('flexible done returns completion activity', () {
const service = FlexibleTaskActionService();
final task = scheduledTask(id: 'flex', now: now);
final result = service.apply(
task: task,
action: FlexibleTaskQuickAction.done,
updatedAt: now,
operationId: 'op-flex-done',
);
expect(result.transitionOutcomeCode, TaskTransitionOutcomeCode.applied);
expect(result.task.completedAt, now);
expect(result.activities.single.code, TaskActivityCode.completed);
expect(result.activities.single.operationId, 'op-flex-done');
});
test('required missed returns canonical activities', () {
const service = RequiredTaskActionService();
final task = requiredTask(
id: 'critical',
type: TaskType.critical,
now: now,
);
final result = service.apply(
task: task,
action: RequiredTaskAction.missed,
updatedAt: now,
operationId: 'op-required-missed',
);
expect(result.transitionOutcomeCode, TaskTransitionOutcomeCode.applied);
expect(result.task.status, TaskStatus.backlog);
expect(result.activities.map((activity) => activity.code), [
TaskActivityCode.missed,
TaskActivityCode.movedToBacklog,
]);
});
test('push destination returns movement activities for changed tasks', () {
const service = FlexibleTaskActionService();
final first = scheduledTask(id: 'first', now: now);
final second = scheduledTask(
id: 'second',
now: now,
start: DateTime(2026, 6, 25, 9, 20),
);
final result = service.applyPushDestination(
destination: PushDestination.nextAvailableSlot,
input: SchedulingInput(
tasks: [first, second],
window: SchedulingWindow(
start: DateTime(2026, 6, 25, 9),
end: DateTime(2026, 6, 25, 11),
),
),
taskId: first.id,
updatedAt: now,
operationId: 'op-push',
);
expect(
result.schedulingResult.outcomeCode, SchedulingOutcomeCode.success);
expect(result.activities.map((activity) => activity.code), [
TaskActivityCode.manuallyPushed,
TaskActivityCode.automaticallyPushed,
]);
expect(
result.activities.map((activity) => activity.operationId).toSet(), {
'op-push',
});
});
test('duplicate push destination operation id returns no-op result', () {
const service = FlexibleTaskActionService();
final task = scheduledTask(id: 'first', now: now);
final previousActivity = TaskActivity(
id: 'activity-push',
operationId: 'op-push',
code: TaskActivityCode.manuallyPushed,
taskId: task.id,
projectId: task.projectId,
occurredAt: now,
);
final result = service.applyPushDestination(
destination: PushDestination.nextAvailableSlot,
input: SchedulingInput(
tasks: [task],
window: SchedulingWindow(
start: DateTime(2026, 6, 25, 9),
end: DateTime(2026, 6, 25, 11),
),
),
taskId: task.id,
updatedAt: now,
operationId: 'op-push',
existingActivities: [previousActivity],
);
expect(result.schedulingResult.outcomeCode, SchedulingOutcomeCode.noOp);
expect(result.schedulingResult.tasks.single, task);
expect(result.activities, isEmpty);
});
test('surprise logging applies completion accounting', () {
const service = SurpriseTaskLogService();
final result = service.log(
request: SurpriseTaskLogRequest(
id: 'surprise',
title: 'Unexpected call',
createdAt: now,
startedAt: DateTime(2026, 6, 25, 9, 10),
timeUsedMinutes: 40,
),
input: SchedulingInput(
tasks: const [],
window: SchedulingWindow(
start: DateTime(2026, 6, 25, 9),
end: DateTime(2026, 6, 25, 11),
),
lockedIntervals: [
TimeInterval(
start: DateTime(2026, 6, 25, 9, 30),
end: DateTime(2026, 6, 25, 10),
),
],
),
updatedAt: now,
);
expect(result.activities.single.code, TaskActivityCode.completed);
expect(result.surpriseTask.completedAt, now);
expect(result.surpriseTask.stats.completedDuringLockedHoursCount, 1);
expect(result.surpriseTask.stats.completedDuringLockedHoursMinutes, 20);
});
});
}
Task scheduledTask({
required String id,
required DateTime now,
DateTime? start,
int durationMinutes = 15,
}) {
final scheduledStart = start ?? DateTime(2026, 6, 25, 9);
return Task(
id: id,
title: id,
projectId: 'project',
type: TaskType.flexible,
status: TaskStatus.planned,
priority: PriorityLevel.medium,
durationMinutes: durationMinutes,
scheduledStart: scheduledStart,
scheduledEnd: scheduledStart.add(Duration(minutes: durationMinutes)),
createdAt: now,
updatedAt: now,
);
}
Task requiredTask({
required String id,
required TaskType type,
required DateTime now,
}) {
return Task(
id: id,
title: id,
projectId: 'required',
type: type,
status: TaskStatus.planned,
priority: PriorityLevel.high,
durationMinutes: 30,
scheduledStart: DateTime(2026, 6, 25, 9),
scheduledEnd: DateTime(2026, 6, 25, 9, 30),
createdAt: now,
updatedAt: now,
);
}