focus-flow/test/surprise_task_logging_test.dart

287 lines
8.7 KiB
Dart

import 'package:adhd_scheduler_core/scheduler_core.dart';
import 'package:test/test.dart';
void main() {
group('Surprise task logging', () {
final now = DateTime(2026, 6, 19, 12);
const service = SurpriseTaskLogService();
test('creates a completed surprise task that occupies the logged time', () {
final result = service.log(
request: SurpriseTaskLogRequest(
id: 'surprise-1',
title: 'Answered urgent text',
createdAt: now,
startedAt: DateTime(2026, 6, 19, 9),
timeUsedMinutes: 20,
projectId: 'relationships',
priority: PriorityLevel.high,
reward: RewardLevel.medium,
),
input: emptyInput(),
updatedAt: now,
);
expect(result.surpriseTask.type, TaskType.surprise);
expect(result.surpriseTask.status, TaskStatus.completed);
expect(result.surpriseTask.title, 'Answered urgent text');
expect(result.surpriseTask.projectId, 'relationships');
expect(result.surpriseTask.priority, PriorityLevel.high);
expect(result.surpriseTask.reward, RewardLevel.medium);
expect(result.surpriseTask.durationMinutes, 20);
expect(result.surpriseTask.scheduledStart, DateTime(2026, 6, 19, 9));
expect(result.surpriseTask.scheduledEnd, DateTime(2026, 6, 19, 9, 20));
expect(
result.schedulingResult.tasks.map((task) => task.id),
['surprise-1'],
);
});
test('pushes overlapping flexible tasks using normal push rules', () {
final flexible = plannedFlexibleTask(
id: 'flexible',
title: 'Write notes',
createdAt: now,
start: DateTime(2026, 6, 19, 9),
durationMinutes: 30,
);
final result = service.log(
request: SurpriseTaskLogRequest(
id: 'surprise-1',
title: 'Unplanned call',
createdAt: now,
startedAt: DateTime(2026, 6, 19, 9, 10),
timeUsedMinutes: 20,
),
input: SchedulingInput(
tasks: [flexible],
window: SchedulingWindow(
start: DateTime(2026, 6, 19, 9),
end: DateTime(2026, 6, 19, 11),
),
),
updatedAt: now,
);
final moved = taskById(result.schedulingResult.tasks, 'flexible');
expect(moved.scheduledStart, DateTime(2026, 6, 19, 9, 30));
expect(moved.scheduledEnd, DateTime(2026, 6, 19, 10));
expect(moved.stats.autoPushedCount, 1);
expect(moved.stats.manuallyPushedCount, 0);
expect(result.schedulingResult.changes.single.taskId, 'flexible');
expect(result.usedNormalPushRules, isTrue);
});
test('does not move inflexible or critical overlaps and reports them', () {
final inflexible = fixedTask(
id: 'appointment',
type: TaskType.inflexible,
createdAt: now,
start: DateTime(2026, 6, 19, 9),
durationMinutes: 30,
);
final critical = fixedTask(
id: 'meds',
type: TaskType.critical,
createdAt: now,
start: DateTime(2026, 6, 19, 9, 20),
durationMinutes: 20,
);
final result = service.log(
request: SurpriseTaskLogRequest(
id: 'surprise-1',
title: 'Unplanned call',
createdAt: now,
startedAt: DateTime(2026, 6, 19, 9, 10),
timeUsedMinutes: 20,
),
input: SchedulingInput(
tasks: [inflexible, critical],
window: SchedulingWindow(
start: DateTime(2026, 6, 19, 9),
end: DateTime(2026, 6, 19, 11),
),
),
updatedAt: now,
);
expect(taskById(result.schedulingResult.tasks, 'appointment'),
sameScheduleAs(inflexible));
expect(taskById(result.schedulingResult.tasks, 'meds'),
sameScheduleAs(critical));
expect(
result.requiredOverlaps.map((overlap) => overlap.taskId),
['appointment', 'meds'],
);
expect(
result.schedulingResult.overlaps.map((overlap) => overlap.taskId),
['appointment', 'meds'],
);
});
test('tracks locked overlap without rendering locked time as a task', () {
final result = service.log(
request: SurpriseTaskLogRequest(
id: 'surprise-1',
title: 'Unplanned call',
createdAt: now,
startedAt: DateTime(2026, 6, 19, 9, 10),
timeUsedMinutes: 20,
),
input: SchedulingInput(
tasks: const [],
window: SchedulingWindow(
start: DateTime(2026, 6, 19, 9),
end: DateTime(2026, 6, 19, 11),
),
lockedIntervals: [
TimeInterval(
start: DateTime(2026, 6, 19, 9),
end: DateTime(2026, 6, 19, 10),
label: 'hidden-work',
),
],
),
updatedAt: now,
);
expect(result.lockedOverlaps.single.label, 'hidden-work');
expect(result.schedulingResult.tasks.map((task) => task.type), [
TaskType.surprise,
]);
expect(result.schedulingResult.overlaps, isEmpty);
});
test('optional fields can be omitted without blocking save', () {
final result = service.log(
request: SurpriseTaskLogRequest(
id: 'surprise-1',
title: ' Took a break ',
createdAt: now,
),
input: emptyInput(),
updatedAt: now,
);
expect(result.surpriseTask.title, 'Took a break');
expect(result.surpriseTask.projectId, 'inbox');
expect(result.surpriseTask.priority, isNull);
expect(result.surpriseTask.reward, RewardLevel.notSet);
expect(result.surpriseTask.durationMinutes, isNull);
expect(result.surpriseTask.scheduledStart, isNull);
expect(result.surpriseTask.scheduledEnd, isNull);
expect(result.schedulingResult.tasks.single.id, 'surprise-1');
});
test('does not push the same flexible task twice after cascade movement',
() {
final first = plannedFlexibleTask(
id: 'first',
title: 'First',
createdAt: now,
start: DateTime(2026, 6, 19, 9),
durationMinutes: 30,
);
final second = plannedFlexibleTask(
id: 'second',
title: 'Second',
createdAt: now,
start: DateTime(2026, 6, 19, 9, 30),
durationMinutes: 30,
);
final result = service.log(
request: SurpriseTaskLogRequest(
id: 'surprise-1',
title: 'Unplanned call',
createdAt: now,
startedAt: DateTime(2026, 6, 19, 9, 10),
timeUsedMinutes: 40,
),
input: SchedulingInput(
tasks: [first, second],
window: SchedulingWindow(
start: DateTime(2026, 6, 19, 9),
end: DateTime(2026, 6, 19, 12),
),
),
updatedAt: now,
);
expect(taskById(result.schedulingResult.tasks, 'first').scheduledStart,
DateTime(2026, 6, 19, 9, 50));
expect(taskById(result.schedulingResult.tasks, 'second').scheduledStart,
DateTime(2026, 6, 19, 10, 20));
expect(
result.schedulingResult.changes.map((change) => change.taskId),
['first', 'second'],
);
});
});
}
SchedulingInput emptyInput() {
return SchedulingInput(
tasks: const [],
window: SchedulingWindow(
start: DateTime(2026, 6, 19, 9),
end: DateTime(2026, 6, 19, 17),
),
);
}
Task plannedFlexibleTask({
required String id,
required String title,
required DateTime createdAt,
required DateTime start,
required int durationMinutes,
}) {
return Task.quickCapture(
id: id,
title: title,
createdAt: createdAt,
).copyWith(
status: TaskStatus.planned,
durationMinutes: durationMinutes,
scheduledStart: start,
scheduledEnd: start.add(Duration(minutes: durationMinutes)),
);
}
Task fixedTask({
required String id,
required TaskType type,
required DateTime createdAt,
required DateTime start,
required int durationMinutes,
}) {
return Task(
id: id,
title: id,
projectId: 'fixed',
type: type,
status: TaskStatus.planned,
priority: PriorityLevel.high,
durationMinutes: durationMinutes,
scheduledStart: start,
scheduledEnd: start.add(Duration(minutes: durationMinutes)),
createdAt: createdAt,
updatedAt: createdAt,
);
}
Task taskById(List<Task> tasks, String id) {
return tasks.singleWhere((task) => task.id == id);
}
Matcher sameScheduleAs(Task expected) {
return predicate<Task>((actual) {
return actual.scheduledStart == expected.scheduledStart &&
actual.scheduledEnd == expected.scheduledEnd &&
actual.status == expected.status &&
actual.type == expected.type;
}, 'same schedule and state as ${expected.id}');
}