358 lines
11 KiB
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);
|
|
}
|