forked from eva/focus-flow
1057 lines
36 KiB
Dart
1057 lines
36 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, PriorityLevel.medium);
|
|
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('quick capture requires a title', () {
|
|
expect(
|
|
() => Task.quickCapture(
|
|
id: 'capture-1',
|
|
title: ' ',
|
|
createdAt: now,
|
|
),
|
|
throwsArgumentError,
|
|
);
|
|
});
|
|
|
|
test('backlog filters use derived categories over a unified list', () {
|
|
final inbox = Task.quickCapture(
|
|
id: 'inbox-1',
|
|
title: 'Buy stamps',
|
|
createdAt: now,
|
|
);
|
|
final pushed = Task.quickCapture(
|
|
id: 'pushed-1',
|
|
title: 'Fold laundry',
|
|
createdAt: now,
|
|
projectId: 'home',
|
|
).copyWith(
|
|
stats: const TaskStatistics(manuallyPushedCount: 1),
|
|
);
|
|
final criticalMissed = Task.quickCapture(
|
|
id: 'critical-1',
|
|
title: 'Pay bill',
|
|
createdAt: now,
|
|
projectId: 'money',
|
|
type: TaskType.critical,
|
|
).copyWith(
|
|
stats: const TaskStatistics(missedCount: 1),
|
|
);
|
|
final wishlist = Task.quickCapture(
|
|
id: 'wishlist-1',
|
|
title: 'Try new notebook',
|
|
createdAt: now,
|
|
projectId: 'ideas',
|
|
backlogTags: const {BacklogTag.wishlist},
|
|
);
|
|
final stale = Task.quickCapture(
|
|
id: 'stale-1',
|
|
title: 'Sort cables',
|
|
createdAt: now.subtract(const Duration(days: 40)),
|
|
projectId: 'home',
|
|
).copyWith(updatedAt: now.subtract(const Duration(days: 40)));
|
|
final planned = Task.quickCapture(
|
|
id: 'planned-1',
|
|
title: 'Scheduled task',
|
|
createdAt: now,
|
|
).copyWith(status: TaskStatus.planned);
|
|
final view = BacklogView(
|
|
tasks: [inbox, pushed, criticalMissed, wishlist, stale, planned],
|
|
now: now,
|
|
);
|
|
|
|
expect(view.backlogTasks.map((task) => task.id), [
|
|
'inbox-1',
|
|
'pushed-1',
|
|
'critical-1',
|
|
'wishlist-1',
|
|
'stale-1',
|
|
]);
|
|
expect(view.filter(BacklogFilter.inbox).map((task) => task.id), [
|
|
'inbox-1',
|
|
]);
|
|
expect(view.filter(BacklogFilter.pushed).map((task) => task.id), [
|
|
'pushed-1',
|
|
]);
|
|
expect(view.filter(BacklogFilter.criticalMissed).map((task) => task.id), [
|
|
'critical-1',
|
|
]);
|
|
expect(view.filter(BacklogFilter.wishlist).map((task) => task.id), [
|
|
'wishlist-1',
|
|
]);
|
|
expect(view.filter(BacklogFilter.stale).map((task) => task.id), [
|
|
'stale-1',
|
|
]);
|
|
expect(view.filter(BacklogFilter.noRewardSet).map((task) => task.id), [
|
|
'inbox-1',
|
|
'pushed-1',
|
|
'critical-1',
|
|
'wishlist-1',
|
|
'stale-1',
|
|
]);
|
|
});
|
|
|
|
test('backlog sorting uses backlog keys instead of original schedule', () {
|
|
final older = Task.quickCapture(
|
|
id: 'older',
|
|
title: 'Older backlog item',
|
|
createdAt: DateTime(2026, 6, 1),
|
|
).copyWith(
|
|
scheduledStart: DateTime(2026, 6, 19, 18),
|
|
scheduledEnd: DateTime(2026, 6, 19, 18, 15),
|
|
);
|
|
final newer = Task.quickCapture(
|
|
id: 'newer',
|
|
title: 'Newer backlog item',
|
|
createdAt: DateTime(2026, 6, 10),
|
|
).copyWith(
|
|
scheduledStart: DateTime(2026, 6, 19, 9),
|
|
scheduledEnd: DateTime(2026, 6, 19, 9, 15),
|
|
);
|
|
final movedOlder = const SchedulingEngine().moveToBacklog(
|
|
older,
|
|
updatedAt: now,
|
|
);
|
|
final movedNewer = const SchedulingEngine().moveToBacklog(
|
|
newer,
|
|
updatedAt: now,
|
|
);
|
|
final view = BacklogView(tasks: [movedNewer, movedOlder], now: now);
|
|
|
|
expect(movedOlder.scheduledStart, isNull);
|
|
expect(movedNewer.scheduledStart, isNull);
|
|
expect(view.sorted(BacklogSortKey.age).map((task) => task.id), [
|
|
'older',
|
|
'newer',
|
|
]);
|
|
});
|
|
|
|
test('backlog sorting supports priority reward effort project and pushes',
|
|
() {
|
|
final lowPriority = Task.quickCapture(
|
|
id: 'low',
|
|
title: 'Low',
|
|
createdAt: now,
|
|
priority: PriorityLevel.low,
|
|
reward: RewardLevel.low,
|
|
difficulty: DifficultyLevel.hard,
|
|
projectId: 'zeta',
|
|
);
|
|
final highPriority = Task.quickCapture(
|
|
id: 'high',
|
|
title: 'High',
|
|
createdAt: now,
|
|
priority: PriorityLevel.high,
|
|
reward: RewardLevel.high,
|
|
difficulty: DifficultyLevel.easy,
|
|
projectId: 'alpha',
|
|
).copyWith(
|
|
stats: const TaskStatistics(autoPushedCount: 2),
|
|
);
|
|
final mediumPriority = Task.quickCapture(
|
|
id: 'medium',
|
|
title: 'Medium',
|
|
createdAt: now,
|
|
priority: PriorityLevel.medium,
|
|
reward: RewardLevel.medium,
|
|
difficulty: DifficultyLevel.medium,
|
|
projectId: 'beta',
|
|
).copyWith(
|
|
stats: const TaskStatistics(manuallyPushedCount: 1),
|
|
);
|
|
final view = BacklogView(
|
|
tasks: [lowPriority, mediumPriority, highPriority],
|
|
now: now,
|
|
);
|
|
|
|
expect(view.sorted(BacklogSortKey.priority).map((task) => task.id), [
|
|
'high',
|
|
'medium',
|
|
'low',
|
|
]);
|
|
expect(
|
|
view.sorted(BacklogSortKey.rewardVsEffort).map((task) => task.id),
|
|
['high', 'medium', 'low'],
|
|
);
|
|
expect(view.sorted(BacklogSortKey.project).map((task) => task.id), [
|
|
'high',
|
|
'medium',
|
|
'low',
|
|
]);
|
|
expect(view.sorted(BacklogSortKey.timesPushed).map((task) => task.id), [
|
|
'high',
|
|
'medium',
|
|
'low',
|
|
]);
|
|
});
|
|
|
|
test('unchecked quick capture goes to backlog', () {
|
|
const service = QuickCaptureService();
|
|
|
|
final result = service.capture(
|
|
QuickCaptureRequest(
|
|
id: 'capture-1',
|
|
title: 'Buy stamps',
|
|
createdAt: now,
|
|
),
|
|
updatedAt: now,
|
|
);
|
|
|
|
expect(result.status, QuickCaptureStatus.addedToBacklog);
|
|
expect(result.isValid, isTrue);
|
|
expect(result.task.status, TaskStatus.backlog);
|
|
expect(result.task.scheduledStart, isNull);
|
|
expect(result.task.priority, PriorityLevel.medium);
|
|
expect(result.task.reward, RewardLevel.notSet);
|
|
expect(result.schedulingResult, isNull);
|
|
});
|
|
|
|
test('checked quick capture with duration schedules next available slot',
|
|
() {
|
|
const service = QuickCaptureService();
|
|
final plannedFlexible = Task.quickCapture(
|
|
id: 'flexible-1',
|
|
title: 'Existing flexible task',
|
|
createdAt: now,
|
|
).copyWith(
|
|
status: TaskStatus.planned,
|
|
durationMinutes: 30,
|
|
scheduledStart: DateTime(2026, 6, 19, 13),
|
|
scheduledEnd: DateTime(2026, 6, 19, 13, 30),
|
|
);
|
|
|
|
final result = service.capture(
|
|
QuickCaptureRequest(
|
|
id: 'capture-1',
|
|
title: 'Buy stamps',
|
|
createdAt: now,
|
|
addToNextAvailableSlot: true,
|
|
durationMinutes: 15,
|
|
projectId: 'errands',
|
|
reward: RewardLevel.low,
|
|
),
|
|
schedulingInput: SchedulingInput(
|
|
tasks: [plannedFlexible],
|
|
window: SchedulingWindow(
|
|
start: DateTime(2026, 6, 19, 13),
|
|
end: DateTime(2026, 6, 19, 14),
|
|
),
|
|
),
|
|
updatedAt: now,
|
|
);
|
|
|
|
expect(result.status, QuickCaptureStatus.scheduled);
|
|
expect(result.task.status, TaskStatus.planned);
|
|
expect(result.task.projectId, 'errands');
|
|
expect(result.task.reward, RewardLevel.low);
|
|
expect(result.task.scheduledStart, DateTime(2026, 6, 19, 13));
|
|
expect(result.task.scheduledEnd, DateTime(2026, 6, 19, 13, 15));
|
|
expect(
|
|
result.schedulingResult!.changes.map((change) => change.taskId),
|
|
['capture-1', 'flexible-1'],
|
|
);
|
|
});
|
|
|
|
test('checked quick capture without duration returns validation result',
|
|
() {
|
|
const service = QuickCaptureService();
|
|
|
|
final result = service.capture(
|
|
QuickCaptureRequest(
|
|
id: 'capture-1',
|
|
title: 'Buy stamps',
|
|
createdAt: now,
|
|
addToNextAvailableSlot: true,
|
|
),
|
|
schedulingInput: SchedulingInput(
|
|
tasks: const [],
|
|
window: SchedulingWindow(
|
|
start: DateTime(2026, 6, 19, 13),
|
|
end: DateTime(2026, 6, 19, 14),
|
|
),
|
|
),
|
|
updatedAt: now,
|
|
);
|
|
|
|
expect(result.status, QuickCaptureStatus.validationError);
|
|
expect(result.isValid, isFalse);
|
|
expect(result.task.status, TaskStatus.backlog);
|
|
expect(result.task.scheduledStart, isNull);
|
|
expect(result.messages, [
|
|
'Duration is required to schedule quick capture.',
|
|
]);
|
|
expect(result.schedulingResult, isNull);
|
|
});
|
|
|
|
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('push flexible task moves it after current slot', () {
|
|
final task = flexibleTask().copyWith(
|
|
scheduledStart: DateTime(2026, 6, 19, 13),
|
|
scheduledEnd: DateTime(2026, 6, 19, 13, 15),
|
|
);
|
|
final result = engine.pushFlexibleTaskToNextAvailableSlot(
|
|
input: SchedulingInput(
|
|
tasks: [task],
|
|
window: SchedulingWindow(
|
|
start: DateTime(2026, 6, 19, 13),
|
|
end: DateTime(2026, 6, 19, 14),
|
|
),
|
|
),
|
|
taskId: 'task-1',
|
|
updatedAt: now,
|
|
);
|
|
final pushed = result.tasks.single;
|
|
|
|
expect(pushed.scheduledStart, DateTime(2026, 6, 19, 13, 15));
|
|
expect(pushed.scheduledEnd, DateTime(2026, 6, 19, 13, 30));
|
|
expect(pushed.stats.manuallyPushedCount, 1);
|
|
expect(result.changes.single.previousStart, DateTime(2026, 6, 19, 13));
|
|
expect(result.changes.single.nextStart, DateTime(2026, 6, 19, 13, 15));
|
|
expect(result.notices.single.type, SchedulingNoticeType.moved);
|
|
});
|
|
|
|
test('push flexible task bumps downstream flexible tasks in order', () {
|
|
final first = flexibleTask().copyWith(
|
|
id: 'flexible-1',
|
|
scheduledStart: DateTime(2026, 6, 19, 13),
|
|
scheduledEnd: DateTime(2026, 6, 19, 13, 15),
|
|
);
|
|
final second = flexibleTask().copyWith(
|
|
id: 'flexible-2',
|
|
durationMinutes: 30,
|
|
scheduledStart: DateTime(2026, 6, 19, 13, 15),
|
|
scheduledEnd: DateTime(2026, 6, 19, 13, 45),
|
|
);
|
|
final third = flexibleTask().copyWith(
|
|
id: 'flexible-3',
|
|
scheduledStart: DateTime(2026, 6, 19, 13, 45),
|
|
scheduledEnd: DateTime(2026, 6, 19, 14),
|
|
);
|
|
final result = engine.pushFlexibleTaskToNextAvailableSlot(
|
|
input: SchedulingInput(
|
|
tasks: [first, second, third],
|
|
window: SchedulingWindow(
|
|
start: DateTime(2026, 6, 19, 13),
|
|
end: DateTime(2026, 6, 19, 14, 30),
|
|
),
|
|
),
|
|
taskId: 'flexible-1',
|
|
updatedAt: now,
|
|
);
|
|
final pushed = taskById(result.tasks, 'flexible-1');
|
|
final bumpedSecond = taskById(result.tasks, 'flexible-2');
|
|
final bumpedThird = taskById(result.tasks, 'flexible-3');
|
|
|
|
expect(pushed.scheduledStart, DateTime(2026, 6, 19, 13, 15));
|
|
expect(pushed.scheduledEnd, DateTime(2026, 6, 19, 13, 30));
|
|
expect(bumpedSecond.scheduledStart, DateTime(2026, 6, 19, 13, 30));
|
|
expect(bumpedSecond.scheduledEnd, DateTime(2026, 6, 19, 14));
|
|
expect(bumpedThird.scheduledStart, DateTime(2026, 6, 19, 14));
|
|
expect(bumpedThird.scheduledEnd, DateTime(2026, 6, 19, 14, 15));
|
|
expect(bumpedSecond.stats.autoPushedCount, 1);
|
|
expect(bumpedThird.stats.autoPushedCount, 1);
|
|
expect(result.changes.map((change) => change.taskId), [
|
|
'flexible-1',
|
|
'flexible-2',
|
|
'flexible-3',
|
|
]);
|
|
});
|
|
|
|
test('push flexible task respects locked intervals', () {
|
|
final task = flexibleTask().copyWith(
|
|
scheduledStart: DateTime(2026, 6, 19, 13),
|
|
scheduledEnd: DateTime(2026, 6, 19, 13, 15),
|
|
);
|
|
final result = engine.pushFlexibleTaskToNextAvailableSlot(
|
|
input: SchedulingInput(
|
|
tasks: [task],
|
|
window: SchedulingWindow(
|
|
start: DateTime(2026, 6, 19, 13),
|
|
end: DateTime(2026, 6, 19, 14),
|
|
),
|
|
lockedIntervals: [
|
|
TimeInterval(
|
|
start: DateTime(2026, 6, 19, 13, 15),
|
|
end: DateTime(2026, 6, 19, 13, 30),
|
|
label: 'locked-block',
|
|
),
|
|
],
|
|
),
|
|
taskId: 'task-1',
|
|
updatedAt: now,
|
|
);
|
|
final pushed = result.tasks.single;
|
|
|
|
expect(pushed.scheduledStart, DateTime(2026, 6, 19, 13, 30));
|
|
expect(pushed.scheduledEnd, DateTime(2026, 6, 19, 13, 45));
|
|
});
|
|
|
|
test('push flexible task leaves required items fixed and visible', () {
|
|
final task = flexibleTask().copyWith(
|
|
scheduledStart: DateTime(2026, 6, 19, 13),
|
|
scheduledEnd: DateTime(2026, 6, 19, 13, 15),
|
|
);
|
|
final inflexibleTask = flexibleTask().copyWith(
|
|
id: 'inflexible-1',
|
|
type: TaskType.inflexible,
|
|
scheduledStart: DateTime(2026, 6, 19, 13, 15),
|
|
scheduledEnd: DateTime(2026, 6, 19, 13, 45),
|
|
);
|
|
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.pushFlexibleTaskToNextAvailableSlot(
|
|
input: SchedulingInput(
|
|
tasks: [task, inflexibleTask, criticalTask],
|
|
window: SchedulingWindow(
|
|
start: DateTime(2026, 6, 19, 13),
|
|
end: DateTime(2026, 6, 19, 15),
|
|
),
|
|
),
|
|
taskId: 'task-1',
|
|
updatedAt: now,
|
|
);
|
|
final pushed = taskById(result.tasks, 'task-1');
|
|
final inflexible = taskById(result.tasks, 'inflexible-1');
|
|
final critical = taskById(result.tasks, 'critical-1');
|
|
|
|
expect(pushed.scheduledStart, DateTime(2026, 6, 19, 13, 45));
|
|
expect(pushed.scheduledEnd, DateTime(2026, 6, 19, 14));
|
|
expect(inflexible.scheduledStart, DateTime(2026, 6, 19, 13, 15));
|
|
expect(inflexible.scheduledEnd, DateTime(2026, 6, 19, 13, 45));
|
|
expect(critical.scheduledStart, DateTime(2026, 6, 19, 14));
|
|
expect(critical.scheduledEnd, DateTime(2026, 6, 19, 14, 30));
|
|
expect(result.changes.map((change) => change.taskId), ['task-1']);
|
|
});
|
|
|
|
test('push flexible task to tomorrow top of queue', () {
|
|
final task = flexibleTask().copyWith(
|
|
scheduledStart: DateTime(2026, 6, 19, 13),
|
|
scheduledEnd: DateTime(2026, 6, 19, 13, 15),
|
|
);
|
|
final tomorrowFlexible = flexibleTask().copyWith(
|
|
id: 'tomorrow-flexible-1',
|
|
durationMinutes: 30,
|
|
scheduledStart: DateTime(2026, 6, 20, 9),
|
|
scheduledEnd: DateTime(2026, 6, 20, 9, 30),
|
|
);
|
|
final result = engine.pushFlexibleTaskToTomorrowTopOfQueue(
|
|
input: SchedulingInput(
|
|
tasks: [task, tomorrowFlexible],
|
|
window: SchedulingWindow(
|
|
start: DateTime(2026, 6, 20, 9),
|
|
end: DateTime(2026, 6, 20, 10),
|
|
),
|
|
),
|
|
taskId: 'task-1',
|
|
updatedAt: now,
|
|
);
|
|
final movedToTomorrow = taskById(result.tasks, 'task-1');
|
|
final bumpedTomorrow = taskById(result.tasks, 'tomorrow-flexible-1');
|
|
|
|
expect(movedToTomorrow.scheduledStart, DateTime(2026, 6, 20, 9));
|
|
expect(movedToTomorrow.scheduledEnd, DateTime(2026, 6, 20, 9, 15));
|
|
expect(movedToTomorrow.stats.manuallyPushedCount, 1);
|
|
expect(bumpedTomorrow.scheduledStart, DateTime(2026, 6, 20, 9, 15));
|
|
expect(bumpedTomorrow.scheduledEnd, DateTime(2026, 6, 20, 9, 45));
|
|
expect(bumpedTomorrow.stats.autoPushedCount, 1);
|
|
expect(result.changes.map((change) => change.taskId), [
|
|
'task-1',
|
|
'tomorrow-flexible-1',
|
|
]);
|
|
expect(result.notices.first.message, 'Flexible task moved to tomorrow.');
|
|
});
|
|
|
|
test('push flexible task to tomorrow respects required and locked time',
|
|
() {
|
|
final task = flexibleTask().copyWith(
|
|
scheduledStart: DateTime(2026, 6, 19, 13),
|
|
scheduledEnd: DateTime(2026, 6, 19, 13, 15),
|
|
);
|
|
final criticalTask = flexibleTask().copyWith(
|
|
id: 'critical-1',
|
|
type: TaskType.critical,
|
|
scheduledStart: DateTime(2026, 6, 20, 9, 30),
|
|
scheduledEnd: DateTime(2026, 6, 20, 10),
|
|
);
|
|
final tomorrowFlexible = flexibleTask().copyWith(
|
|
id: 'tomorrow-flexible-1',
|
|
scheduledStart: DateTime(2026, 6, 20, 10),
|
|
scheduledEnd: DateTime(2026, 6, 20, 10, 15),
|
|
);
|
|
final result = engine.pushFlexibleTaskToTomorrowTopOfQueue(
|
|
input: SchedulingInput(
|
|
tasks: [task, criticalTask, tomorrowFlexible],
|
|
window: SchedulingWindow(
|
|
start: DateTime(2026, 6, 20, 9),
|
|
end: DateTime(2026, 6, 20, 11),
|
|
),
|
|
lockedIntervals: [
|
|
TimeInterval(
|
|
start: DateTime(2026, 6, 20, 9),
|
|
end: DateTime(2026, 6, 20, 9, 30),
|
|
label: 'locked-block',
|
|
),
|
|
],
|
|
),
|
|
taskId: 'task-1',
|
|
updatedAt: now,
|
|
);
|
|
final movedToTomorrow = taskById(result.tasks, 'task-1');
|
|
final critical = taskById(result.tasks, 'critical-1');
|
|
final bumpedTomorrow = taskById(result.tasks, 'tomorrow-flexible-1');
|
|
|
|
expect(movedToTomorrow.scheduledStart, DateTime(2026, 6, 20, 10));
|
|
expect(movedToTomorrow.scheduledEnd, DateTime(2026, 6, 20, 10, 15));
|
|
expect(critical.scheduledStart, DateTime(2026, 6, 20, 9, 30));
|
|
expect(critical.scheduledEnd, DateTime(2026, 6, 20, 10));
|
|
expect(bumpedTomorrow.scheduledStart, DateTime(2026, 6, 20, 10, 15));
|
|
expect(bumpedTomorrow.scheduledEnd, DateTime(2026, 6, 20, 10, 30));
|
|
expect(result.changes.map((change) => change.taskId), [
|
|
'task-1',
|
|
'tomorrow-flexible-1',
|
|
]);
|
|
});
|
|
|
|
test('rollover moves unfinished flexible tasks to tomorrow in order', () {
|
|
final first = flexibleTask().copyWith(
|
|
id: 'flexible-1',
|
|
scheduledStart: DateTime(2026, 6, 19, 13),
|
|
scheduledEnd: DateTime(2026, 6, 19, 13, 15),
|
|
);
|
|
final second = flexibleTask().copyWith(
|
|
id: 'flexible-2',
|
|
status: TaskStatus.active,
|
|
scheduledStart: DateTime(2026, 6, 19, 14),
|
|
scheduledEnd: DateTime(2026, 6, 19, 14, 30),
|
|
);
|
|
final tomorrowFlexible = flexibleTask().copyWith(
|
|
id: 'tomorrow-flexible-1',
|
|
durationMinutes: 15,
|
|
scheduledStart: DateTime(2026, 6, 20, 9),
|
|
scheduledEnd: DateTime(2026, 6, 20, 9, 15),
|
|
);
|
|
final result = engine.rollOverUnfinishedFlexibleTasks(
|
|
input: SchedulingInput(
|
|
tasks: [first, second, tomorrowFlexible],
|
|
window: SchedulingWindow(
|
|
start: DateTime(2026, 6, 20, 9),
|
|
end: DateTime(2026, 6, 20, 10, 30),
|
|
),
|
|
),
|
|
updatedAt: now,
|
|
);
|
|
final rolledFirst = taskById(result.tasks, 'flexible-1');
|
|
final rolledSecond = taskById(result.tasks, 'flexible-2');
|
|
final bumpedTomorrow = taskById(result.tasks, 'tomorrow-flexible-1');
|
|
|
|
expect(rolledFirst.status, TaskStatus.planned);
|
|
expect(rolledFirst.scheduledStart, DateTime(2026, 6, 20, 9));
|
|
expect(rolledFirst.scheduledEnd, DateTime(2026, 6, 20, 9, 15));
|
|
expect(rolledSecond.status, TaskStatus.planned);
|
|
expect(rolledSecond.scheduledStart, DateTime(2026, 6, 20, 9, 15));
|
|
expect(rolledSecond.scheduledEnd, DateTime(2026, 6, 20, 9, 45));
|
|
expect(bumpedTomorrow.scheduledStart, DateTime(2026, 6, 20, 9, 45));
|
|
expect(bumpedTomorrow.scheduledEnd, DateTime(2026, 6, 20, 10));
|
|
expect(result.changes.map((change) => change.taskId), [
|
|
'flexible-1',
|
|
'flexible-2',
|
|
'tomorrow-flexible-1',
|
|
]);
|
|
expect(
|
|
result.notices.first.message,
|
|
'2 unfinished flexible tasks were moved to tomorrow.',
|
|
);
|
|
});
|
|
|
|
test('rollover excludes locked required completed and cancelled tasks', () {
|
|
final flexible = flexibleTask().copyWith(
|
|
id: 'flexible-1',
|
|
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 inflexible = flexibleTask().copyWith(
|
|
id: 'inflexible-1',
|
|
type: TaskType.inflexible,
|
|
scheduledStart: DateTime(2026, 6, 19, 15),
|
|
scheduledEnd: DateTime(2026, 6, 19, 15, 30),
|
|
);
|
|
final critical = flexibleTask().copyWith(
|
|
id: 'critical-1',
|
|
type: TaskType.critical,
|
|
scheduledStart: DateTime(2026, 6, 19, 16),
|
|
scheduledEnd: DateTime(2026, 6, 19, 16, 30),
|
|
);
|
|
final completed = flexibleTask().copyWith(
|
|
id: 'completed-1',
|
|
status: TaskStatus.completed,
|
|
scheduledStart: DateTime(2026, 6, 19, 17),
|
|
scheduledEnd: DateTime(2026, 6, 19, 17, 15),
|
|
);
|
|
final cancelled = flexibleTask().copyWith(
|
|
id: 'cancelled-1',
|
|
status: TaskStatus.cancelled,
|
|
scheduledStart: DateTime(2026, 6, 19, 18),
|
|
scheduledEnd: DateTime(2026, 6, 19, 18, 15),
|
|
);
|
|
final result = engine.rollOverUnfinishedFlexibleTasks(
|
|
input: SchedulingInput(
|
|
tasks: [flexible, locked, inflexible, critical, completed, cancelled],
|
|
window: SchedulingWindow(
|
|
start: DateTime(2026, 6, 20, 9),
|
|
end: DateTime(2026, 6, 20, 10),
|
|
),
|
|
),
|
|
updatedAt: now,
|
|
);
|
|
|
|
expect(taskById(result.tasks, 'flexible-1').scheduledStart,
|
|
DateTime(2026, 6, 20, 9));
|
|
expect(taskById(result.tasks, 'locked-1').scheduledStart,
|
|
DateTime(2026, 6, 19, 14));
|
|
expect(taskById(result.tasks, 'inflexible-1').scheduledStart,
|
|
DateTime(2026, 6, 19, 15));
|
|
expect(taskById(result.tasks, 'critical-1').scheduledStart,
|
|
DateTime(2026, 6, 19, 16));
|
|
expect(taskById(result.tasks, 'completed-1').scheduledStart,
|
|
DateTime(2026, 6, 19, 17));
|
|
expect(taskById(result.tasks, 'cancelled-1').scheduledStart,
|
|
DateTime(2026, 6, 19, 18));
|
|
expect(result.changes.map((change) => change.taskId), ['flexible-1']);
|
|
expect(
|
|
result.notices.first.message,
|
|
'1 unfinished flexible tasks were moved to tomorrow.',
|
|
);
|
|
});
|
|
|
|
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));
|
|
});
|
|
});
|
|
}
|