test(tasks): add edge-case regression coverage before state transitions

This commit is contained in:
Ashley Venn 2026-06-24 11:18:34 -07:00
parent b4e321da02
commit 5b49d4e8a6
8 changed files with 886 additions and 16 deletions

View file

@ -30,7 +30,7 @@ Do not infer a new level name.
3. `V1_BLOCK_03_Scheduling_Engine.md`
4. `V1_BLOCK_04_Backlog_Quick_Capture.md`
5. `V1_BLOCK_05_Recurring_Locked_Blocks.md`
6. `V1_BLOCK_06_Task_Actions_State_Transitions.md`
6. `V1_BLOCK_06_Task_Actions_State_Transitions_UPDATED.md`
7. `V1_BLOCK_07_Child_Tasks.md`
8. `V1_BLOCK_08_Today_Timeline_State.md`
9. `V1_BLOCK_09_Persistence_Preparation.md`

View file

@ -1,6 +1,9 @@
# V1 Block 06 — Task Actions and State Transitions
Status: Planned
Status: Superseded by `V1_BLOCK_06_Task_Actions_State_Transitions_UPDATED.md`
Use the updated Block 06 plan for remaining work. The updated plan inserts the
Chunk 6.3 edge-case regression suite before required task state transitions.
Purpose: Define and implement the safe, low-friction actions available from task cards.

View file

