focus-flow/test/scheduling_engine_test.dart

1632 lines
55 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('backlog staleness markers cover default thresholds', () {
final fresh = Task.quickCapture(
id: 'fresh',
title: 'Fresh',
createdAt: now.subtract(const Duration(days: 7)),
);
final aging = Task.quickCapture(
id: 'aging',
title: 'Aging',
createdAt: now.subtract(const Duration(days: 8)),
);
final stale = Task.quickCapture(
id: 'stale',
title: 'Stale',
createdAt: now.subtract(const Duration(days: 31)),
);
final view = BacklogView(tasks: [fresh, aging, stale], now: now);
expect(view.stalenessMarkerFor(fresh), BacklogStalenessMarker.green);
expect(view.stalenessMarkerFor(aging), BacklogStalenessMarker.blue);
expect(view.stalenessMarkerFor(stale), BacklogStalenessMarker.purple);
});
test('backlog staleness markers support configurable thresholds', () {
final task = Task.quickCapture(
id: 'custom',
title: 'Custom',
createdAt: now.subtract(const Duration(days: 5)),
);
final view = BacklogView(
tasks: [task],
now: now,
stalenessSettings: const BacklogStalenessSettings(
greenMaxAge: Duration(days: 2),
blueMaxAge: Duration(days: 4),
),
);
expect(view.stalenessMarkerFor(task), BacklogStalenessMarker.purple);
});
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);
});
test('locked block model supports one-off and recurring constraints', () {
final oneOff = LockedBlock(
id: 'appointment',
name: 'Appointment',
startTime: const ClockTime(hour: 13, minute: 0),
endTime: const ClockTime(hour: 14, minute: 0),
date: DateTime(2026, 6, 19),
createdAt: now,
updatedAt: now,
projectId: 'health',
);
final workHours = LockedBlock(
id: 'work-hours',
name: 'Work',
startTime: const ClockTime(hour: 9, minute: 0),
endTime: const ClockTime(hour: 17, minute: 0),
recurrence: const LockedBlockRecurrence.weekly(
weekdays: {
LockedWeekday.monday,
LockedWeekday.tuesday,
LockedWeekday.wednesday,
LockedWeekday.thursday,
LockedWeekday.friday,
},
),
createdAt: now,
updatedAt: now,
);
expect(oneOff.isRecurring, isFalse);
expect(oneOff.hiddenByDefault, isTrue);
expect(oneOff.projectId, 'health');
expect(workHours.isRecurring, isTrue);
expect(workHours.hiddenByDefault, isTrue);
expect(workHours.recurrence!.occursOn(DateTime(2026, 6, 19)), isTrue);
expect(workHours.recurrence!.occursOn(DateTime(2026, 6, 20)), isFalse);
});
test('locked block override removes only one date', () {
final override = LockedBlockOverride.remove(
id: 'friday-off',
lockedBlockId: 'work-hours',
date: DateTime(2026, 6, 19),
createdAt: now,
);
expect(override.type, LockedBlockOverrideType.remove);
expect(
override.appliesTo(
blockId: 'work-hours',
occurrenceDate: DateTime(2026, 6, 19),
),
isTrue,
);
expect(
override.appliesTo(
blockId: 'work-hours',
occurrenceDate: DateTime(2026, 6, 20),
),
isFalse,
);
});
test('locked block override modifies one date without recurrence changes',
() {
final recurrence = const LockedBlockRecurrence.weekly(
weekdays: {
LockedWeekday.monday,
LockedWeekday.tuesday,
LockedWeekday.wednesday,
LockedWeekday.thursday,
LockedWeekday.friday,
},
);
final workHours = LockedBlock(
id: 'work-hours',
name: 'Work',
startTime: const ClockTime(hour: 9, minute: 0),
endTime: const ClockTime(hour: 17, minute: 0),
recurrence: recurrence,
createdAt: now,
updatedAt: now,
);
final halfDay = LockedBlockOverride.replace(
id: 'half-day',
lockedBlockId: 'work-hours',
date: DateTime(2026, 6, 19),
startTime: const ClockTime(hour: 9, minute: 0),
endTime: const ClockTime(hour: 12, minute: 0),
createdAt: now,
);
final interval = halfDay.intervalForDate(DateTime(2026, 6, 19));
expect(interval, isNotNull);
expect(interval!.start, DateTime(2026, 6, 19, 9));
expect(interval.end, DateTime(2026, 6, 19, 12));
expect(workHours.recurrence, same(recurrence));
expect(workHours.recurrence!.occursOn(DateTime(2026, 6, 19)), isTrue);
});
test('locked block override can add surprise locked time', () {
final surprise = LockedBlockOverride.add(
id: 'surprise-lock',
date: DateTime(2026, 6, 19),
name: 'Urgent errand',
startTime: const ClockTime(hour: 15, minute: 0),
endTime: const ClockTime(hour: 16, minute: 0),
createdAt: now,
projectId: 'home',
);
final interval = surprise.intervalForDate(DateTime(2026, 6, 19));
expect(surprise.type, LockedBlockOverrideType.add);
expect(surprise.lockedBlockId, isNull);
expect(surprise.projectId, 'home');
expect(interval, isNotNull);
expect(interval!.start, DateTime(2026, 6, 19, 15));
expect(interval.end, DateTime(2026, 6, 19, 16));
});
test('locked block expansion returns weekday occurrences', () {
final workHours = LockedBlock(
id: 'work-hours',
name: 'Work',
startTime: const ClockTime(hour: 9, minute: 0),
endTime: const ClockTime(hour: 17, minute: 0),
recurrence: const LockedBlockRecurrence.weekly(
weekdays: {
LockedWeekday.monday,
LockedWeekday.tuesday,
LockedWeekday.wednesday,
LockedWeekday.thursday,
LockedWeekday.friday,
},
),
createdAt: now,
updatedAt: now,
);
final fridayExpansion = expandLockedBlocksForDay(
blocks: [workHours],
overrides: const [],
date: DateTime(2026, 6, 19),
);
final saturdayExpansion = expandLockedBlocksForDay(
blocks: [workHours],
overrides: const [],
date: DateTime(2026, 6, 20),
);
final occurrence = fridayExpansion.occurrences.single;
expect(fridayExpansion.date, DateTime(2026, 6, 19));
expect(occurrence.lockedBlockId, 'work-hours');
expect(occurrence.overrideId, isNull);
expect(occurrence.name, 'Work');
expect(occurrence.interval.start, DateTime(2026, 6, 19, 9));
expect(occurrence.interval.end, DateTime(2026, 6, 19, 17));
expect(occurrence.hiddenByDefault, isTrue);
expect(occurrence.schedulingInterval.label, 'Work');
expect(saturdayExpansion.occurrences, isEmpty);
});
test('locked block expansion applies remove and replace overrides', () {
final workHours = LockedBlock(
id: 'work-hours',
name: 'Work',
startTime: const ClockTime(hour: 9, minute: 0),
endTime: const ClockTime(hour: 17, minute: 0),
recurrence: const LockedBlockRecurrence.weekly(
weekdays: {
LockedWeekday.monday,
LockedWeekday.tuesday,
LockedWeekday.wednesday,
LockedWeekday.thursday,
LockedWeekday.friday,
},
),
hiddenByDefault: true,
createdAt: now,
updatedAt: now,
);
final fridayOff = LockedBlockOverride.remove(
id: 'friday-off',
lockedBlockId: 'work-hours',
date: DateTime(2026, 6, 19),
createdAt: now,
);
final halfDay = LockedBlockOverride.replace(
id: 'half-day',
lockedBlockId: 'work-hours',
date: DateTime(2026, 6, 22),
name: 'Half-day work',
startTime: const ClockTime(hour: 9, minute: 0),
endTime: const ClockTime(hour: 12, minute: 0),
hiddenByDefault: false,
createdAt: now.add(const Duration(minutes: 1)),
);
final removed = expandLockedBlocksForDay(
blocks: [workHours],
overrides: [fridayOff, halfDay],
date: DateTime(2026, 6, 19),
);
final modified = expandLockedBlocksForDay(
blocks: [workHours],
overrides: [fridayOff, halfDay],
date: DateTime(2026, 6, 22),
);
final normal = expandLockedBlocksForDay(
blocks: [workHours],
overrides: [fridayOff, halfDay],
date: DateTime(2026, 6, 23),
);
final halfDayOccurrence = modified.occurrences.single;
expect(removed.occurrences, isEmpty);
expect(halfDayOccurrence.lockedBlockId, 'work-hours');
expect(halfDayOccurrence.overrideId, 'half-day');
expect(halfDayOccurrence.name, 'Half-day work');
expect(halfDayOccurrence.interval.start, DateTime(2026, 6, 22, 9));
expect(halfDayOccurrence.interval.end, DateTime(2026, 6, 22, 12));
expect(halfDayOccurrence.hiddenByDefault, isFalse);
expect(normal.occurrences.single.interval.end, DateTime(2026, 6, 23, 17));
expect(workHours.recurrence!.occursOn(DateTime(2026, 6, 22)), isTrue);
});
test('locked block expansion includes surprise locked time overrides', () {
final surprise = LockedBlockOverride.add(
id: 'surprise-lock',
date: DateTime(2026, 6, 19),
name: 'Urgent errand',
startTime: const ClockTime(hour: 15, minute: 0),
endTime: const ClockTime(hour: 16, minute: 0),
createdAt: now,
projectId: 'home',
);
final expansion = expandLockedBlocksForDay(
blocks: const [],
overrides: [surprise],
date: DateTime(2026, 6, 19),
);
final occurrence = expansion.occurrences.single;
expect(occurrence.lockedBlockId, isNull);
expect(occurrence.overrideId, 'surprise-lock');
expect(occurrence.name, 'Urgent errand');
expect(occurrence.projectId, 'home');
expect(occurrence.interval.start, DateTime(2026, 6, 19, 15));
expect(occurrence.interval.end, DateTime(2026, 6, 19, 16));
});
test(
'locked hour completion tracking covers task entirely inside locked time',
() {
final task = Task.quickCapture(
id: 'task-1',
title: 'Handle paperwork',
createdAt: now,
).copyWith(
status: TaskStatus.completed,
scheduledStart: DateTime(2026, 6, 19, 13, 15),
scheduledEnd: DateTime(2026, 6, 19, 13, 45),
);
final updated = trackCompletedDuringLockedHours(
task: task,
lockedIntervals: [
TimeInterval(
start: DateTime(2026, 6, 19, 13),
end: DateTime(2026, 6, 19, 14),
label: 'work',
),
],
);
expect(updated.stats.completedDuringLockedHoursCount, 1);
expect(updated.stats.completedDuringLockedHoursMinutes, 30);
expect(task.stats.completedDuringLockedHoursCount, 0);
});
test('locked hour completion tracking covers partial overlap', () {
final task = Task.quickCapture(
id: 'task-1',
title: 'Handle paperwork',
createdAt: now,
).copyWith(
status: TaskStatus.completed,
scheduledStart: DateTime(2026, 6, 19, 13, 45),
scheduledEnd: DateTime(2026, 6, 19, 14, 15),
);
final minutes = completedDuringLockedHoursMinutes(
task: task,
lockedIntervals: [
TimeInterval(
start: DateTime(2026, 6, 19, 13),
end: DateTime(2026, 6, 19, 14),
label: 'work',
),
],
);
expect(minutes, 15);
});
test('locked hour completion tracking ignores no-overlap tasks', () {
final task = Task.quickCapture(
id: 'task-1',
title: 'Handle paperwork',
createdAt: now,
).copyWith(
status: TaskStatus.completed,
scheduledStart: DateTime(2026, 6, 19, 14),
scheduledEnd: DateTime(2026, 6, 19, 14, 30),
);
final updated = trackCompletedDuringLockedHours(
task: task,
lockedIntervals: [
TimeInterval(
start: DateTime(2026, 6, 19, 13),
end: DateTime(2026, 6, 19, 14),
label: 'work',
),
],
);
expect(updated, same(task));
expect(updated.stats.completedDuringLockedHoursCount, 0);
expect(updated.stats.completedDuringLockedHoursMinutes, 0);
});
test('locked hour completion tracking covers surprise task overlap', () {
final task = Task.quickCapture(
id: 'surprise-1',
title: 'Unexpected call',
createdAt: now,
).copyWith(
type: TaskType.surprise,
scheduledStart: DateTime(2026, 6, 19, 13, 45),
scheduledEnd: DateTime(2026, 6, 19, 14, 15),
);
final updated = trackCompletedDuringLockedHours(
task: task,
lockedIntervals: [
TimeInterval(
start: DateTime(2026, 6, 19, 13),
end: DateTime(2026, 6, 19, 14),
label: 'work',
),
],
);
expect(updated.stats.completedDuringLockedHoursCount, 1);
expect(updated.stats.completedDuringLockedHoursMinutes, 15);
});
test('locked hour completion tracking does not double count overlaps', () {
final task = Task.quickCapture(
id: 'task-1',
title: 'Handle paperwork',
createdAt: now,
).copyWith(
status: TaskStatus.completed,
scheduledStart: DateTime(2026, 6, 19, 13),
scheduledEnd: DateTime(2026, 6, 19, 14),
);
final minutes = completedDuringLockedHoursMinutes(
task: task,
lockedIntervals: [
TimeInterval(
start: DateTime(2026, 6, 19, 13),
end: DateTime(2026, 6, 19, 13, 45),
label: 'work',
),
TimeInterval(
start: DateTime(2026, 6, 19, 13, 30),
end: DateTime(2026, 6, 19, 14),
label: 'appointment',
),
],
);
expect(minutes, 60);
});
test('flexible quick action done marks task completed', () {
const service = FlexibleTaskActionService();
final task = Task.quickCapture(
id: 'task-1',
title: 'Handle paperwork',
createdAt: now,
).copyWith(
status: TaskStatus.planned,
scheduledStart: DateTime(2026, 6, 19, 13),
scheduledEnd: DateTime(2026, 6, 19, 13, 30),
);
final result = service.apply(
task: task,
action: FlexibleTaskQuickAction.done,
updatedAt: now,
);
expect(result.action, FlexibleTaskQuickAction.done);
expect(result.task.status, TaskStatus.completed);
expect(result.task.scheduledStart, DateTime(2026, 6, 19, 13));
expect(result.task.updatedAt, now);
expect(result.changedTask, isTrue);
});
test('flexible quick action backlog clears schedule placement', () {
const service = FlexibleTaskActionService();
final task = Task.quickCapture(
id: 'task-1',
title: 'Handle paperwork',
createdAt: now,
).copyWith(
status: TaskStatus.planned,
scheduledStart: DateTime(2026, 6, 19, 13),
scheduledEnd: DateTime(2026, 6, 19, 13, 30),
);
final result = service.apply(
task: task,
action: FlexibleTaskQuickAction.backlog,
updatedAt: now,
);
expect(result.action, FlexibleTaskQuickAction.backlog);
expect(result.task.status, TaskStatus.backlog);
expect(result.task.scheduledStart, isNull);
expect(result.task.scheduledEnd, isNull);
expect(result.task.stats.movedToBacklogCount, 1);
expect(result.changedTask, isTrue);
});
test('flexible quick action push exposes domain destinations', () {
const service = FlexibleTaskActionService();
final task = Task.quickCapture(
id: 'task-1',
title: 'Handle paperwork',
createdAt: now,
).copyWith(status: TaskStatus.planned);
final result = service.apply(
task: task,
action: FlexibleTaskQuickAction.push,
updatedAt: now,
);
expect(result.task, same(task));
expect(result.pushDestinations, const [
PushDestination.nextAvailableSlot,
PushDestination.tomorrowTopOfQueue,
PushDestination.backlog,
]);
expect(result.pushDestinations, isNot(contains('laterToday')));
expect(result.changedTask, isFalse);
});
test('flexible quick action break up starts child task flow', () {
const service = FlexibleTaskActionService();
final task = Task.quickCapture(
id: 'task-1',
title: 'Handle paperwork',
createdAt: now,
).copyWith(status: TaskStatus.planned);
final result = service.apply(
task: task,
action: FlexibleTaskQuickAction.breakUp,
updatedAt: now,
);
expect(result.task, same(task));
expect(result.startsChildTaskFlow, isTrue);
expect(result.changedTask, isFalse);
});
});
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 respects expanded locked blocks', () {
final backlogTask = Task.quickCapture(
id: 'backlog-1',
title: 'Start laundry',
createdAt: now,
).copyWith(durationMinutes: 15);
final workHours = LockedBlock(
id: 'work-hours',
name: 'Work',
startTime: const ClockTime(hour: 13, minute: 0),
endTime: const ClockTime(hour: 13, minute: 20),
recurrence: const LockedBlockRecurrence.weekly(
weekdays: {LockedWeekday.friday},
),
hiddenByDefault: true,
createdAt: now,
updatedAt: now,
);
final replacement = LockedBlockOverride.replace(
id: 'longer-work',
lockedBlockId: 'work-hours',
date: DateTime(2026, 6, 19),
startTime: const ClockTime(hour: 13, minute: 0),
endTime: const ClockTime(hour: 13, minute: 30),
createdAt: now,
);
final lockedIntervals = lockedSchedulingIntervalsForDay(
blocks: [workHours],
overrides: [replacement],
date: DateTime(2026, 6, 19),
);
final result = engine.insertBacklogTaskIntoNextAvailableSlot(
input: SchedulingInput(
tasks: [backlogTask],
window: SchedulingWindow(
start: DateTime(2026, 6, 19, 13),
end: DateTime(2026, 6, 19, 14),
),
lockedIntervals: lockedIntervals,
),
taskId: 'backlog-1',
updatedAt: now,
);
final inserted = result.tasks.single;
expect(lockedIntervals.single.start, DateTime(2026, 6, 19, 13));
expect(lockedIntervals.single.end, DateTime(2026, 6, 19, 13, 30));
expect(inserted.scheduledStart, DateTime(2026, 6, 19, 13, 30));
expect(inserted.scheduledEnd, DateTime(2026, 6, 19, 13, 45));
});
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));
});
});
}