forked from eva/focus-flow
578 lines
18 KiB
Dart
578 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.completedAt, now);
|
|
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}');
|
|
}
|