forked from eva/focus-flow
280 lines
8.9 KiB
Dart
280 lines
8.9 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.activities.single.metadata,
|
|
containsPair('previousStatus', 'planned'),
|
|
);
|
|
expect(
|
|
result.activities.single.metadata,
|
|
containsPair('nextStatus', 'completed'),
|
|
);
|
|
});
|
|
|
|
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('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',
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
Task scheduledTask({
|
|
required String id,
|
|
required DateTime now,
|
|
DateTime? start,
|
|
}) {
|
|
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: 15,
|
|
scheduledStart: scheduledStart,
|
|
scheduledEnd: scheduledStart.add(const Duration(minutes: 15)),
|
|
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,
|
|
);
|
|
}
|