792 lines
23 KiB
Dart
792 lines
23 KiB
Dart
import 'package:adhd_scheduler_core/scheduler_core.dart';
|
|
import 'package:test/test.dart';
|
|
|
|
void main() {
|
|
group('Block 06.3 domain model regressions', () {
|
|
final now = DateTime(2026, 6, 19, 12);
|
|
|
|
test('quick capture trims titles before storing them', () {
|
|
final task = Task.quickCapture(
|
|
id: 'capture-1',
|
|
title: ' Refill medication ',
|
|
createdAt: now,
|
|
);
|
|
|
|
expect(task.title, 'Refill medication');
|
|
});
|
|
|
|
test('copyWith can clear schedule while preserving omitted values', () {
|
|
final scheduled = Task.quickCapture(
|
|
id: 'task-1',
|
|
title: 'Send invoice',
|
|
createdAt: now,
|
|
projectId: 'admin',
|
|
priority: PriorityLevel.high,
|
|
reward: RewardLevel.medium,
|
|
).copyWith(
|
|
status: TaskStatus.planned,
|
|
durationMinutes: 30,
|
|
scheduledStart: DateTime(2026, 6, 19, 13),
|
|
scheduledEnd: DateTime(2026, 6, 19, 13, 30),
|
|
);
|
|
|
|
final cleared = scheduled.copyWith(clearSchedule: true);
|
|
|
|
expect(cleared.scheduledStart, isNull);
|
|
expect(cleared.scheduledEnd, isNull);
|
|
expect(cleared.id, scheduled.id);
|
|
expect(cleared.title, scheduled.title);
|
|
expect(cleared.projectId, 'admin');
|
|
expect(cleared.priority, PriorityLevel.high);
|
|
expect(cleared.reward, RewardLevel.medium);
|
|
expect(cleared.durationMinutes, 30);
|
|
});
|
|
|
|
test('project profile trims and validates task titles like quick capture',
|
|
() {
|
|
final project = ProjectProfile(
|
|
id: 'health',
|
|
name: 'Health',
|
|
colorKey: 'green',
|
|
);
|
|
|
|
final task = project.createTask(
|
|
id: 'task-1',
|
|
title: ' Book appointment ',
|
|
createdAt: now,
|
|
);
|
|
|
|
expect(task.title, 'Book appointment');
|
|
expect(
|
|
() => project.createTask(
|
|
id: 'task-2',
|
|
title: ' ',
|
|
createdAt: now,
|
|
),
|
|
throwsArgumentError,
|
|
);
|
|
});
|
|
});
|
|
|
|
group('Block 06.3 scheduling regressions', () {
|
|
final now = DateTime(2026, 6, 19, 12);
|
|
const engine = SchedulingEngine();
|
|
|
|
test('next-available insertion uses earliest fitting open slot', () {
|
|
final backlog = backlogTask(
|
|
id: 'new-task',
|
|
title: 'Make call',
|
|
createdAt: now,
|
|
durationMinutes: 15,
|
|
);
|
|
final laterFlexible = plannedFlexibleTask(
|
|
id: 'later-flex',
|
|
title: 'Fold laundry',
|
|
createdAt: now,
|
|
start: DateTime(2026, 6, 19, 10),
|
|
durationMinutes: 30,
|
|
);
|
|
|
|
final result = engine.insertBacklogTaskIntoNextAvailableSlot(
|
|
input: SchedulingInput(
|
|
tasks: [backlog, laterFlexible],
|
|
window: SchedulingWindow(
|
|
start: DateTime(2026, 6, 19, 9),
|
|
end: DateTime(2026, 6, 19, 11),
|
|
),
|
|
lockedIntervals: [
|
|
TimeInterval(
|
|
start: DateTime(2026, 6, 19, 9),
|
|
end: DateTime(2026, 6, 19, 9, 20),
|
|
label: 'locked',
|
|
),
|
|
],
|
|
),
|
|
taskId: 'new-task',
|
|
updatedAt: now,
|
|
);
|
|
final inserted = taskById(result.tasks, 'new-task');
|
|
|
|
expect(inserted.status, TaskStatus.planned);
|
|
expect(inserted.scheduledStart, DateTime(2026, 6, 19, 9, 20));
|
|
expect(inserted.scheduledEnd, DateTime(2026, 6, 19, 9, 35));
|
|
});
|
|
|
|
test('no-fit insertion leaves the task backlogged and unplaced', () {
|
|
final backlog = backlogTask(
|
|
id: 'new-task',
|
|
title: 'Make call',
|
|
createdAt: now,
|
|
durationMinutes: 45,
|
|
);
|
|
|
|
final result = engine.insertBacklogTaskIntoNextAvailableSlot(
|
|
input: SchedulingInput(
|
|
tasks: [backlog],
|
|
window: SchedulingWindow(
|
|
start: DateTime(2026, 6, 19, 9),
|
|
end: DateTime(2026, 6, 19, 9, 30),
|
|
),
|
|
),
|
|
taskId: 'new-task',
|
|
updatedAt: now,
|
|
);
|
|
final unchanged = taskById(result.tasks, 'new-task');
|
|
|
|
expect(unchanged.status, TaskStatus.backlog);
|
|
expect(unchanged.scheduledStart, isNull);
|
|
expect(unchanged.scheduledEnd, isNull);
|
|
expect(result.notices.single.type, SchedulingNoticeType.overflow);
|
|
});
|
|
|
|
test('pushing flexible tasks preserves order without moving fixed items',
|
|
() {
|
|
final first = plannedFlexibleTask(
|
|
id: 'first',
|
|
title: 'Email reply',
|
|
createdAt: now,
|
|
start: DateTime(2026, 6, 19, 9),
|
|
durationMinutes: 15,
|
|
);
|
|
final second = plannedFlexibleTask(
|
|
id: 'second',
|
|
title: 'Laundry',
|
|
createdAt: now,
|
|
start: DateTime(2026, 6, 19, 9, 15),
|
|
durationMinutes: 15,
|
|
);
|
|
final inflexible = fixedTask(
|
|
id: 'appointment',
|
|
type: TaskType.inflexible,
|
|
createdAt: now,
|
|
start: DateTime(2026, 6, 19, 10),
|
|
durationMinutes: 15,
|
|
);
|
|
final critical = fixedTask(
|
|
id: 'meds',
|
|
type: TaskType.critical,
|
|
createdAt: now,
|
|
start: DateTime(2026, 6, 19, 10, 15),
|
|
durationMinutes: 15,
|
|
);
|
|
final locked = fixedTask(
|
|
id: 'locked-task',
|
|
type: TaskType.locked,
|
|
createdAt: now,
|
|
start: DateTime(2026, 6, 19, 9, 30),
|
|
durationMinutes: 15,
|
|
);
|
|
|
|
final result = engine.pushFlexibleTaskToNextAvailableSlot(
|
|
input: SchedulingInput(
|
|
tasks: [first, second, locked, inflexible, critical],
|
|
window: SchedulingWindow(
|
|
start: DateTime(2026, 6, 19, 9),
|
|
end: DateTime(2026, 6, 19, 11),
|
|
),
|
|
),
|
|
taskId: 'first',
|
|
updatedAt: now,
|
|
);
|
|
|
|
expect(
|
|
result.changes.map((change) => change.taskId),
|
|
['first', 'second'],
|
|
);
|
|
expect(
|
|
taskById(result.tasks, 'first').scheduledStart,
|
|
DateTime(2026, 6, 19, 9, 15),
|
|
);
|
|
expect(
|
|
taskById(result.tasks, 'second').scheduledStart,
|
|
DateTime(2026, 6, 19, 9, 45),
|
|
);
|
|
expect(taskById(result.tasks, 'appointment').type, TaskType.inflexible);
|
|
expect(taskById(result.tasks, 'appointment').scheduledStart,
|
|
inflexible.scheduledStart);
|
|
expect(taskById(result.tasks, 'meds').type, TaskType.critical);
|
|
expect(taskById(result.tasks, 'meds').scheduledStart,
|
|
critical.scheduledStart);
|
|
expect(taskById(result.tasks, 'locked-task').type, TaskType.locked);
|
|
expect(taskById(result.tasks, 'locked-task').scheduledStart,
|
|
locked.scheduledStart);
|
|
});
|
|
|
|
test('rollover uses an explicit source window and ignores future tasks',
|
|
() {
|
|
final sourceWindow = SchedulingWindow(
|
|
start: DateTime(2026, 6, 19),
|
|
end: DateTime(2026, 6, 20),
|
|
);
|
|
final targetWindow = SchedulingWindow(
|
|
start: DateTime(2026, 6, 20, 9),
|
|
end: DateTime(2026, 6, 20, 12),
|
|
);
|
|
final sourcePlanned = plannedFlexibleTask(
|
|
id: 'source-planned',
|
|
title: 'Source planned',
|
|
createdAt: now,
|
|
start: DateTime(2026, 6, 19, 13),
|
|
durationMinutes: 15,
|
|
);
|
|
final sourceActive = plannedFlexibleTask(
|
|
id: 'source-active',
|
|
title: 'Source active',
|
|
createdAt: now,
|
|
start: DateTime(2026, 6, 19, 14),
|
|
durationMinutes: 15,
|
|
).copyWith(status: TaskStatus.active);
|
|
final completed = plannedFlexibleTask(
|
|
id: 'completed',
|
|
title: 'Completed',
|
|
createdAt: now,
|
|
start: DateTime(2026, 6, 19, 15),
|
|
durationMinutes: 15,
|
|
).copyWith(status: TaskStatus.completed);
|
|
final cancelled = plannedFlexibleTask(
|
|
id: 'cancelled',
|
|
title: 'Cancelled',
|
|
createdAt: now,
|
|
start: DateTime(2026, 6, 19, 16),
|
|
durationMinutes: 15,
|
|
).copyWith(status: TaskStatus.cancelled);
|
|
final backlogged = backlogTask(
|
|
id: 'backlog',
|
|
title: 'Already backlog',
|
|
createdAt: now,
|
|
durationMinutes: 15,
|
|
);
|
|
final critical = fixedTask(
|
|
id: 'critical',
|
|
type: TaskType.critical,
|
|
createdAt: now,
|
|
start: DateTime(2026, 6, 19, 17),
|
|
durationMinutes: 15,
|
|
);
|
|
final inflexible = fixedTask(
|
|
id: 'inflexible',
|
|
type: TaskType.inflexible,
|
|
createdAt: now,
|
|
start: DateTime(2026, 6, 19, 18),
|
|
durationMinutes: 15,
|
|
);
|
|
final futureFlexible = plannedFlexibleTask(
|
|
id: 'future',
|
|
title: 'Future',
|
|
createdAt: now,
|
|
start: DateTime(2026, 6, 21, 9),
|
|
durationMinutes: 15,
|
|
);
|
|
final tomorrowFlexible = plannedFlexibleTask(
|
|
id: 'tomorrow',
|
|
title: 'Tomorrow',
|
|
createdAt: now,
|
|
start: DateTime(2026, 6, 20, 9),
|
|
durationMinutes: 30,
|
|
);
|
|
|
|
final result = engine.rollOverUnfinishedFlexibleTasks(
|
|
input: SchedulingInput(
|
|
tasks: [
|
|
futureFlexible,
|
|
sourceActive,
|
|
sourcePlanned,
|
|
completed,
|
|
cancelled,
|
|
backlogged,
|
|
critical,
|
|
inflexible,
|
|
tomorrowFlexible,
|
|
],
|
|
window: targetWindow,
|
|
),
|
|
sourceWindow: sourceWindow,
|
|
updatedAt: now,
|
|
);
|
|
|
|
expect(taskById(result.tasks, 'source-planned').scheduledStart,
|
|
DateTime(2026, 6, 20, 9));
|
|
expect(taskById(result.tasks, 'source-active').scheduledStart,
|
|
DateTime(2026, 6, 20, 9, 15));
|
|
expect(taskById(result.tasks, 'tomorrow').scheduledStart,
|
|
DateTime(2026, 6, 20, 9, 30));
|
|
expect(taskById(result.tasks, 'future').scheduledStart,
|
|
futureFlexible.scheduledStart);
|
|
expect(taskById(result.tasks, 'completed').status, TaskStatus.completed);
|
|
expect(taskById(result.tasks, 'cancelled').status, TaskStatus.cancelled);
|
|
expect(taskById(result.tasks, 'backlog').status, TaskStatus.backlog);
|
|
expect(taskById(result.tasks, 'critical').type, TaskType.critical);
|
|
expect(taskById(result.tasks, 'inflexible').type, TaskType.inflexible);
|
|
expect(
|
|
result.changes.map((change) => change.taskId),
|
|
['source-active', 'source-planned', 'tomorrow'],
|
|
);
|
|
});
|
|
});
|
|
|
|
group('Block 06.3 backlog regressions', () {
|
|
final now = DateTime(2026, 6, 19, 12);
|
|
|
|
test('filters only include backlog tasks in the expected category', () {
|
|
final inbox = backlogTask(
|
|
id: 'inbox',
|
|
title: 'Inbox',
|
|
createdAt: now,
|
|
reward: RewardLevel.low,
|
|
);
|
|
final pushed = backlogTask(
|
|
id: 'pushed',
|
|
title: 'Pushed',
|
|
createdAt: now,
|
|
projectId: 'home',
|
|
reward: RewardLevel.low,
|
|
).copyWith(stats: const TaskStatistics(manuallyPushedCount: 1));
|
|
final criticalMissed = backlogTask(
|
|
id: 'critical',
|
|
title: 'Critical',
|
|
createdAt: now,
|
|
type: TaskType.critical,
|
|
projectId: 'health',
|
|
reward: RewardLevel.low,
|
|
).copyWith(stats: const TaskStatistics(missedCount: 1));
|
|
final wishlist = backlogTask(
|
|
id: 'wishlist',
|
|
title: 'Wishlist',
|
|
createdAt: now,
|
|
projectId: 'ideas',
|
|
reward: RewardLevel.low,
|
|
backlogTags: const {BacklogTag.wishlist},
|
|
);
|
|
final stale = backlogTask(
|
|
id: 'stale',
|
|
title: 'Stale',
|
|
createdAt: now.subtract(const Duration(days: 40)),
|
|
projectId: 'archive',
|
|
reward: RewardLevel.low,
|
|
);
|
|
final noReward = backlogTask(
|
|
id: 'no-reward',
|
|
title: 'No reward',
|
|
createdAt: now,
|
|
projectId: 'admin',
|
|
);
|
|
final plannedNoReward = noReward.copyWith(
|
|
id: 'planned-no-reward',
|
|
status: TaskStatus.planned,
|
|
);
|
|
final view = BacklogView(
|
|
tasks: [
|
|
inbox,
|
|
pushed,
|
|
criticalMissed,
|
|
wishlist,
|
|
stale,
|
|
noReward,
|
|
plannedNoReward,
|
|
],
|
|
now: now,
|
|
);
|
|
|
|
expect(view.filter(BacklogFilter.inbox).map((task) => task.id), [
|
|
'inbox',
|
|
]);
|
|
expect(view.filter(BacklogFilter.pushed).map((task) => task.id), [
|
|
'pushed',
|
|
]);
|
|
expect(view.filter(BacklogFilter.criticalMissed).map((task) => task.id), [
|
|
'critical',
|
|
]);
|
|
expect(view.filter(BacklogFilter.wishlist).map((task) => task.id), [
|
|
'wishlist',
|
|
]);
|
|
expect(view.filter(BacklogFilter.stale).map((task) => task.id), [
|
|
'stale',
|
|
]);
|
|
expect(view.filter(BacklogFilter.noRewardSet).map((task) => task.id), [
|
|
'no-reward',
|
|
]);
|
|
});
|
|
|
|
test('priority sorting preserves input order for equal priorities', () {
|
|
final first = backlogTask(id: 'first', title: 'First', createdAt: now);
|
|
final second = backlogTask(id: 'second', title: 'Second', createdAt: now);
|
|
final third = backlogTask(id: 'third', title: 'Third', createdAt: now);
|
|
final view = BacklogView(tasks: [second, first, third], now: now);
|
|
|
|
expect(view.sorted(BacklogSortKey.priority).map((task) => task.id), [
|
|
'second',
|
|
'first',
|
|
'third',
|
|
]);
|
|
});
|
|
|
|
test('reward not-set sorts as distinct from very-low reward', () {
|
|
final notSet = backlogTask(
|
|
id: 'not-set',
|
|
title: 'Unknown reward',
|
|
createdAt: now,
|
|
reward: RewardLevel.notSet,
|
|
difficulty: DifficultyLevel.easy,
|
|
);
|
|
final veryLow = backlogTask(
|
|
id: 'very-low',
|
|
title: 'Very low reward',
|
|
createdAt: now,
|
|
reward: RewardLevel.veryLow,
|
|
difficulty: DifficultyLevel.easy,
|
|
);
|
|
final view = BacklogView(tasks: [notSet, veryLow], now: now);
|
|
|
|
expect(
|
|
view.sorted(BacklogSortKey.rewardVsEffort).map((task) => task.id), [
|
|
'very-low',
|
|
'not-set',
|
|
]);
|
|
});
|
|
|
|
test('age sorting preserves input order when createdAt values match', () {
|
|
final createdAt = DateTime(2026, 6, 1);
|
|
final first =
|
|
backlogTask(id: 'first', title: 'First', createdAt: createdAt);
|
|
final second =
|
|
backlogTask(id: 'second', title: 'Second', createdAt: createdAt);
|
|
final view = BacklogView(tasks: [second, first], now: now);
|
|
|
|
expect(view.sorted(BacklogSortKey.age).map((task) => task.id), [
|
|
'second',
|
|
'first',
|
|
]);
|
|
});
|
|
|
|
test('stale filter and marker use the same creation-age semantics', () {
|
|
final oldButRecentlyUpdated = backlogTask(
|
|
id: 'old',
|
|
title: 'Old idea',
|
|
createdAt: now.subtract(const Duration(days: 40)),
|
|
).copyWith(updatedAt: now);
|
|
final newButOldUpdate = backlogTask(
|
|
id: 'new',
|
|
title: 'New idea',
|
|
createdAt: now,
|
|
).copyWith(updatedAt: now.subtract(const Duration(days: 40)));
|
|
final view = BacklogView(
|
|
tasks: [oldButRecentlyUpdated, newButOldUpdate],
|
|
now: now,
|
|
);
|
|
|
|
expect(view.filter(BacklogFilter.stale).map((task) => task.id), ['old']);
|
|
expect(
|
|
view.stalenessMarkerFor(oldButRecentlyUpdated),
|
|
BacklogStalenessMarker.purple,
|
|
);
|
|
expect(
|
|
view.stalenessMarkerFor(newButOldUpdate),
|
|
BacklogStalenessMarker.green,
|
|
);
|
|
});
|
|
});
|
|
|
|
group('Block 06.3 quick capture regressions', () {
|
|
final now = DateTime(2026, 6, 19, 12);
|
|
const service = QuickCaptureService();
|
|
|
|
test('immediate scheduling uses normal insertion and push rules', () {
|
|
final existing = plannedFlexibleTask(
|
|
id: 'existing',
|
|
title: 'Existing',
|
|
createdAt: now,
|
|
start: DateTime(2026, 6, 19, 9),
|
|
durationMinutes: 15,
|
|
);
|
|
|
|
final result = service.capture(
|
|
QuickCaptureRequest(
|
|
id: 'capture',
|
|
title: 'Captured task',
|
|
createdAt: now,
|
|
addToNextAvailableSlot: true,
|
|
durationMinutes: 15,
|
|
),
|
|
schedulingInput: SchedulingInput(
|
|
tasks: [existing],
|
|
window: SchedulingWindow(
|
|
start: DateTime(2026, 6, 19, 9),
|
|
end: DateTime(2026, 6, 19, 10),
|
|
),
|
|
),
|
|
updatedAt: now,
|
|
);
|
|
final scheduledTasks = result.schedulingResult!.tasks;
|
|
|
|
expect(result.status, QuickCaptureStatus.scheduled);
|
|
expect(taskById(scheduledTasks, 'capture').scheduledStart,
|
|
DateTime(2026, 6, 19, 9));
|
|
expect(taskById(scheduledTasks, 'existing').scheduledStart,
|
|
DateTime(2026, 6, 19, 9, 15));
|
|
});
|
|
|
|
test('immediate scheduling requires enough scheduling data', () {
|
|
final result = service.capture(
|
|
QuickCaptureRequest(
|
|
id: 'capture',
|
|
title: 'Captured task',
|
|
createdAt: now,
|
|
addToNextAvailableSlot: true,
|
|
durationMinutes: 15,
|
|
),
|
|
updatedAt: now,
|
|
);
|
|
|
|
expect(result.status, QuickCaptureStatus.validationError);
|
|
expect(result.messages.single, 'Scheduling input is required.');
|
|
});
|
|
|
|
test('quick capture cannot create blank tasks', () {
|
|
expect(
|
|
() => service.capture(
|
|
QuickCaptureRequest(
|
|
id: 'capture',
|
|
title: ' ',
|
|
createdAt: now,
|
|
),
|
|
),
|
|
throwsArgumentError,
|
|
);
|
|
});
|
|
});
|
|
|
|
group('Block 06.3 locked-time regressions', () {
|
|
final now = DateTime(2026, 6, 19, 12);
|
|
|
|
test('locked occurrence exposes overlay data without becoming a task', () {
|
|
final block = LockedBlock(
|
|
id: 'work',
|
|
name: 'Work',
|
|
startTime: ClockTime(hour: 9, minute: 0),
|
|
endTime: ClockTime(hour: 17, minute: 0),
|
|
recurrence: LockedBlockRecurrence.weekly(
|
|
weekdays: {LockedWeekday.friday},
|
|
),
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
);
|
|
|
|
final expansion = expandLockedBlocksForDay(
|
|
blocks: [block],
|
|
overrides: const [],
|
|
date: DateTime(2026, 6, 19),
|
|
);
|
|
final occurrence = expansion.occurrences.single;
|
|
|
|
expect(occurrence, isA<LockedBlockOccurrence>());
|
|
expect(occurrence, isNot(isA<Task>()));
|
|
expect(occurrence.hiddenByDefault, isTrue);
|
|
expect(occurrence.schedulingInterval.label, 'Work');
|
|
expect(expansion.schedulingIntervals.single.label, 'Work');
|
|
});
|
|
|
|
test('completed tasks outside locked hours do not increment counters', () {
|
|
final task = plannedFlexibleTask(
|
|
id: 'task',
|
|
title: 'Task',
|
|
createdAt: now,
|
|
start: DateTime(2026, 6, 19, 8),
|
|
durationMinutes: 30,
|
|
).copyWith(status: TaskStatus.completed);
|
|
|
|
final tracked = trackCompletedDuringLockedHours(
|
|
task: task,
|
|
lockedIntervals: [
|
|
TimeInterval(
|
|
start: DateTime(2026, 6, 19, 9),
|
|
end: DateTime(2026, 6, 19, 10),
|
|
),
|
|
],
|
|
);
|
|
|
|
expect(tracked.stats.completedDuringLockedHoursCount, 0);
|
|
expect(tracked.stats.completedDuringLockedHoursMinutes, 0);
|
|
});
|
|
});
|
|
|
|
group('Block 06.3 action-service regressions', () {
|
|
final now = DateTime(2026, 6, 19, 12);
|
|
const service = FlexibleTaskActionService();
|
|
|
|
test('unsupported flexible quick action types fail predictably', () {
|
|
final inflexible = fixedTask(
|
|
id: 'appointment',
|
|
type: TaskType.inflexible,
|
|
createdAt: now,
|
|
start: DateTime(2026, 6, 19, 9),
|
|
durationMinutes: 30,
|
|
);
|
|
|
|
expect(
|
|
() => service.apply(
|
|
task: inflexible,
|
|
action: FlexibleTaskQuickAction.done,
|
|
updatedAt: now,
|
|
),
|
|
throwsArgumentError,
|
|
);
|
|
});
|
|
|
|
test('push backlog destination does not create duplicate task ids', () {
|
|
final task = plannedFlexibleTask(
|
|
id: 'task',
|
|
title: 'Task',
|
|
createdAt: now,
|
|
start: DateTime(2026, 6, 19, 9),
|
|
durationMinutes: 30,
|
|
);
|
|
|
|
final result = service.applyPushDestination(
|
|
destination: PushDestination.backlog,
|
|
input: SchedulingInput(
|
|
tasks: [task],
|
|
window: SchedulingWindow(
|
|
start: DateTime(2026, 6, 19, 9),
|
|
end: DateTime(2026, 6, 19, 10),
|
|
),
|
|
),
|
|
taskId: 'task',
|
|
updatedAt: now,
|
|
);
|
|
|
|
expect(result.schedulingResult.tasks.map((task) => task.id), ['task']);
|
|
expect(result.schedulingResult.tasks.toSet().length, 1);
|
|
expect(result.schedulingResult.changes.single.previousStart,
|
|
DateTime(2026, 6, 19, 9));
|
|
expect(result.schedulingResult.changes.single.nextStart, isNull);
|
|
});
|
|
|
|
test('public scheduling services do not reorder the input task list', () {
|
|
final first = plannedFlexibleTask(
|
|
id: 'first',
|
|
title: 'First',
|
|
createdAt: now,
|
|
start: DateTime(2026, 6, 19, 9),
|
|
durationMinutes: 15,
|
|
);
|
|
final second = plannedFlexibleTask(
|
|
id: 'second',
|
|
title: 'Second',
|
|
createdAt: now,
|
|
start: DateTime(2026, 6, 19, 9, 15),
|
|
durationMinutes: 15,
|
|
);
|
|
final tasks = [second, first];
|
|
|
|
const SchedulingEngine().pushFlexibleTaskToNextAvailableSlot(
|
|
input: SchedulingInput(
|
|
tasks: tasks,
|
|
window: SchedulingWindow(
|
|
start: DateTime(2026, 6, 19, 9),
|
|
end: DateTime(2026, 6, 19, 10),
|
|
),
|
|
),
|
|
taskId: 'first',
|
|
updatedAt: now,
|
|
);
|
|
|
|
expect(tasks.map((task) => task.id), ['second', 'first']);
|
|
});
|
|
|
|
test('invalid push destinations leave fixed task types unchanged', () {
|
|
final critical = fixedTask(
|
|
id: 'critical',
|
|
type: TaskType.critical,
|
|
createdAt: now,
|
|
start: DateTime(2026, 6, 19, 9),
|
|
durationMinutes: 30,
|
|
);
|
|
|
|
final result = service.applyPushDestination(
|
|
destination: PushDestination.nextAvailableSlot,
|
|
input: SchedulingInput(
|
|
tasks: [critical],
|
|
window: SchedulingWindow(
|
|
start: DateTime(2026, 6, 19, 9),
|
|
end: DateTime(2026, 6, 19, 10),
|
|
),
|
|
),
|
|
taskId: 'critical',
|
|
updatedAt: now,
|
|
);
|
|
|
|
expect(result.schedulingResult.notices.single.type,
|
|
SchedulingNoticeType.noFit);
|
|
expect(result.schedulingResult.tasks.single.type, TaskType.critical);
|
|
expect(result.schedulingResult.tasks.single.status, TaskStatus.planned);
|
|
});
|
|
});
|
|
}
|
|
|
|
Task backlogTask({
|
|
required String id,
|
|
required String title,
|
|
required DateTime createdAt,
|
|
String projectId = 'inbox',
|
|
TaskType type = TaskType.flexible,
|
|
RewardLevel reward = RewardLevel.notSet,
|
|
DifficultyLevel difficulty = DifficultyLevel.notSet,
|
|
int? durationMinutes,
|
|
Set<BacklogTag> backlogTags = const <BacklogTag>{},
|
|
}) {
|
|
return Task.quickCapture(
|
|
id: id,
|
|
title: title,
|
|
createdAt: createdAt,
|
|
projectId: projectId,
|
|
type: type,
|
|
reward: reward,
|
|
difficulty: difficulty,
|
|
backlogTags: backlogTags,
|
|
).copyWith(durationMinutes: durationMinutes);
|
|
}
|
|
|
|
Task plannedFlexibleTask({
|
|
required String id,
|
|
required String title,
|
|
required DateTime createdAt,
|
|
required DateTime start,
|
|
required int durationMinutes,
|
|
}) {
|
|
return backlogTask(
|
|
id: id,
|
|
title: title,
|
|
createdAt: createdAt,
|
|
durationMinutes: durationMinutes,
|
|
).copyWith(
|
|
status: TaskStatus.planned,
|
|
scheduledStart: start,
|
|
scheduledEnd: start.add(Duration(minutes: durationMinutes)),
|
|
);
|
|
}
|
|
|
|
Task fixedTask({
|
|
required String id,
|
|
required TaskType type,
|
|
required DateTime createdAt,
|
|
required DateTime start,
|
|
required int durationMinutes,
|
|
}) {
|
|
return Task(
|
|
id: id,
|
|
title: id,
|
|
projectId: 'fixed',
|
|
type: type,
|
|
status: TaskStatus.planned,
|
|
priority: PriorityLevel.medium,
|
|
durationMinutes: durationMinutes,
|
|
scheduledStart: start,
|
|
scheduledEnd: start.add(Duration(minutes: durationMinutes)),
|
|
createdAt: createdAt,
|
|
updatedAt: createdAt,
|
|
);
|
|
}
|
|
|
|
Task taskById(List<Task> tasks, String id) {
|
|
return tasks.singleWhere((task) => task.id == id);
|
|
}
|