@ -85,7 +85,7 @@ BREAKPOINT: Stop here. Confirm `extra high` mode before implementing Chunk 6.3,
Recommended Codex level: extra high
Status: Planned
Status: Completed
Purpose:
@ -236,6 +236,18 @@ Commit suggestion:
test(tasks): add edge-case regression coverage before state transitions
```
Execution notes:
- Added `test/edge_case_regression_test.dart` as a dedicated Block 06.3 regression suite.
- Covered task title trimming/validation, `copyWith(clearSchedule: true)`, ProjectProfile task creation validation, flexible insertion no-fit safety, push order/fixed-item preservation, explicit source-window rollover safety, stable backlog sorting, consistent backlog staleness timestamps, quick capture scheduling validation, locked overlay-domain data, locked-hour counters, action-service unsupported-type handling, duplicate-id safety, change metadata, and input-list immutability.
- Added optional `sourceWindow` to `SchedulingEngine.rollOverUnfinishedFlexibleTasks` so day-end rollover can explicitly limit rolled tasks to the intended source day while preserving existing callers.
- Updated backlog stale filtering to use the same creation-age basis as staleness markers; V1 still documents that a separate backlog-entered timestamp belongs to later persistence work.
- Made `ProjectProfile.createTask` trim and reject blank titles, matching quick-capture validation.
- Made backlog sorting stable for equal sort keys by preserving source-list order.
- No Required Task States implementation was added in this chunk.
- No Surprise Task Logging implementation was added in this chunk.
- `dart format lib test`, `dart analyze`, `dart test`, and `git diff --check` passed.
BREAKPOINT: Stop here. Confirm `medium` mode before implementing Chunk 6.4 required task state transitions.
## Chunk 6.4 — Required task states

View file

@ -31,6 +31,14 @@ Future sync layer, if explicitly planned
The scheduling core must never move locked or inflexible blocks during automatic rescheduling.
Flexible end-of-day rollover should use an explicit source-day window when
available. That prevents planned flexible tasks from future days from being
pulled into tomorrow during recovery.
Backlog staleness in the current V1 domain model uses task creation age for both
the stale filter and green/blue/purple marker. A later MongoDB-backed
persistence pass may add a separate backlog-entered timestamp, but the current
core keeps the timestamp semantics consistent and database-independent.
## Persistence direction

View file

@ -134,7 +134,11 @@ class BacklogView {
/// Clock value supplied by the caller so age/staleness behavior is testable.
final DateTime now;
/// Age since [Task.updatedAt] that qualifies for the `stale` filter.
/// Age since [Task.createdAt] that qualifies for the `stale` filter.
///
/// V1 does not yet store a separate "entered backlog at" timestamp. Until
/// persistence adds that field, both stale filtering and visual staleness use
/// task creation age so they do not disagree after a task edit.
final Duration staleAfter;
/// Color-bucket threshold configuration for backlog aging indicators.
@ -164,10 +168,28 @@ class BacklogView {
/// reordered by a read operation. The final list is unmodifiable to make that
/// intent explicit to callers.
List<Task> sorted(BacklogSortKey sortKey) {
final sortedTasks = [...backlogTasks];
sortedTasks.sort((a, b) => _compareTasks(a, b, sortKey));
final sortedTasks = backlogTasks
.asMap()
.entries
.map(
(entry) => _IndexedTask(
task: entry.value,
originalIndex: entry.key,
),
)
.toList(growable: false);
sortedTasks.sort((a, b) {
final comparison = _compareTasks(a.task, b.task, sortKey);
if (comparison != 0) {
return comparison;
}
return List<Task>.unmodifiable(sortedTasks);
return a.originalIndex.compareTo(b.originalIndex);
});
return List<Task>.unmodifiable(
sortedTasks.map((indexedTask) => indexedTask.task),
);
}
/// Return the green/blue/purple marker for one task.
@ -187,7 +209,7 @@ class BacklogView {
BacklogFilter.criticalMissed =>
task.type == TaskType.critical && task.stats.missedCount > 0,
BacklogFilter.wishlist => task.backlogTags.contains(BacklogTag.wishlist),
BacklogFilter.stale => now.difference(task.updatedAt) >= staleAfter,
BacklogFilter.stale => now.difference(task.createdAt) >= staleAfter,
BacklogFilter.noRewardSet => task.reward == RewardLevel.notSet,
};
}
@ -261,3 +283,14 @@ int _rewardVsEffortScore(Task task) {
int _timesPushed(Task task) {
return task.stats.manuallyPushedCount + task.stats.autoPushedCount;
}
/// Backlog task paired with its source-list position for stable sorting.
class _IndexedTask {
const _IndexedTask({
required this.task,
required this.originalIndex,
});
final Task task;
final int originalIndex;
}

View file

@ -441,9 +441,14 @@ class ProjectProfile {
Set<BacklogTag> backlogTags = const <BacklogTag>{},
DateTime? updatedAt,
}) {
final trimmedTitle = title.trim();
if (trimmedTitle.isEmpty) {
throw ArgumentError.value(title, 'title', 'Title is required.');
}
return Task(
id: id,
title: title,
title: trimmedTitle,
projectId: this.id,
type: type,
status: status,

View file

@ -511,20 +511,29 @@ class SchedulingEngine {
/// Rollover is bulk push behavior for day-end recovery. It collects unfinished
/// flexible tasks outside the target window, preserves their relative order,
/// then places them at the start of the new window while shifting already
/// planned flexible tasks as needed.
/// planned flexible tasks as needed. When [sourceWindow] is supplied, only
/// unfinished flexible tasks from that source window are rolled over. This
/// prevents future planned tasks from being pulled into tomorrow accidentally.
SchedulingResult rollOverUnfinishedFlexibleTasks({
required SchedulingInput input,
SchedulingWindow? sourceWindow,
DateTime? updatedAt,
}) {
// Build the explicit queue of tasks to roll before asking the planner to
// place anything. This keeps selection separate from placement.
final rolledItems = <_PlacementItem>[];
final rolloverTasks = input.flexibleTasks
.where((task) => _shouldRollOver(task, input.window))
.where((task) => _shouldRollOver(
task: task,
targetWindow: input.window,
sourceWindow: sourceWindow,
))
.toList()
..sort((a, b) {
final aStart = a.scheduledStart ?? input.window.start;
final bStart = b.scheduledStart ?? input.window.start;
final aStart =
a.scheduledStart ?? sourceWindow?.start ?? input.window.start;
final bStart =
b.scheduledStart ?? sourceWindow?.start ?? input.window.start;
return aStart.compareTo(bStart);
});
@ -1269,13 +1278,21 @@ SchedulingResult _applyRolloverPlacement({
///
/// Planned/active flexible tasks already inside the target window are not rolled
/// again; they are handled as existing tasks that may shift to make room.
bool _shouldRollOver(Task task, SchedulingWindow tomorrowWindow) {
bool _shouldRollOver({
required Task task,
required SchedulingWindow targetWindow,
SchedulingWindow? sourceWindow,
}) {
final interval = _scheduledIntervalFor(task);
final isTomorrowTask =
interval != null && interval.overlaps(tomorrowWindow.interval);
interval != null && interval.overlaps(targetWindow.interval);
final isInSourceWindow = sourceWindow == null ||
(interval != null && sourceWindow.contains(interval));
return task.isFlexible &&
!isTomorrowTask &&
isInSourceWindow &&
(task.status == TaskStatus.planned || task.status == TaskStatus.active);
}

View file

@ -0,0 +1,792 @@
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',
() {
const 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: const ClockTime(hour: 9, minute: 0),
endTime: const ClockTime(hour: 17, minute: 0),
recurrence: const 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);
}