forked from eva/focus-flow
475 lines
15 KiB
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,
|
|
);
|
|
}
|