forked from eva/focus-flow
test(tasks): add edge-case regression coverage before state transitions
This commit is contained in:
parent
b4e321da02
commit
5b49d4e8a6
8 changed files with 886 additions and 16 deletions
|
|
@ -30,7 +30,7 @@ Do not infer a new level name.
|
||||||
3. `V1_BLOCK_03_Scheduling_Engine.md`
|
3. `V1_BLOCK_03_Scheduling_Engine.md`
|
||||||
4. `V1_BLOCK_04_Backlog_Quick_Capture.md`
|
4. `V1_BLOCK_04_Backlog_Quick_Capture.md`
|
||||||
5. `V1_BLOCK_05_Recurring_Locked_Blocks.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`
|
7. `V1_BLOCK_07_Child_Tasks.md`
|
||||||
8. `V1_BLOCK_08_Today_Timeline_State.md`
|
8. `V1_BLOCK_08_Today_Timeline_State.md`
|
||||||
9. `V1_BLOCK_09_Persistence_Preparation.md`
|
9. `V1_BLOCK_09_Persistence_Preparation.md`
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
# V1 Block 06 — Task Actions and State Transitions
|
# 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.
|
Purpose: Define and implement the safe, low-friction actions available from task cards.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,7 @@ BREAKPOINT: Stop here. Confirm `extra high` mode before implementing Chunk 6.3,
|
||||||
|
|
||||||
Recommended Codex level: extra high
|
Recommended Codex level: extra high
|
||||||
|
|
||||||
Status: Planned
|
Status: Completed
|
||||||
|
|
||||||
Purpose:
|
Purpose:
|
||||||
|
|
||||||
|
|
@ -236,6 +236,18 @@ Commit suggestion:
|
||||||
test(tasks): add edge-case regression coverage before state transitions
|
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.
|
BREAKPOINT: Stop here. Confirm `medium` mode before implementing Chunk 6.4 required task state transitions.
|
||||||
|
|
||||||
## Chunk 6.4 — Required task states
|
## Chunk 6.4 — Required task states
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,14 @@ Future sync layer, if explicitly planned
|
||||||
|
|
||||||
The scheduling core must never move locked or inflexible blocks during automatic rescheduling.
|
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
|
## Persistence direction
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// Backlog query helpers for the ADHD scheduling core.
|
// Backlog query helpers for the ADHD scheduling core.
|
||||||
//
|
//
|
||||||
// The backlog is not a separate database table in this starter model. It is a
|
// The backlog is not a separate database table in this starter model. It is a
|
||||||
// view over tasks whose `Task.status` is `TaskStatus.backlog`. This file keeps
|
// view over tasks whose `Task.status` is `TaskStatus.backlog`. This file keeps
|
||||||
|
|
@ -134,7 +134,11 @@ class BacklogView {
|
||||||
/// Clock value supplied by the caller so age/staleness behavior is testable.
|
/// Clock value supplied by the caller so age/staleness behavior is testable.
|
||||||
final DateTime now;
|
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;
|
final Duration staleAfter;
|
||||||
|
|
||||||
/// Color-bucket threshold configuration for backlog aging indicators.
|
/// 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
|
/// reordered by a read operation. The final list is unmodifiable to make that
|
||||||
/// intent explicit to callers.
|
/// intent explicit to callers.
|
||||||
List<Task> sorted(BacklogSortKey sortKey) {
|
List<Task> sorted(BacklogSortKey sortKey) {
|
||||||
final sortedTasks = [...backlogTasks];
|
final sortedTasks = backlogTasks
|
||||||
sortedTasks.sort((a, b) => _compareTasks(a, b, sortKey));
|
.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.
|
/// Return the green/blue/purple marker for one task.
|
||||||
|
|
@ -187,7 +209,7 @@ class BacklogView {
|
||||||
BacklogFilter.criticalMissed =>
|
BacklogFilter.criticalMissed =>
|
||||||
task.type == TaskType.critical && task.stats.missedCount > 0,
|
task.type == TaskType.critical && task.stats.missedCount > 0,
|
||||||
BacklogFilter.wishlist => task.backlogTags.contains(BacklogTag.wishlist),
|
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,
|
BacklogFilter.noRewardSet => task.reward == RewardLevel.notSet,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -261,3 +283,14 @@ int _rewardVsEffortScore(Task task) {
|
||||||
int _timesPushed(Task task) {
|
int _timesPushed(Task task) {
|
||||||
return task.stats.manuallyPushedCount + task.stats.autoPushedCount;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -441,9 +441,14 @@ class ProjectProfile {
|
||||||
Set<BacklogTag> backlogTags = const <BacklogTag>{},
|
Set<BacklogTag> backlogTags = const <BacklogTag>{},
|
||||||
DateTime? updatedAt,
|
DateTime? updatedAt,
|
||||||
}) {
|
}) {
|
||||||
|
final trimmedTitle = title.trim();
|
||||||
|
if (trimmedTitle.isEmpty) {
|
||||||
|
throw ArgumentError.value(title, 'title', 'Title is required.');
|
||||||
|
}
|
||||||
|
|
||||||
return Task(
|
return Task(
|
||||||
id: id,
|
id: id,
|
||||||
title: title,
|
title: trimmedTitle,
|
||||||
projectId: this.id,
|
projectId: this.id,
|
||||||
type: type,
|
type: type,
|
||||||
status: status,
|
status: status,
|
||||||
|
|
|
||||||
|
|
@ -511,20 +511,29 @@ class SchedulingEngine {
|
||||||
/// Rollover is bulk push behavior for day-end recovery. It collects unfinished
|
/// Rollover is bulk push behavior for day-end recovery. It collects unfinished
|
||||||
/// flexible tasks outside the target window, preserves their relative order,
|
/// flexible tasks outside the target window, preserves their relative order,
|
||||||
/// then places them at the start of the new window while shifting already
|
/// 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({
|
SchedulingResult rollOverUnfinishedFlexibleTasks({
|
||||||
required SchedulingInput input,
|
required SchedulingInput input,
|
||||||
|
SchedulingWindow? sourceWindow,
|
||||||
DateTime? updatedAt,
|
DateTime? updatedAt,
|
||||||
}) {
|
}) {
|
||||||
// Build the explicit queue of tasks to roll before asking the planner to
|
// Build the explicit queue of tasks to roll before asking the planner to
|
||||||
// place anything. This keeps selection separate from placement.
|
// place anything. This keeps selection separate from placement.
|
||||||
final rolledItems = <_PlacementItem>[];
|
final rolledItems = <_PlacementItem>[];
|
||||||
final rolloverTasks = input.flexibleTasks
|
final rolloverTasks = input.flexibleTasks
|
||||||
.where((task) => _shouldRollOver(task, input.window))
|
.where((task) => _shouldRollOver(
|
||||||
|
task: task,
|
||||||
|
targetWindow: input.window,
|
||||||
|
sourceWindow: sourceWindow,
|
||||||
|
))
|
||||||
.toList()
|
.toList()
|
||||||
..sort((a, b) {
|
..sort((a, b) {
|
||||||
final aStart = a.scheduledStart ?? input.window.start;
|
final aStart =
|
||||||
final bStart = b.scheduledStart ?? input.window.start;
|
a.scheduledStart ?? sourceWindow?.start ?? input.window.start;
|
||||||
|
final bStart =
|
||||||
|
b.scheduledStart ?? sourceWindow?.start ?? input.window.start;
|
||||||
|
|
||||||
return aStart.compareTo(bStart);
|
return aStart.compareTo(bStart);
|
||||||
});
|
});
|
||||||
|
|
@ -1269,13 +1278,21 @@ SchedulingResult _applyRolloverPlacement({
|
||||||
///
|
///
|
||||||
/// Planned/active flexible tasks already inside the target window are not rolled
|
/// 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.
|
/// 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 interval = _scheduledIntervalFor(task);
|
||||||
final isTomorrowTask =
|
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 &&
|
return task.isFlexible &&
|
||||||
!isTomorrowTask &&
|
!isTomorrowTask &&
|
||||||
|
isInSourceWindow &&
|
||||||
(task.status == TaskStatus.planned || task.status == TaskStatus.active);
|
(task.status == TaskStatus.planned || task.status == TaskStatus.active);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
792
test/edge_case_regression_test.dart
Normal file
792
test/edge_case_regression_test.dart
Normal 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);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue