focus-flow/test/scheduling_engine_test.dart

428 lines
15 KiB
Dart

import 'package:adhd_scheduler_core/scheduler_core.dart';
import 'package:test/test.dart';
void main() {
group('Domain model', () {
final now = DateTime(2026, 6, 19, 12);
test('core enum values are importable', () {
expect(TaskType.locked, TaskType.locked);
expect(TaskStatus.noLongerRelevant, TaskStatus.noLongerRelevant);
expect(PriorityLevel.veryHigh, PriorityLevel.veryHigh);
expect(RewardLevel.notSet, RewardLevel.notSet);
expect(DifficultyLevel.notSet, DifficultyLevel.notSet);
expect(ReminderProfile.gentle, ReminderProfile.gentle);
});
test('quick capture uses neutral defaults without schedule details', () {
final task = Task.quickCapture(
id: 'capture-1',
title: 'Buy stamps',
createdAt: now,
);
expect(task.projectId, 'inbox');
expect(task.type, TaskType.flexible);
expect(task.status, TaskStatus.backlog);
expect(task.priority, isNull);
expect(task.reward, RewardLevel.notSet);
expect(task.difficulty, DifficultyLevel.notSet);
expect(task.durationMinutes, isNull);
expect(task.scheduledStart, isNull);
expect(task.scheduledEnd, isNull);
expect(task.createdAt, now);
expect(task.updatedAt, now);
});
test('project defaults apply while task overrides remain possible', () {
const project = ProjectProfile(
id: 'home',
name: 'Home',
colorKey: 'green',
defaultPriority: PriorityLevel.high,
defaultReward: RewardLevel.medium,
defaultDifficulty: DifficultyLevel.easy,
defaultReminderProfile: ReminderProfile.persistent,
defaultDurationMinutes: 25,
);
final defaulted = project.createTask(
id: 'task-1',
title: 'Clean desk',
createdAt: now,
);
final overridden = project.createTask(
id: 'task-2',
title: 'Fold laundry',
createdAt: now,
priority: PriorityLevel.low,
reward: RewardLevel.high,
difficulty: DifficultyLevel.hard,
durationMinutes: 10,
);
expect(defaulted.projectId, 'home');
expect(defaulted.priority, PriorityLevel.high);
expect(defaulted.reward, RewardLevel.medium);
expect(defaulted.difficulty, DifficultyLevel.easy);
expect(defaulted.durationMinutes, 25);
expect(overridden.priority, PriorityLevel.low);
expect(overridden.reward, RewardLevel.high);
expect(overridden.difficulty, DifficultyLevel.hard);
expect(overridden.durationMinutes, 10);
});
test('statistics increments preserve existing values immutably', () {
const stats = TaskStatistics(missedCount: 2);
final updated = stats.incrementCompletedDuringLockedHours(15);
expect(stats.completedDuringLockedHoursCount, 0);
expect(stats.completedDuringLockedHoursMinutes, 0);
expect(updated.missedCount, 2);
expect(updated.completedDuringLockedHoursCount, 1);
expect(updated.completedDuringLockedHoursMinutes, 15);
});
});
group('SchedulingEngine starter behavior', () {
final now = DateTime(2026, 6, 19, 12);
const engine = SchedulingEngine();
Task flexibleTask() {
return Task(
id: 'task-1',
title: 'Clean coffee maker',
projectId: 'home',
type: TaskType.flexible,
status: TaskStatus.planned,
priority: PriorityLevel.medium,
durationMinutes: 15,
createdAt: now,
updatedAt: now,
);
}
Task taskById(List<Task> tasks, String id) {
return tasks.singleWhere((task) => task.id == id);
}
test('scheduling input classifies in-memory task groups', () {
final flexible = flexibleTask().copyWith(
scheduledStart: DateTime(2026, 6, 19, 13),
scheduledEnd: DateTime(2026, 6, 19, 13, 15),
);
final locked = flexibleTask().copyWith(
id: 'locked-1',
type: TaskType.locked,
scheduledStart: DateTime(2026, 6, 19, 14),
scheduledEnd: DateTime(2026, 6, 19, 15),
);
final critical = flexibleTask().copyWith(
id: 'critical-1',
type: TaskType.critical,
scheduledStart: DateTime(2026, 6, 19, 16),
scheduledEnd: DateTime(2026, 6, 19, 16, 30),
);
final input = SchedulingInput(
tasks: [flexible, locked, critical],
window: SchedulingWindow(
start: DateTime(2026, 6, 19, 12),
end: DateTime(2026, 6, 19, 18),
),
);
expect(input.flexibleTasks.map((task) => task.id), ['task-1']);
expect(input.lockedTasks.map((task) => task.id), ['locked-1']);
expect(input.requiredVisibleTasks.map((task) => task.id), ['critical-1']);
expect(input.blockedIntervals.map((interval) => interval.label), [
'locked-1',
'critical-1',
]);
});
test('analyzeSchedule reports flexible overlaps without moving tasks', () {
final flexible = flexibleTask().copyWith(
scheduledStart: DateTime(2026, 6, 19, 13),
scheduledEnd: DateTime(2026, 6, 19, 13, 30),
);
final tasks = [flexible];
final result = engine.analyzeSchedule(
SchedulingInput(
tasks: tasks,
window: SchedulingWindow(
start: DateTime(2026, 6, 19, 12),
end: DateTime(2026, 6, 19, 18),
),
lockedIntervals: [
TimeInterval(
start: DateTime(2026, 6, 19, 13, 15),
end: DateTime(2026, 6, 19, 14),
label: 'locked-block',
),
],
),
);
expect(result.tasks, same(tasks));
expect(result.changes, isEmpty);
expect(result.overlaps.single.taskId, 'task-1');
expect(result.overlaps.single.blockedInterval.label, 'locked-block');
expect(result.notices.single.type, SchedulingNoticeType.overlap);
expect(result.notices.single.taskId, 'task-1');
});
test('scheduling result can describe exact task movement', () {
final moved = flexibleTask().copyWith(
scheduledStart: DateTime(2026, 6, 19, 14),
scheduledEnd: DateTime(2026, 6, 19, 14, 15),
);
final result = SchedulingResult(
tasks: [moved],
notices: const [
SchedulingNotice(
'Flexible task moved.',
type: SchedulingNoticeType.moved,
taskId: 'task-1',
),
],
changes: [
SchedulingChange(
taskId: 'task-1',
previousStart: DateTime(2026, 6, 19, 13),
previousEnd: DateTime(2026, 6, 19, 13, 15),
nextStart: DateTime(2026, 6, 19, 14),
nextEnd: DateTime(2026, 6, 19, 14, 15),
),
],
);
expect(result.tasks.single.scheduledStart, DateTime(2026, 6, 19, 14));
expect(result.changes.single.previousStart, DateTime(2026, 6, 19, 13));
expect(result.changes.single.nextEnd, DateTime(2026, 6, 19, 14, 15));
expect(result.notices.single.type, SchedulingNoticeType.moved);
});
test('insert backlog task into an open 20-minute slot', () {
final backlogTask = Task.quickCapture(
id: 'backlog-1',
title: 'Start laundry',
createdAt: now,
).copyWith(durationMinutes: 15);
final result = engine.insertBacklogTaskIntoNextAvailableSlot(
input: SchedulingInput(
tasks: [backlogTask],
window: SchedulingWindow(
start: DateTime(2026, 6, 19, 13),
end: DateTime(2026, 6, 19, 13, 20),
),
),
taskId: 'backlog-1',
updatedAt: now,
);
final inserted = result.tasks.single;
expect(inserted.status, TaskStatus.planned);
expect(inserted.scheduledStart, DateTime(2026, 6, 19, 13));
expect(inserted.scheduledEnd, DateTime(2026, 6, 19, 13, 15));
expect(inserted.stats.restoredFromBacklogCount, 1);
expect(result.changes.single.taskId, 'backlog-1');
expect(result.notices.single.type, SchedulingNoticeType.moved);
});
test('insert backlog task before flexible task and push later task', () {
final backlogTask = Task.quickCapture(
id: 'backlog-1',
title: 'Start laundry',
createdAt: now,
).copyWith(durationMinutes: 15);
final plannedFlexible = flexibleTask().copyWith(
id: 'flexible-1',
durationMinutes: 30,
scheduledStart: DateTime(2026, 6, 19, 13),
scheduledEnd: DateTime(2026, 6, 19, 13, 30),
);
final result = engine.insertBacklogTaskIntoNextAvailableSlot(
input: SchedulingInput(
tasks: [backlogTask, plannedFlexible],
window: SchedulingWindow(
start: DateTime(2026, 6, 19, 13),
end: DateTime(2026, 6, 19, 14),
),
),
taskId: 'backlog-1',
updatedAt: now,
);
final inserted = taskById(result.tasks, 'backlog-1');
final pushed = taskById(result.tasks, 'flexible-1');
expect(inserted.scheduledStart, DateTime(2026, 6, 19, 13));
expect(inserted.scheduledEnd, DateTime(2026, 6, 19, 13, 15));
expect(pushed.scheduledStart, DateTime(2026, 6, 19, 13, 15));
expect(pushed.scheduledEnd, DateTime(2026, 6, 19, 13, 45));
expect(pushed.stats.autoPushedCount, 1);
expect(result.changes.map((change) => change.taskId), [
'backlog-1',
'flexible-1',
]);
});
test('insert backlog task skips locked time', () {
final backlogTask = Task.quickCapture(
id: 'backlog-1',
title: 'Start laundry',
createdAt: now,
).copyWith(durationMinutes: 15);
final result = engine.insertBacklogTaskIntoNextAvailableSlot(
input: SchedulingInput(
tasks: [backlogTask],
window: SchedulingWindow(
start: DateTime(2026, 6, 19, 13),
end: DateTime(2026, 6, 19, 14),
),
lockedIntervals: [
TimeInterval(
start: DateTime(2026, 6, 19, 13),
end: DateTime(2026, 6, 19, 13, 20),
label: 'appointment',
),
],
),
taskId: 'backlog-1',
updatedAt: now,
);
final inserted = result.tasks.single;
expect(inserted.scheduledStart, DateTime(2026, 6, 19, 13, 20));
expect(inserted.scheduledEnd, DateTime(2026, 6, 19, 13, 35));
});
test('insert backlog task does not move inflexible or critical blocks', () {
final backlogTask = Task.quickCapture(
id: 'backlog-1',
title: 'Start laundry',
createdAt: now,
).copyWith(durationMinutes: 15);
final inflexibleTask = flexibleTask().copyWith(
id: 'inflexible-1',
type: TaskType.inflexible,
scheduledStart: DateTime(2026, 6, 19, 13),
scheduledEnd: DateTime(2026, 6, 19, 13, 30),
);
final criticalTask = flexibleTask().copyWith(
id: 'critical-1',
type: TaskType.critical,
scheduledStart: DateTime(2026, 6, 19, 14),
scheduledEnd: DateTime(2026, 6, 19, 14, 30),
);
final result = engine.insertBacklogTaskIntoNextAvailableSlot(
input: SchedulingInput(
tasks: [backlogTask, inflexibleTask, criticalTask],
window: SchedulingWindow(
start: DateTime(2026, 6, 19, 13),
end: DateTime(2026, 6, 19, 15),
),
),
taskId: 'backlog-1',
updatedAt: now,
);
final inserted = taskById(result.tasks, 'backlog-1');
final inflexible = taskById(result.tasks, 'inflexible-1');
final critical = taskById(result.tasks, 'critical-1');
expect(inserted.scheduledStart, DateTime(2026, 6, 19, 13, 30));
expect(inserted.scheduledEnd, DateTime(2026, 6, 19, 13, 45));
expect(inflexible.scheduledStart, DateTime(2026, 6, 19, 13));
expect(inflexible.scheduledEnd, DateTime(2026, 6, 19, 13, 30));
expect(critical.scheduledStart, DateTime(2026, 6, 19, 14));
expect(critical.scheduledEnd, DateTime(2026, 6, 19, 14, 30));
expect(result.changes.map((change) => change.taskId), ['backlog-1']);
});
test('insert backlog task returns overflow when no slot fits today', () {
final backlogTask = Task.quickCapture(
id: 'backlog-1',
title: 'Start laundry',
createdAt: now,
).copyWith(durationMinutes: 15);
final tasks = [backlogTask];
final result = engine.insertBacklogTaskIntoNextAvailableSlot(
input: SchedulingInput(
tasks: tasks,
window: SchedulingWindow(
start: DateTime(2026, 6, 19, 13),
end: DateTime(2026, 6, 19, 13, 30),
),
lockedIntervals: [
TimeInterval(
start: DateTime(2026, 6, 19, 13),
end: DateTime(2026, 6, 19, 13, 30),
label: 'appointment',
),
],
),
taskId: 'backlog-1',
updatedAt: now,
);
expect(result.tasks, same(tasks));
expect(result.changes, isEmpty);
expect(result.notices.single.type, SchedulingNoticeType.overflow);
expect(result.notices.single.taskId, 'backlog-1');
expect(backlogTask.status, TaskStatus.backlog);
});
test('moveToBacklog clears active schedule placement', () {
final task = flexibleTask().copyWith(
scheduledStart: DateTime(2026, 6, 19, 18),
scheduledEnd: DateTime(2026, 6, 19, 18, 15),
);
final moved = engine.moveToBacklog(task, updatedAt: now);
expect(moved.status, TaskStatus.backlog);
expect(moved.scheduledStart, isNull);
expect(moved.scheduledEnd, isNull);
expect(moved.stats.movedToBacklogCount, 1);
});
test('critical missed task goes to backlog', () {
final task = flexibleTask().copyWith(type: TaskType.critical);
final missed = engine.markMissed(task, updatedAt: now);
expect(missed.status, TaskStatus.backlog);
expect(missed.stats.missedCount, 1);
expect(missed.stats.movedToBacklogCount, 1);
});
test('inflexible missed task remains missed in place', () {
final task = flexibleTask().copyWith(type: TaskType.inflexible);
final missed = engine.markMissed(task, updatedAt: now);
expect(missed.status, TaskStatus.missed);
expect(missed.stats.missedCount, 1);
});
test('findFirstOpenInterval skips blocked time', () {
final slot = engine.findFirstOpenInterval(
windowStart: DateTime(2026, 6, 19, 17),
windowEnd: DateTime(2026, 6, 19, 20),
duration: const Duration(minutes: 30),
blocked: [
TimeInterval(
start: DateTime(2026, 6, 19, 17),
end: DateTime(2026, 6, 19, 18),
label: 'Work overflow',
),
],
);
expect(slot, isNotNull);
expect(slot!.start, DateTime(2026, 6, 19, 18));
expect(slot.end, DateTime(2026, 6, 19, 18, 30));
});
});
}