focus-flow/test/free_slots_test.dart

358 lines
11 KiB
Dart

import 'package:adhd_scheduler_core/scheduler_core.dart';
import 'package:test/test.dart';
void main() {
group('FreeSlotService', () {
final now = DateTime(2026, 6, 19, 8);
const service = FreeSlotService();
test('creates and updates scheduled Free Slot records', () {
final freeSlot = service.create(
id: 'rest',
title: 'Rest',
projectId: 'health',
start: DateTime(2026, 6, 19, 9),
end: DateTime(2026, 6, 19, 9, 30),
createdAt: now,
);
final updated = service.update(
freeSlot: freeSlot,
title: 'Recovery time',
start: DateTime(2026, 6, 19, 10),
end: DateTime(2026, 6, 19, 11),
updatedAt: now.add(const Duration(minutes: 1)),
);
expect(freeSlot.type, TaskType.freeSlot);
expect(freeSlot.status, TaskStatus.planned);
expect(freeSlot.durationMinutes, 30);
expect(freeSlot.scheduledStart, DateTime(2026, 6, 19, 9));
expect(freeSlot.scheduledEnd, DateTime(2026, 6, 19, 9, 30));
expect(updated.type, TaskType.freeSlot);
expect(updated.title, 'Recovery time');
expect(updated.durationMinutes, 60);
expect(updated.scheduledStart, DateTime(2026, 6, 19, 10));
expect(updated.scheduledEnd, DateTime(2026, 6, 19, 11));
expect(updated.updatedAt, now.add(const Duration(minutes: 1)));
});
test('rejects invalid Free Slot updates', () {
final flexible = flexibleTask(
id: 'flexible',
createdAt: now,
start: DateTime(2026, 6, 19, 9),
durationMinutes: 30,
);
expect(
() => service.update(
freeSlot: flexible,
start: DateTime(2026, 6, 19, 10),
end: DateTime(2026, 6, 19, 11),
),
throwsArgumentError,
);
expect(
() => service.create(
id: 'bad',
title: 'Bad rest',
start: DateTime(2026, 6, 19, 10),
end: DateTime(2026, 6, 19, 10),
createdAt: now,
),
throwsA(isA<DomainValidationException>()),
);
});
test('explicit required overlap returns typed conflict without moving rest',
() {
final freeSlot = service.create(
id: 'rest',
title: 'Rest',
start: DateTime(2026, 6, 19, 9),
end: DateTime(2026, 6, 19, 10),
createdAt: now,
);
final required = requiredTask(
id: 'appointment',
type: TaskType.inflexible,
createdAt: now,
);
final result = service.scheduleRequiredCommitment(
input: inputFor([freeSlot, required]),
taskId: 'appointment',
start: DateTime(2026, 6, 19, 9, 30),
end: DateTime(2026, 6, 19, 10, 30),
updatedAt: now,
);
final scheduled = taskById(result.schedulingResult.tasks, 'appointment');
final rest = taskById(result.schedulingResult.tasks, 'rest');
expect(
result.status,
RequiredCommitmentScheduleStatus.scheduledWithProtectedFreeSlotConflict,
);
expect(result.hasProtectedFreeSlotConflict, isTrue);
expect(result.protectedFreeSlotConflicts.single.freeSlotTaskId, 'rest');
expect(scheduled.scheduledStart, DateTime(2026, 6, 19, 9, 30));
expect(scheduled.scheduledEnd, DateTime(2026, 6, 19, 10, 30));
expect(rest.scheduledStart, freeSlot.scheduledStart);
expect(rest.scheduledEnd, freeSlot.scheduledEnd);
expect(result.schedulingResult.overlaps.single.taskId, 'appointment');
expect(result.schedulingResult.changes.single.taskId, 'appointment');
});
test('required boundary touch does not conflict with protected rest', () {
final freeSlot = service.create(
id: 'rest',
title: 'Rest',
start: DateTime(2026, 6, 19, 9),
end: DateTime(2026, 6, 19, 10),
createdAt: now,
);
final required = requiredTask(
id: 'meds',
type: TaskType.critical,
createdAt: now,
);
final result = service.scheduleRequiredCommitment(
input: inputFor([freeSlot, required]),
taskId: 'meds',
start: DateTime(2026, 6, 19, 10),
end: DateTime(2026, 6, 19, 10, 15),
updatedAt: now,
);
expect(result.status, RequiredCommitmentScheduleStatus.scheduled);
expect(result.protectedFreeSlotConflicts, isEmpty);
expect(result.schedulingResult.overlaps, isEmpty);
});
});
group('Free Slot automatic scheduling protection', () {
final now = DateTime(2026, 6, 19, 8);
const service = FreeSlotService();
const engine = SchedulingEngine();
test('insertion skips multiple adjacent Free Slots', () {
final backlog = Task.quickCapture(
id: 'backlog',
title: 'Make call',
createdAt: now,
).copyWith(durationMinutes: 15);
final firstRest = service.create(
id: 'rest-1',
title: 'Rest 1',
start: DateTime(2026, 6, 19, 9),
end: DateTime(2026, 6, 19, 9, 30),
createdAt: now,
);
final secondRest = service.create(
id: 'rest-2',
title: 'Rest 2',
start: DateTime(2026, 6, 19, 9, 30),
end: DateTime(2026, 6, 19, 10),
createdAt: now,
);
final result = engine.insertBacklogTaskIntoNextAvailableSlot(
input: inputFor([backlog, firstRest, secondRest]),
taskId: 'backlog',
updatedAt: now,
);
expect(taskById(result.tasks, 'backlog').scheduledStart,
DateTime(2026, 6, 19, 10));
expect(taskById(result.tasks, 'rest-1').scheduledStart,
firstRest.scheduledStart);
expect(taskById(result.tasks, 'rest-2').scheduledStart,
secondRest.scheduledStart);
expect(result.changes.map((change) => change.taskId), ['backlog']);
});
test('push skips a Free Slot at the push boundary', () {
final flexible = flexibleTask(
id: 'flexible',
createdAt: now,
start: DateTime(2026, 6, 19, 9),
durationMinutes: 30,
);
final rest = service.create(
id: 'rest',
title: 'Rest',
start: DateTime(2026, 6, 19, 9, 30),
end: DateTime(2026, 6, 19, 10),
createdAt: now,
);
final result = engine.pushFlexibleTaskToNextAvailableSlot(
input: inputFor([flexible, rest]),
taskId: 'flexible',
updatedAt: now,
);
expect(taskById(result.tasks, 'flexible').scheduledStart,
DateTime(2026, 6, 19, 10));
expect(taskById(result.tasks, 'flexible').scheduledEnd,
DateTime(2026, 6, 19, 10, 30));
expect(
taskById(result.tasks, 'rest').scheduledStart, rest.scheduledStart);
});
test('tomorrow placement preserves Free Slots at window edges', () {
final task = flexibleTask(
id: 'today',
createdAt: now,
start: DateTime(2026, 6, 19, 9),
durationMinutes: 30,
);
final startRest = service.create(
id: 'start-rest',
title: 'Start rest',
start: DateTime(2026, 6, 20, 9),
end: DateTime(2026, 6, 20, 9, 30),
createdAt: now,
);
final endRest = service.create(
id: 'end-rest',
title: 'End rest',
start: DateTime(2026, 6, 20, 10, 30),
end: DateTime(2026, 6, 20, 11),
createdAt: now,
);
final result = engine.pushFlexibleTaskToTomorrowTopOfQueue(
input: SchedulingInput(
tasks: [task, startRest, endRest],
window: SchedulingWindow(
start: DateTime(2026, 6, 20, 9),
end: DateTime(2026, 6, 20, 11),
),
),
taskId: 'today',
updatedAt: now,
);
expect(taskById(result.tasks, 'today').scheduledStart,
DateTime(2026, 6, 20, 9, 30));
expect(taskById(result.tasks, 'start-rest').scheduledStart,
startRest.scheduledStart);
expect(taskById(result.tasks, 'end-rest').scheduledStart,
endRest.scheduledStart);
});
test('whole-window Free Slot leaves no fit and does not move tasks', () {
final flexible = flexibleTask(
id: 'flexible',
createdAt: now,
start: DateTime(2026, 6, 19, 9),
durationMinutes: 30,
);
final rest = service.create(
id: 'whole-rest',
title: 'Whole morning rest',
start: DateTime(2026, 6, 20, 9),
end: DateTime(2026, 6, 20, 10),
createdAt: now,
);
final result = engine.rollOverUnfinishedFlexibleTasks(
input: SchedulingInput(
tasks: [flexible, rest],
window: SchedulingWindow(
start: DateTime(2026, 6, 20, 9),
end: DateTime(2026, 6, 20, 10),
),
),
sourceWindow: SchedulingWindow(
start: DateTime(2026, 6, 19, 0),
end: DateTime(2026, 6, 20, 0),
),
updatedAt: now,
);
expect(taskById(result.tasks, 'flexible'), same(flexible));
expect(taskById(result.tasks, 'whole-rest'), same(rest));
expect(result.changes, isEmpty);
expect(result.notices.single.type, SchedulingNoticeType.overflow);
expect(taskById(result.tasks, 'whole-rest').scheduledStart,
rest.scheduledStart);
});
});
group('Free Slot timeline metadata', () {
final now = DateTime(2026, 6, 19, 8);
const service = FreeSlotService();
test('Free Slot read model is visible without flexible quick actions', () {
final freeSlot = service.create(
id: 'rest',
title: 'Rest',
projectId: 'health',
start: DateTime(2026, 6, 19, 9),
end: DateTime(2026, 6, 19, 10),
createdAt: now,
);
final item = TimelineItemMapper().fromTask(freeSlot);
expect(item.taskType, TaskType.freeSlot);
expect(item.backgroundToken, TimelineBackgroundToken.freeSlot);
expect(item.category, TimelineItemCategory.taskCard);
expect(item.quickActions, isEmpty);
expect(item.start, freeSlot.scheduledStart);
expect(item.end, freeSlot.scheduledEnd);
});
});
}
SchedulingInput inputFor(List<Task> tasks) {
return SchedulingInput(
tasks: tasks,
window: SchedulingWindow(
start: DateTime(2026, 6, 19, 9),
end: DateTime(2026, 6, 19, 12),
),
);
}
Task flexibleTask({
required String id,
required DateTime createdAt,
required DateTime start,
required int durationMinutes,
}) {
return Task.quickCapture(
id: id,
title: id,
createdAt: createdAt,
).copyWith(
status: TaskStatus.planned,
durationMinutes: durationMinutes,
scheduledStart: start,
scheduledEnd: start.add(Duration(minutes: durationMinutes)),
);
}
Task requiredTask({
required String id,
required TaskType type,
required DateTime createdAt,
}) {
return Task(
id: id,
title: id,
projectId: 'required',
type: type,
status: TaskStatus.planned,
priority: PriorityLevel.high,
createdAt: createdAt,
updatedAt: createdAt,
);
}
Task taskById(List<Task> tasks, String id) {
return tasks.singleWhere((task) => task.id == id);
}