focus-flow/test/surprise_task_logging_test.dart

577 lines
18 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.surpriseTask.actualStart, DateTime(2026, 6, 19, 9));
expect(result.surpriseTask.actualEnd, 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 leaking hidden details by default', () {
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, isNull);
expect(result.lockedOverlaps.single.start, DateTime(2026, 6, 19, 9));
expect(result.lockedOverlaps.single.end, DateTime(2026, 6, 19, 10));
expect(result.schedulingResult.tasks.map((task) => task.type), [
TaskType.surprise,
]);
expect(result.schedulingResult.overlaps, isEmpty);
});
test('can reveal hidden locked overlap details when explicitly requested',
() {
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,
revealHiddenLockedDetails: true,
);
expect(result.lockedOverlaps.single.label, 'hidden-work');
});
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.surpriseTask.actualStart, isNull);
expect(result.surpriseTask.actualEnd, 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'],
);
});
test('retrying the same surprise id is a no-op', () {
final flexible = plannedFlexibleTask(
id: 'flexible',
title: 'Write notes',
createdAt: now,
start: DateTime(2026, 6, 19, 9),
durationMinutes: 30,
);
final first = 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 retry = service.log(
request: SurpriseTaskLogRequest(
id: 'surprise-1',
title: 'Unplanned call',
createdAt: now,
startedAt: DateTime(2026, 6, 19, 9, 10),
timeUsedMinutes: 20,
),
input: SchedulingInput(
tasks: first.schedulingResult.tasks,
window: SchedulingWindow(
start: DateTime(2026, 6, 19, 9),
end: DateTime(2026, 6, 19, 11),
),
),
updatedAt: now,
);
expect(
retry.schedulingResult.tasks
.where((task) => task.id == 'surprise-1')
.length,
1,
);
expect(retry.schedulingResult.changes, isEmpty);
expect(
taskById(retry.schedulingResult.tasks, 'flexible').scheduledStart,
taskById(first.schedulingResult.tasks, 'flexible').scheduledStart,
);
expect(
retry.schedulingResult.notices.single.issueCode,
SchedulingIssueCode.duplicateSurpriseLog,
);
});
test('later insertion avoids persisted surprise actual occupancy', () {
final surpriseResult = service.log(
request: SurpriseTaskLogRequest(
id: 'surprise-1',
title: 'Unplanned call',
createdAt: now,
startedAt: DateTime(2026, 6, 19, 9),
timeUsedMinutes: 30,
),
input: SchedulingInput(
tasks: const [],
window: SchedulingWindow(
start: DateTime(2026, 6, 19, 9),
end: DateTime(2026, 6, 19, 11),
),
),
updatedAt: now,
);
final backlog = Task.quickCapture(
id: 'backlog',
title: 'Make call',
createdAt: now,
).copyWith(durationMinutes: 15);
final later =
const SchedulingEngine().insertBacklogTaskIntoNextAvailableSlot(
input: SchedulingInput(
tasks: [backlog, ...surpriseResult.schedulingResult.tasks],
window: SchedulingWindow(
start: DateTime(2026, 6, 19, 9),
end: DateTime(2026, 6, 19, 11),
),
),
taskId: 'backlog',
updatedAt: now,
);
expect(taskById(later.tasks, 'backlog').scheduledStart,
DateTime(2026, 6, 19, 9, 30));
});
test('active and completed actual intervals block later insertion', () {
final active = plannedFlexibleTask(
id: 'active',
title: 'Active',
createdAt: now,
start: DateTime(2026, 6, 19, 8),
durationMinutes: 30,
).copyWith(
status: TaskStatus.active,
actualStart: DateTime(2026, 6, 19, 9),
actualEnd: DateTime(2026, 6, 19, 9, 20),
);
final completed = plannedFlexibleTask(
id: 'completed',
title: 'Completed',
createdAt: now,
start: DateTime(2026, 6, 19, 8, 30),
durationMinutes: 30,
).copyWith(
status: TaskStatus.completed,
actualStart: DateTime(2026, 6, 19, 9, 20),
actualEnd: DateTime(2026, 6, 19, 9, 40),
);
final backlog = Task.quickCapture(
id: 'backlog',
title: 'Make call',
createdAt: now,
).copyWith(durationMinutes: 15);
final result =
const SchedulingEngine().insertBacklogTaskIntoNextAvailableSlot(
input: SchedulingInput(
tasks: [backlog, active, completed],
window: SchedulingWindow(
start: DateTime(2026, 6, 19, 9),
end: DateTime(2026, 6, 19, 11),
),
),
taskId: 'backlog',
updatedAt: now,
);
expect(taskById(result.tasks, 'backlog').scheduledStart,
DateTime(2026, 6, 19, 9, 40));
expect(taskById(result.tasks, 'active').scheduledStart,
active.scheduledStart);
expect(taskById(result.tasks, 'completed').actualStart,
completed.actualStart);
});
test('exact surprise boundaries do not move adjacent flexible tasks', () {
final before = plannedFlexibleTask(
id: 'before',
title: 'Before',
createdAt: now,
start: DateTime(2026, 6, 19, 9),
durationMinutes: 30,
);
final after = plannedFlexibleTask(
id: 'after',
title: 'After',
createdAt: now,
start: DateTime(2026, 6, 19, 10),
durationMinutes: 30,
);
final result = service.log(
request: SurpriseTaskLogRequest(
id: 'surprise-1',
title: 'Unplanned call',
createdAt: now,
startedAt: DateTime(2026, 6, 19, 9, 30),
timeUsedMinutes: 30,
),
input: SchedulingInput(
tasks: [before, after],
window: SchedulingWindow(
start: DateTime(2026, 6, 19, 9),
end: DateTime(2026, 6, 19, 11),
),
),
updatedAt: now,
);
expect(result.schedulingResult.changes, isEmpty);
expect(taskById(result.schedulingResult.tasks, 'before').scheduledStart,
before.scheduledStart);
expect(taskById(result.schedulingResult.tasks, 'after').scheduledStart,
after.scheduledStart);
});
test('multiple surprise entries remain occupied for later scheduling', () {
final first = service.log(
request: SurpriseTaskLogRequest(
id: 'surprise-1',
title: 'First',
createdAt: now,
startedAt: DateTime(2026, 6, 19, 9),
timeUsedMinutes: 15,
),
input: SchedulingInput(
tasks: const [],
window: SchedulingWindow(
start: DateTime(2026, 6, 19, 9),
end: DateTime(2026, 6, 19, 11),
),
),
updatedAt: now,
);
final second = service.log(
request: SurpriseTaskLogRequest(
id: 'surprise-2',
title: 'Second',
createdAt: now,
startedAt: DateTime(2026, 6, 19, 9, 15),
timeUsedMinutes: 15,
),
input: SchedulingInput(
tasks: first.schedulingResult.tasks,
window: SchedulingWindow(
start: DateTime(2026, 6, 19, 9),
end: DateTime(2026, 6, 19, 11),
),
),
updatedAt: now,
);
final backlog = Task.quickCapture(
id: 'backlog',
title: 'Make call',
createdAt: now,
).copyWith(durationMinutes: 15);
final later =
const SchedulingEngine().insertBacklogTaskIntoNextAvailableSlot(
input: SchedulingInput(
tasks: [backlog, ...second.schedulingResult.tasks],
window: SchedulingWindow(
start: DateTime(2026, 6, 19, 9),
end: DateTime(2026, 6, 19, 11),
),
),
taskId: 'backlog',
updatedAt: now,
);
expect(later.tasks.where((task) => task.type == TaskType.surprise).length,
2);
expect(taskById(later.tasks, 'backlog').scheduledStart,
DateTime(2026, 6, 19, 9, 30));
});
});
}
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}');
}