focus-flow/test/edge_case_regression_test.dart

796 lines
24 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);
const utcTimeZoneId = 'UTC';
const utcResolver = FixedOffsetTimeZoneResolver.utc();
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: CivilDate(2026, 6, 19),
timeZoneId: utcTimeZoneId,
timeZoneResolver: utcResolver,
);
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);
}